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.
788 lines
32 KiB
Rust
788 lines
32 KiB
Rust
//! Shell command suggestion controller.
|
|
//!
|
|
//! Manages ghost text state, progressive matching, and ACP integration
|
|
//! for shell command suggestions. Ghost text is rendered as dimmed italic
|
|
//! text after the cursor. Progressive matching trims the ghost when the
|
|
//! user types a character that matches the ghost's prefix, avoiding
|
|
//! unnecessary network requests.
|
|
//!
|
|
//! ACP integration: on text change (after debounce), sends an
|
|
//! `kigi/suggest` request through the Effect pipeline. Stale responses
|
|
//! are discarded via generation tracking.
|
|
|
|
/// Source of a shell command suggestion.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub enum SuggestionSource {
|
|
#[default]
|
|
None,
|
|
History,
|
|
PathExecutable,
|
|
FilePath,
|
|
AI,
|
|
}
|
|
|
|
impl SuggestionSource {
|
|
fn parse_source(s: &str) -> Self {
|
|
match s {
|
|
"history" => Self::History,
|
|
"path" => Self::PathExecutable,
|
|
"file" => Self::FilePath,
|
|
"ai" => Self::AI,
|
|
_ => Self::None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ghost text state for shell command suggestions.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct GhostTextState {
|
|
/// Current ghost text to render (may be trimmed by progressive matching).
|
|
pub(crate) text: String,
|
|
/// Where this suggestion came from.
|
|
pub(crate) source: SuggestionSource,
|
|
/// Original full suggestion text before progressive matching.
|
|
pub(crate) full_text: String,
|
|
/// Generation counter when this ghost was set.
|
|
pub(crate) generation: u64,
|
|
}
|
|
|
|
/// Parsed ghost suggestion from an ACP `kigi/suggest` response.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GhostSuggestionParsed {
|
|
pub suffix: String,
|
|
pub source: SuggestionSource,
|
|
}
|
|
|
|
/// A single completion item from an ACP `kigi/suggest` response.
|
|
// `Default` (empty item) exists for downstream test fixtures — functional-
|
|
// update construction (`..Default::default()`) keeps out-of-crate literals
|
|
// (e.g. kigi-pager-minimal's) compiling when optional fields are added.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CompletionItemParsed {
|
|
pub display: String,
|
|
pub description: String,
|
|
/// Whole-line replacement — always safe to `set_text` (the shell keeps
|
|
/// this backward-shaped for range-unaware pagers).
|
|
pub insert_text: String,
|
|
pub source: SuggestionSource,
|
|
pub priority: i32,
|
|
/// Byte range in the REQUEST text the completion targets. `None` (older
|
|
/// shells, whole-line items, or malformed wire data) keeps the
|
|
/// whole-line accept behavior. Parsed atomically with `token_text`:
|
|
/// present only as a pair.
|
|
pub replace_range: Option<std::ops::Range<usize>>,
|
|
/// Replacement for `replace_range` (path/file token completions);
|
|
/// `Some` exactly when `replace_range` is.
|
|
pub token_text: Option<String>,
|
|
/// The provider capped its scan/results — the set may be incomplete, so
|
|
/// Tab must not conclude from it (dropdown-only). Absent on the wire
|
|
/// (older shells) parses as `false`.
|
|
pub truncated: bool,
|
|
}
|
|
|
|
impl CompletionItemParsed {
|
|
/// The text that replaces `replace_range` on an in-place accept.
|
|
pub fn span_replacement(&self) -> &str {
|
|
self.token_text.as_deref().unwrap_or(&self.insert_text)
|
|
}
|
|
}
|
|
|
|
/// Parsed response from an ACP `kigi/suggest` request.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SuggestResponseParsed {
|
|
pub ghost: Option<GhostSuggestionParsed>,
|
|
pub completions: Vec<CompletionItemParsed>,
|
|
pub generation: u64,
|
|
}
|
|
|
|
impl SuggestResponseParsed {
|
|
/// Parse a raw JSON value from an ACP `kigi/suggest` response.
|
|
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
|
|
let result = value.get("result").unwrap_or(value);
|
|
let generation = result.get("generation")?.as_u64()?;
|
|
let ghost = result.get("ghost").and_then(|g| {
|
|
if g.is_null() {
|
|
return None;
|
|
}
|
|
let suffix = g.get("suffix")?.as_str()?;
|
|
if suffix.is_empty() {
|
|
return None;
|
|
}
|
|
let source_str = g.get("source").and_then(|s| s.as_str()).unwrap_or("");
|
|
Some(GhostSuggestionParsed {
|
|
suffix: suffix.to_owned(),
|
|
source: SuggestionSource::parse_source(source_str),
|
|
})
|
|
});
|
|
let completions = result
|
|
.get("completions")
|
|
.and_then(|c| c.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|item| {
|
|
let display = item.get("display")?.as_str()?.to_owned();
|
|
let insert_text = item.get("insertText")?.as_str()?.to_owned();
|
|
let description = item
|
|
.get("description")
|
|
.and_then(|d| d.as_str())
|
|
.unwrap_or("")
|
|
.to_owned();
|
|
let source_str = item.get("source").and_then(|s| s.as_str()).unwrap_or("");
|
|
let priority =
|
|
item.get("priority").and_then(|p| p.as_i64()).unwrap_or(0) as i32;
|
|
// Optional `[start, end]`; anything malformed
|
|
// degrades to the legacy whole-line accept.
|
|
let replace_range = item.get("replaceRange").and_then(|r| {
|
|
let arr = r.as_array()?;
|
|
let (start, end) = match arr.as_slice() {
|
|
[s, e] => (s.as_u64()? as usize, e.as_u64()? as usize),
|
|
_ => return None,
|
|
};
|
|
(start <= end).then_some(start..end)
|
|
});
|
|
let token_text = item
|
|
.get("tokenText")
|
|
.and_then(|t| t.as_str())
|
|
.map(str::to_owned);
|
|
// The pair is atomic: a range without its token
|
|
// would splice the whole-line `insertText` into a
|
|
// token span (`cat no` → `cat cat notes.md`), a
|
|
// token without its range has nowhere to go — half
|
|
// pairs degrade to the rangeless whole-line accept.
|
|
let (replace_range, token_text) = match (replace_range, token_text) {
|
|
(Some(r), Some(t)) => (Some(r), Some(t)),
|
|
_ => (None, None),
|
|
};
|
|
let truncated = item
|
|
.get("truncated")
|
|
.and_then(|t| t.as_bool())
|
|
.unwrap_or(false);
|
|
Some(CompletionItemParsed {
|
|
display,
|
|
description,
|
|
insert_text,
|
|
source: SuggestionSource::parse_source(source_str),
|
|
priority,
|
|
replace_range,
|
|
token_text,
|
|
truncated,
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
Some(Self {
|
|
ghost,
|
|
completions,
|
|
generation,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// How much of the ghost text to accept.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AcceptMode {
|
|
/// Accept the entire ghost text (Right arrow).
|
|
Full,
|
|
/// Accept one word of ghost text (Ctrl+Right).
|
|
OneWord,
|
|
}
|
|
|
|
/// Action requested by `text_changed` for the caller to dispatch.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum SuggestionAction {
|
|
/// Text progressively matched the ghost — no network request needed.
|
|
Matched,
|
|
/// Spawn a debounce timer. On expiry, call `on_debounce_expired`.
|
|
Debounce { generation: u64 },
|
|
}
|
|
|
|
/// Wire `limit` for `kigi/suggest` fetches. Matches the shell file
|
|
/// provider's ranked-result cap (`MAX_RESULTS` in the shell crate's
|
|
/// `file_provider.rs`): the provider ranks BEFORE capping, the dropdown
|
|
/// renders 6 rows and scrolls the rest. Both fetch sites (Tab and the
|
|
/// as-you-type debounce) must send the same value or their candidate sets
|
|
/// diverge.
|
|
pub const SHELL_SUGGEST_WIRE_LIMIT: usize = 50;
|
|
|
|
/// Terminal-Tab decision over the current dropdown items — computed by
|
|
/// [`SuggestionController::tab_decision`], executed by the view. Owning the
|
|
/// whole policy here (staleness, source shape, single-candidate, LCP) keeps
|
|
/// the view from reading item internals.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum TabAction {
|
|
/// Exactly one token candidate: accept it immediately, no dropdown flash.
|
|
InstaAccept,
|
|
/// Write the shared prefix over the validated span (bash's first Tab),
|
|
/// then re-fetch for the longer token.
|
|
Fill(std::ops::Range<usize>, String),
|
|
/// Ambiguous (or whole-line/mixed sources): open the dropdown.
|
|
Open,
|
|
/// No usable candidates: none fetched, or outdated by an edit / cursor
|
|
/// move. The key path fetches on this; the landing path does nothing.
|
|
Nothing,
|
|
}
|
|
|
|
/// A resolved dropdown accept: what to write, decided against the draft
|
|
/// BEFORE the dropdown closed (so nothing depends on state surviving
|
|
/// `close()`). Produced by [`SuggestionController::accept_completion`],
|
|
/// applied by `PromptWidget::apply_completion_splice`.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum CompletionSplice {
|
|
/// Rangeless (legacy-shell) item: replace the whole line — safe because
|
|
/// wire `insert_text` is always a full line by protocol contract.
|
|
WholeLine(String),
|
|
/// Token item with a still-valid span: replace that range in place.
|
|
Token(std::ops::Range<usize>, String),
|
|
/// The item's span no longer fits the draft: accept is a
|
|
/// draft-preserving no-op — never a clobber.
|
|
Stale,
|
|
}
|
|
|
|
/// State for the shell command completion dropdown.
|
|
#[derive(Debug, Default)]
|
|
pub struct CompletionDropdownState {
|
|
pub open: bool,
|
|
pub items: Vec<CompletionItemParsed>,
|
|
pub selected: usize,
|
|
pub hovered: Option<usize>,
|
|
pub generation: u64,
|
|
/// The request text `items` were computed for — set atomically with the
|
|
/// items when a response lands, so item `replace_range` offsets always
|
|
/// validate against the text they actually index into.
|
|
pub request_text: String,
|
|
/// Cursor position the request was built at. Items target the token AT
|
|
/// this cursor; [`SuggestionController::tab_decision`] refuses items
|
|
/// when the live cursor has moved anywhere else (e.g. a mouse click) —
|
|
/// the only tolerated drift is typing at the end.
|
|
pub request_cursor: usize,
|
|
}
|
|
|
|
impl CompletionDropdownState {
|
|
/// Move the selection by `delta` (negative = up, positive = down),
|
|
/// wrapping around at the ends. Used for keyboard arrow nav.
|
|
pub fn move_selection(&mut self, delta: isize) {
|
|
if self.items.is_empty() {
|
|
return;
|
|
}
|
|
let len = self.items.len() as isize;
|
|
let new = (self.selected as isize + delta).rem_euclid(len) as usize;
|
|
self.selected = new;
|
|
}
|
|
|
|
/// Move the selection by `delta`, clamping at the first and last item
|
|
/// (no wrap-around). Used for mouse-wheel scrolling.
|
|
pub fn scroll_selection(&mut self, delta: isize) {
|
|
if self.items.is_empty() {
|
|
return;
|
|
}
|
|
let len = self.items.len() as isize;
|
|
let new = (self.selected as isize + delta).clamp(0, len - 1) as usize;
|
|
self.selected = new;
|
|
}
|
|
|
|
/// Accept the currently selected item, or `None` when there are no
|
|
/// items. Moves the item out to avoid cloning and closes the dropdown.
|
|
/// Deliberately independent of [`open`](Self::open) (a render flag):
|
|
/// the single-candidate insta-accept consumes an item that was never
|
|
/// rendered.
|
|
pub fn accept(&mut self) -> Option<CompletionItemParsed> {
|
|
if self.items.is_empty() {
|
|
return None;
|
|
}
|
|
let idx = self.selected.min(self.items.len() - 1);
|
|
let item = self.items.swap_remove(idx);
|
|
self.close();
|
|
Some(item)
|
|
}
|
|
|
|
// The `request_text`/`request_cursor` anchor is left in place (inert
|
|
// without items); a landing overwrites it atomically with the items.
|
|
pub fn close(&mut self) {
|
|
self.open = false;
|
|
self.selected = 0;
|
|
self.hovered = None;
|
|
self.items.clear();
|
|
}
|
|
}
|
|
|
|
/// Manages ghost text state, progressive matching, and ACP integration.
|
|
#[derive(Default)]
|
|
pub struct SuggestionController {
|
|
ghost: GhostTextState,
|
|
generation: u64,
|
|
last_request_text: String,
|
|
/// Generation of a Tab-triggered fetch whose landing should run the
|
|
/// terminal Tab semantics (armed by [`Self::begin_tab_completion`],
|
|
/// consumed by [`Self::take_pending_tab`]).
|
|
tab_pending: Option<u64>,
|
|
/// Whether the as-you-type suggestion pipeline (debounced fetches +
|
|
/// ghost rendering) is enabled. Resolved at construction from the
|
|
/// `KIGI_SUGGESTIONS` env var. Tab-triggered completion in bash mode
|
|
/// deliberately does NOT consult this — it is always on.
|
|
pub enabled: bool,
|
|
/// Completion dropdown state (populated from `SuggestResponse.completions`).
|
|
pub dropdown: CompletionDropdownState,
|
|
/// Whether AI-powered suggestions are enabled.
|
|
/// Resolved at construction from `KIGI_SUGGESTIONS_AI` env var.
|
|
pub ai_enabled: bool,
|
|
/// Model to use for AI suggestions. Sent in the `kigi/suggest` request.
|
|
/// Resolved at construction from `KIGI_SUGGESTIONS_AI_MODEL` env var.
|
|
pub ai_model: Option<String>,
|
|
}
|
|
|
|
impl SuggestionController {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
ghost: GhostTextState::default(),
|
|
generation: 0,
|
|
last_request_text: String::new(),
|
|
tab_pending: None,
|
|
enabled: kigi_config::env_bool("KIGI_SUGGESTIONS").unwrap_or(false),
|
|
dropdown: CompletionDropdownState::default(),
|
|
ai_enabled: kigi_config::env_bool("KIGI_SUGGESTIONS_AI").unwrap_or(false),
|
|
ai_model: std::env::var("KIGI_SUGGESTIONS_AI_MODEL")
|
|
.ok()
|
|
.filter(|s| !s.is_empty()),
|
|
}
|
|
}
|
|
|
|
/// Set ghost text with a source. Moves `text` into the controller.
|
|
pub fn set_ghost(&mut self, text: String, source: SuggestionSource) {
|
|
self.generation += 1;
|
|
self.set_ghost_fields(text, source);
|
|
}
|
|
|
|
/// Write ghost fields without touching the generation counter.
|
|
fn set_ghost_fields(&mut self, text: String, source: SuggestionSource) {
|
|
self.ghost.full_text.clear();
|
|
self.ghost.full_text.push_str(&text);
|
|
self.ghost.text = text;
|
|
self.ghost.source = source;
|
|
self.ghost.generation = self.generation;
|
|
}
|
|
|
|
pub fn clear_ghost(&mut self) {
|
|
self.ghost.text.clear();
|
|
self.ghost.full_text.clear();
|
|
self.ghost.source = SuggestionSource::None;
|
|
self.dropdown.close();
|
|
}
|
|
|
|
/// Wholesale suggestion-state discard (prompt emptied, `set_text` swap):
|
|
/// the ghost and the dropdown items belonged to the OLD draft, and any
|
|
/// in-flight fetch was for it, so clear both, disarm a pending Tab, and
|
|
/// bump the generation so late responses are discarded instead of
|
|
/// resurrecting stale state.
|
|
pub fn invalidate_draft(&mut self) {
|
|
self.clear_ghost();
|
|
self.last_request_text.clear();
|
|
self.tab_pending = None;
|
|
self.generation += 1;
|
|
}
|
|
|
|
pub fn has_ghost(&self) -> bool {
|
|
!self.ghost.text.is_empty()
|
|
}
|
|
|
|
/// Returns the current ghost text if non-empty.
|
|
pub fn ghost_text(&self) -> Option<&str> {
|
|
if self.ghost.text.is_empty() {
|
|
None
|
|
} else {
|
|
Some(&self.ghost.text)
|
|
}
|
|
}
|
|
|
|
/// Accept ghost text. Returns the accepted portion, or `None` if empty.
|
|
/// Closes the completion dropdown (accepted ghost text supersedes it)
|
|
/// and bumps the generation so in-flight responses for the pre-accept
|
|
/// text are discarded when they land.
|
|
pub fn accept_ghost(&mut self, mode: AcceptMode) -> Option<String> {
|
|
if self.ghost.text.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
self.dropdown.close();
|
|
|
|
match mode {
|
|
AcceptMode::Full => {
|
|
let accepted = std::mem::take(&mut self.ghost.text);
|
|
self.ghost.full_text.clear();
|
|
self.ghost.source = SuggestionSource::None;
|
|
self.generation += 1;
|
|
Some(accepted)
|
|
}
|
|
AcceptMode::OneWord => {
|
|
let accept_end = one_word_end(&self.ghost.text);
|
|
if accept_end == 0 {
|
|
return None;
|
|
}
|
|
let accepted = self.ghost.text[..accept_end].to_owned();
|
|
self.ghost.text.drain(..accept_end);
|
|
if self.ghost.text.is_empty() {
|
|
self.ghost.full_text.clear();
|
|
self.ghost.source = SuggestionSource::None;
|
|
}
|
|
self.generation += 1;
|
|
Some(accepted)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve what accepting the selected item WOULD write, without
|
|
/// consuming it or touching any state. The view probes this before an
|
|
/// insta-accept: a splice into an atomic prompt element must degrade to
|
|
/// opening the dropdown, not consume the candidate.
|
|
/// [`Self::accept_completion`] delegates here so the two can never
|
|
/// resolve differently.
|
|
pub fn peek_completion_splice(&self, current_text: &str) -> Option<CompletionSplice> {
|
|
if self.dropdown.generation != self.generation || self.dropdown.items.is_empty() {
|
|
return None;
|
|
}
|
|
let idx = self.dropdown.selected.min(self.dropdown.items.len() - 1);
|
|
let item = &self.dropdown.items[idx];
|
|
Some(match item.replace_range.clone() {
|
|
None => CompletionSplice::WholeLine(item.insert_text.clone()),
|
|
Some(range) => {
|
|
match self.validated_replace_range(range, item.span_replacement(), current_text) {
|
|
Some(range) => {
|
|
CompletionSplice::Token(range, item.span_replacement().to_owned())
|
|
}
|
|
None => CompletionSplice::Stale,
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Accept the selected completion-dropdown item, refusing stale state:
|
|
/// items populated for a superseded generation just close the dropdown
|
|
/// and accept nothing (the refreshed fetch is already in flight). The
|
|
/// item's span is resolved against `current_text` BEFORE the dropdown
|
|
/// closes (see [`CompletionSplice`]). A successful accept bumps the
|
|
/// generation so in-flight responses for the pre-accept text are
|
|
/// discarded when they land.
|
|
pub fn accept_completion(&mut self, current_text: &str) -> Option<CompletionSplice> {
|
|
if self.dropdown.generation != self.generation {
|
|
self.dropdown.close();
|
|
return None;
|
|
}
|
|
let resolved = self.peek_completion_splice(current_text)?;
|
|
self.dropdown.accept()?;
|
|
self.generation += 1;
|
|
Some(resolved)
|
|
}
|
|
|
|
/// Try progressive matching: if `new_text` extends `last_request_text`
|
|
/// by exactly one character that matches the ghost's first character,
|
|
/// trim the ghost and return `true`. Otherwise clear the ghost and
|
|
/// return `false`.
|
|
pub fn try_progressive_match(&mut self, new_text: &str) -> bool {
|
|
if self.ghost.text.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let suffix = match new_text.strip_prefix(self.last_request_text.as_str()) {
|
|
Some(s) => s,
|
|
None => {
|
|
self.clear_ghost();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
let mut chars = suffix.chars();
|
|
let typed_char = match (chars.next(), chars.next()) {
|
|
(Some(c), None) => c,
|
|
_ => {
|
|
self.clear_ghost();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
if !self.ghost.text.starts_with(typed_char) {
|
|
self.clear_ghost();
|
|
return false;
|
|
}
|
|
|
|
self.ghost.text.drain(..typed_char.len_utf8());
|
|
self.last_request_text.clear();
|
|
self.last_request_text.push_str(new_text);
|
|
|
|
if self.ghost.text.is_empty() {
|
|
self.ghost.full_text.clear();
|
|
self.ghost.source = SuggestionSource::None;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Update the progressive-match anchor: the text the on-screen ghost is
|
|
/// relative to (reset to the CURRENT text whenever a response lands).
|
|
/// Distinct from [`CompletionDropdownState::request_text`], which pins
|
|
/// the fetch-time text the dropdown items' ranges index into.
|
|
pub fn set_last_request_text(&mut self, text: &str) {
|
|
self.last_request_text.clear();
|
|
self.last_request_text.push_str(text);
|
|
}
|
|
|
|
/// The whole terminal-Tab policy over the current dropdown items:
|
|
/// staleness (generation AND cursor consistency), source shape, the
|
|
/// single-candidate rule, and the shared-prefix rule — one seam, so the
|
|
/// view executes without reading item internals. Only complete token
|
|
/// edits (path/file source AND a range+token pair AND an exhaustive
|
|
/// scan) get shell semantics; everything else — whole-line, mixed, or
|
|
/// degraded sets — always [`TabAction::Open`].
|
|
pub fn tab_decision(&self, current_text: &str, current_cursor: usize) -> TabAction {
|
|
if self.dropdown.generation != self.generation || self.dropdown.items.is_empty() {
|
|
return TabAction::Nothing;
|
|
}
|
|
// Items target the token at the FETCH-time cursor. The only
|
|
// tolerated drift is typing at the end (the same growth the range
|
|
// stretch rule accepts); any other cursor move — a mouse click in
|
|
// particular reports no text change — makes them stale, and Tab
|
|
// must fetch for the token actually under the cursor.
|
|
let grown = current_text
|
|
.len()
|
|
.saturating_sub(self.dropdown.request_text.len());
|
|
if current_cursor != self.dropdown.request_cursor + grown {
|
|
return TabAction::Nothing;
|
|
}
|
|
// Source alone is not enough: old shells send rangeless `path` rows
|
|
// whose whole-line fallback would clobber the draft on insta-accept
|
|
// (`ls | gr` → `grep`), and a truncated (capped) scan may hide the
|
|
// row that disproves a sole match or an LCP.
|
|
let token_shaped = self.dropdown.items.iter().all(|i| {
|
|
matches!(
|
|
i.source,
|
|
SuggestionSource::FilePath | SuggestionSource::PathExecutable
|
|
) && i.replace_range.is_some()
|
|
&& i.token_text.is_some()
|
|
&& !i.truncated
|
|
});
|
|
if token_shaped {
|
|
if self.dropdown.items.len() == 1 {
|
|
return TabAction::InstaAccept;
|
|
}
|
|
if let Some((range, fill)) = self.common_prefix_fill(current_text) {
|
|
return TabAction::Fill(range, fill);
|
|
}
|
|
}
|
|
TabAction::Open
|
|
}
|
|
|
|
/// Shared-prefix fill for terminal-like Tab: when every dropdown item
|
|
/// targets the SAME span and their replacements share a common prefix
|
|
/// that strictly extends the typed token, return the validated span and
|
|
/// the prefix to write (bash's first-Tab behavior). `None` on any
|
|
/// ambiguity — stale generation, mixed or missing ranges, no shared
|
|
/// prefix, or one that doesn't extend what's typed (e.g. candidates
|
|
/// differing in case) — and [`Self::tab_decision`] falls back to
|
|
/// opening the dropdown.
|
|
fn common_prefix_fill(&self, current_text: &str) -> Option<(std::ops::Range<usize>, String)> {
|
|
if self.dropdown.generation != self.generation {
|
|
return None;
|
|
}
|
|
let items = &self.dropdown.items;
|
|
if items.len() < 2 {
|
|
return None;
|
|
}
|
|
let range = items[0].replace_range.clone()?;
|
|
if items[1..]
|
|
.iter()
|
|
.any(|i| i.replace_range.as_ref() != Some(&range))
|
|
{
|
|
return None;
|
|
}
|
|
let mut lcp = items[0].span_replacement();
|
|
for item in &items[1..] {
|
|
lcp = common_str_prefix(lcp, item.span_replacement());
|
|
if lcp.is_empty() {
|
|
return None;
|
|
}
|
|
}
|
|
// Token texts are rendered shell literals (`a b`/`a$c` arrive as
|
|
// `a\ b`/`a\$c`), so their byte LCP can end mid-escape (`a\`) —
|
|
// filling that would write a dangling backslash (line
|
|
// continuation). Trim the incomplete escape; the strict-extension
|
|
// check below then decides whether anything is left to fill.
|
|
if lcp.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
|
|
lcp = &lcp[..lcp.len() - 1];
|
|
}
|
|
let range = self.validated_replace_range(range, lcp, current_text)?;
|
|
let typed = ¤t_text[range.clone()];
|
|
(lcp.len() > typed.len() && lcp.starts_with(typed)).then(|| (range, lcp.to_owned()))
|
|
}
|
|
|
|
/// Re-validate a completion item's `replace_range` against the current
|
|
/// text. Offsets index into [`CompletionDropdownState::request_text`];
|
|
/// the only drift a live dropdown survives is progressive typing, so a
|
|
/// range that reached the request text's end absorbs the typed tail —
|
|
/// but ONLY while the grown span still extends toward `replacement` (a
|
|
/// prefix of it). Anything else returns `None`: no-op, never a clobber.
|
|
fn validated_replace_range(
|
|
&self,
|
|
range: std::ops::Range<usize>,
|
|
replacement: &str,
|
|
current_text: &str,
|
|
) -> Option<std::ops::Range<usize>> {
|
|
let request = self.dropdown.request_text.as_str();
|
|
if range.start > range.end || range.end > request.len() {
|
|
return None;
|
|
}
|
|
if !current_text.starts_with(request) {
|
|
return None;
|
|
}
|
|
let mut end = range.end;
|
|
if range.end == request.len() && current_text.len() > request.len() {
|
|
if !current_text.is_char_boundary(range.start)
|
|
|| !replacement.starts_with(¤t_text[range.start..])
|
|
{
|
|
return None;
|
|
}
|
|
end = current_text.len();
|
|
}
|
|
(current_text.is_char_boundary(range.start) && current_text.is_char_boundary(end))
|
|
.then_some(range.start..end)
|
|
}
|
|
|
|
/// Arm a Tab-triggered deterministic completion fetch. Deliberately
|
|
/// independent of [`enabled`](Self::enabled): Tab in bash mode always
|
|
/// completes. Bumps the generation (discarding any in-flight response)
|
|
/// and returns it for the fetch effect; `run_tab_on_load` marks the
|
|
/// landing response to run the terminal Tab semantics once (the
|
|
/// post-accept/fill refreshes pass `false` so their items land
|
|
/// silently and wait for the next Tab).
|
|
pub fn begin_tab_completion(&mut self, run_tab_on_load: bool) -> u64 {
|
|
self.generation += 1;
|
|
self.tab_pending = run_tab_on_load.then_some(self.generation);
|
|
self.generation
|
|
}
|
|
|
|
/// A Tab-armed fetch is still in flight for the current draft (nothing
|
|
/// invalidated it since arming): a repeat Tab keeps the marker and lets
|
|
/// that landing run the Tab semantics once — no second RPC.
|
|
pub fn tab_fetch_pending(&self) -> bool {
|
|
self.tab_pending == Some(self.generation)
|
|
}
|
|
|
|
/// Consume the pending-Tab mark when the response for `generation`
|
|
/// lands. `true` only when this is the fetch Tab armed AND it is still
|
|
/// current (an edit since the Tab makes it stale).
|
|
pub fn take_pending_tab(&mut self, generation: u64) -> bool {
|
|
if self.tab_pending == Some(generation) {
|
|
self.tab_pending = None;
|
|
return generation == self.generation;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Current generation counter (for callers that need to pass it to effects).
|
|
pub fn generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
|
|
/// Called on each text change. Returns the action the caller should take.
|
|
///
|
|
/// If slash is active (or has an inline ghost), suppresses the pipeline
|
|
/// entirely. Otherwise, tries progressive matching first; if no match,
|
|
/// increments generation and requests a debounce.
|
|
pub fn text_changed(
|
|
&mut self,
|
|
text: &str,
|
|
slash_active: bool,
|
|
slash_has_inline_ghost: bool,
|
|
) -> Option<SuggestionAction> {
|
|
if !self.enabled {
|
|
// No as-you-type pipeline, but Tab-fetched completion state is
|
|
// text-specific: the edit outdates the dropdown items and any
|
|
// in-flight Tab fetch (the generation bump makes them stale).
|
|
self.invalidate_draft();
|
|
return None;
|
|
}
|
|
|
|
if slash_active || slash_has_inline_ghost {
|
|
// Suppression must invalidate, not just hide: an already-armed
|
|
// debounce or pending Tab landing would otherwise repopulate
|
|
// suggestion state behind the slash UI.
|
|
self.invalidate_draft();
|
|
return None;
|
|
}
|
|
|
|
if text.is_empty() {
|
|
self.invalidate_draft();
|
|
return None;
|
|
}
|
|
|
|
if self.try_progressive_match(text) {
|
|
return Some(SuggestionAction::Matched);
|
|
}
|
|
|
|
// Non-matching edit: `try_progressive_match` clears ghost+dropdown
|
|
// on a ghost mismatch, but a ghost-less dropdown (pure path/file
|
|
// items) would leak through its empty-ghost early return — this
|
|
// edit outdated those items, so tear the dropdown down here.
|
|
self.dropdown.close();
|
|
self.generation += 1;
|
|
Some(SuggestionAction::Debounce {
|
|
generation: self.generation,
|
|
})
|
|
}
|
|
|
|
/// Called when a debounce timer expires. If the generation still matches,
|
|
/// returns `true` and the caller should fire the ACP request.
|
|
pub fn on_debounce_expired(&self, generation: u64) -> bool {
|
|
generation == self.generation
|
|
}
|
|
|
|
/// Called when an ACP `kigi/suggest` response arrives, with the text and
|
|
/// cursor the request was built from (the anchor item `replace_range`
|
|
/// offsets index into, and the position Tab targets). Takes ownership to
|
|
/// avoid copying strings. Discards stale responses.
|
|
pub fn on_suggestions_loaded(
|
|
&mut self,
|
|
response: SuggestResponseParsed,
|
|
request_text: &str,
|
|
request_cursor: usize,
|
|
) {
|
|
if response.generation != self.generation {
|
|
return;
|
|
}
|
|
|
|
match response.ghost {
|
|
// The ghost is the env-gated as-you-type surface: Tab-triggered
|
|
// (always-on) fetches feed only the dropdown items.
|
|
Some(ghost) if self.enabled => self.set_ghost_fields(ghost.suffix, ghost.source),
|
|
_ => self.clear_ghost(),
|
|
}
|
|
|
|
self.dropdown.items = response.completions;
|
|
self.dropdown.generation = response.generation;
|
|
self.dropdown.selected = 0;
|
|
self.dropdown.request_text.clear();
|
|
self.dropdown.request_text.push_str(request_text);
|
|
self.dropdown.request_cursor = request_cursor;
|
|
// Don't auto-open; Tab opens it whenever items exist.
|
|
}
|
|
}
|
|
|
|
/// Longest common prefix of two strings, trimmed to a char boundary.
|
|
fn common_str_prefix<'a>(a: &'a str, b: &str) -> &'a str {
|
|
let mut n = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
|
|
while n > 0 && !a.is_char_boundary(n) {
|
|
n -= 1;
|
|
}
|
|
&a[..n]
|
|
}
|
|
|
|
/// Find the byte offset after the first word in `s`. A "word" is optional
|
|
/// leading whitespace followed by a run of non-whitespace characters.
|
|
fn one_word_end(s: &str) -> usize {
|
|
let leading_ws = s.len() - s.trim_start().len();
|
|
let after_ws = &s[leading_ws..];
|
|
let word_len = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
|
|
leading_ws + word_len
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|