M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
//! 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
|
||||
//! `x.ai/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 `x.ai/suggest` response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GhostSuggestionParsed {
|
||||
pub suffix: String,
|
||||
pub source: SuggestionSource,
|
||||
}
|
||||
|
||||
/// A single completion item from an ACP `x.ai/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 `x.ai/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 `x.ai/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 `x.ai/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 `x.ai/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 `x.ai/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;
|
||||
Reference in New Issue
Block a user