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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,367 @@
//! @-context detection: parses `@query` tokens from prompt text + cursor position.
//!
//! Given the prompt text and cursor position, determines whether the cursor is
//! inside an `@`-token and extracts the query string for fuzzy matching.
//!
//! ## Rules
//!
//! - The `@` must NOT be preceded by an alphanumeric character or underscore
//! (avoids triggering on email addresses like `user@example.com`).
//! - The token extends from `@` to the first whitespace, comma, or semicolon.
//! - The cursor must be within the token range.
//! - The query is the text between `@` (exclusive) and the cursor.
//!
//! ## Special modes
//!
//! - **Dir mode**: query ends with `/` → restrict matches to directories only.
//! - **Hidden mode**: query starts with `!` → show hidden/gitignored files.
use std::ops::Range;
/// Context for the current @-completion token.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtContext {
/// Byte range in the input text (includes the `@` as the first character).
pub range: Range<usize>,
/// Cursor byte position within the input text.
pub cursor: usize,
/// Query string: text after `@` (and after `!` if hidden mode) up to cursor.
pub query: String,
}
impl AtContext {
/// Whether the query requests directory-only results (ends with `/`).
pub fn is_dir_mode(&self) -> bool {
self.query.ends_with('/')
}
/// Whether the query requests hidden/gitignored files (starts with `!`).
pub fn is_hidden_mode(&self) -> bool {
self.query.starts_with('!')
}
/// The effective query for the fuzzy matcher (strips leading `!`).
pub fn matcher_query(&self) -> &str {
self.query.strip_prefix('!').unwrap_or(&self.query)
}
/// Byte range covering only the path portion of the @-token: starts
/// after the leading `@` and (in hidden mode) the `!` prefix, ends at
/// the @-token end. This is the range that should be replaced when
/// inserting a path while preserving the `@` and any hidden-mode
/// marker (see `accept_file_search_result_no_space` and
/// `FileSearchState::try_replace`).
pub fn path_range(&self) -> Range<usize> {
let prefix = 1 + if self.is_hidden_mode() { 1 } else { 0 };
self.range.start + prefix..self.range.end
}
}
/// Detect an @-completion context from prompt text and cursor position.
///
/// Returns `None` if the cursor is not inside an @-token, or if the `@` is
/// preceded by an alphanumeric/underscore character (e.g., `email@`).
pub fn detect(text: &str, cursor: usize) -> Option<AtContext> {
detect_with_drill(text, cursor, None)
}
/// Like [`detect`], but treats whitespace *inside* `drill_prefix` (the path of
/// the directory being drilled into) as part of the @-token, so `@my dir/` stays
/// one token. Self-validating: inert once the path content stops matching it.
pub fn detect_with_drill(
text: &str,
cursor: usize,
drill_prefix: Option<&str>,
) -> Option<AtContext> {
// Cursor must be within text bounds and on a char boundary.
if cursor > text.len() || !text.is_char_boundary(cursor) {
return None;
}
// Find the rightmost `@` before the cursor.
let at_idx = text[..cursor].rfind('@')?;
// Reject if `@` is preceded by alphanumeric or underscore (email-like).
if let Some(ch) = text[..at_idx].chars().next_back()
&& (ch.is_alphanumeric() || ch == '_')
{
return None;
}
// Path content starts after `@` (+ optional `!` hidden-mode marker).
let content_start = at_idx + 1;
let after_bang = if text[content_start..].starts_with('!') {
content_start + 1
} else {
content_start
};
// Whitespace inside the drilled prefix is path content, not a terminator.
let internal_until = drill_prefix.and_then(|prefix| {
text.get(after_bang..)
.filter(|rest| rest.starts_with(prefix))
.map(|_| after_bang + prefix.len())
});
// Find the end of the @-token: first whitespace, comma, or semicolon after `@`.
let token_end = text[at_idx + 1..]
.char_indices()
.find_map(|(offset, ch)| {
let abs = at_idx + 1 + offset;
if (ch.is_whitespace() || matches!(ch, ',' | ';'))
&& internal_until.is_none_or(|until| abs >= until)
{
Some(abs)
} else {
None
}
})
.unwrap_or(text.len());
// Cursor must be within the @-token.
if cursor > token_end {
return None;
}
Some(AtContext {
range: at_idx..token_end,
cursor,
query: text[at_idx + 1..cursor].to_owned(),
})
}
/// Normalize a display path (strip leading `./`).
pub fn normalize_display_path(path: &str) -> &str {
path.strip_prefix("./").unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_at_token() {
let ctx = detect("@foo", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
assert!(!ctx.is_dir_mode());
assert!(!ctx.is_hidden_mode());
}
#[test]
fn at_with_prefix_text() {
let ctx = detect("hello @bar/baz", 14).unwrap();
assert_eq!(ctx.range, 6..14);
assert_eq!(ctx.query, "bar/baz");
}
#[test]
fn cursor_mid_token() {
let ctx = detect("@foo/bar", 5).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.query, "foo/");
assert!(ctx.is_dir_mode());
}
#[test]
fn cursor_at_sign_only() {
let ctx = detect("@", 1).unwrap();
assert_eq!(ctx.range, 0..1);
assert_eq!(ctx.query, "");
}
#[test]
fn rejected_email_like() {
// @ preceded by alphanumeric — should not trigger.
assert!(detect("user@example", 12).is_none());
assert!(detect("test_@foo", 9).is_none());
}
#[test]
fn cursor_past_token() {
// Cursor is after the space following the token — no match.
assert!(detect("@foo bar", 5).is_none());
assert!(detect("@foo bar", 8).is_none());
}
#[test]
fn hidden_mode() {
let ctx = detect("@!foo", 5).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.matcher_query(), "foo");
}
#[test]
fn dir_mode() {
let ctx = detect("@src/", 5).unwrap();
assert!(ctx.is_dir_mode());
assert_eq!(ctx.query, "src/");
assert_eq!(ctx.matcher_query(), "src/");
}
#[test]
fn hidden_dir_mode() {
let ctx = detect("@!.config/", 10).unwrap();
assert!(ctx.is_hidden_mode());
assert!(ctx.is_dir_mode());
assert_eq!(ctx.matcher_query(), ".config/");
}
#[test]
fn multiple_at_picks_rightmost() {
let ctx = detect("@first @second", 14).unwrap();
assert_eq!(ctx.query, "second");
assert_eq!(ctx.range, 7..14);
}
#[test]
fn at_after_special_chars() {
// @ preceded by space, parens, etc. — should trigger.
assert!(detect("(@foo", 5).is_some());
assert!(detect(" @foo", 5).is_some());
assert!(detect(",@foo", 5).is_some());
}
#[test]
fn empty_text() {
assert!(detect("", 0).is_none());
}
#[test]
fn cursor_at_zero() {
assert!(detect("@foo", 0).is_none());
}
#[test]
fn normalize_path() {
assert_eq!(normalize_display_path("./foo/bar"), "foo/bar");
assert_eq!(normalize_display_path("foo/bar"), "foo/bar");
assert_eq!(normalize_display_path("./"), "");
}
#[test]
fn token_delimited_by_comma() {
let ctx = detect("@foo,@bar", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
}
#[test]
fn token_delimited_by_semicolon() {
let ctx = detect("@foo;rest", 4).unwrap();
assert_eq!(ctx.range, 0..4);
assert_eq!(ctx.query, "foo");
}
#[test]
fn path_range_skips_at_only() {
// Plain @-token: path_range starts after `@`, ends at token end.
let ctx = detect("@src/foo", 8).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.path_range(), 1..8);
}
#[test]
fn path_range_skips_at_and_bang_in_hidden_mode() {
// Hidden mode: path_range skips both `@` and `!`.
let ctx = detect("@!src/foo", 9).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.range, 0..9);
assert_eq!(ctx.path_range(), 2..9);
}
#[test]
fn path_range_with_prefix_text_offset() {
// @-token preceded by other text: path_range respects the
// absolute offset of the @ in the input.
let ctx = detect("hello @bar", 10).unwrap();
assert_eq!(ctx.range, 6..10);
assert_eq!(ctx.path_range(), 7..10);
}
// ── Drill-aware detection (whitespace inside a drilled dir name) ─────
#[test]
fn drill_prefix_allows_internal_space() {
let ctx = detect_with_drill("@my dir", 7, Some("my dir")).unwrap();
assert_eq!(ctx.range, 0..7);
assert_eq!(ctx.query, "my dir");
assert!(!ctx.is_dir_mode());
}
#[test]
fn drill_prefix_enters_dir_mode_with_trailing_slash() {
let ctx = detect_with_drill("@my dir/", 8, Some("my dir")).unwrap();
assert_eq!(ctx.query, "my dir/");
assert!(ctx.is_dir_mode());
}
#[test]
fn drill_prefix_allows_internal_tab() {
let ctx = detect_with_drill("@my\tdir", 7, Some("my\tdir")).unwrap();
assert_eq!(ctx.range, 0..7);
assert_eq!(ctx.query, "my\tdir");
}
#[test]
fn drill_prefix_with_hidden_mode() {
let ctx = detect_with_drill("@!my dir", 8, Some("my dir")).unwrap();
assert!(ctx.is_hidden_mode());
assert_eq!(ctx.matcher_query(), "my dir");
}
#[test]
fn drill_prefix_mismatch_falls_back_to_whitespace_terminator() {
// Prefix mismatch → space terminates as usual (sentence typing preserved).
assert!(detect_with_drill("@foo bar", 8, Some("my dir")).is_none());
}
#[test]
fn drill_prefix_whitespace_after_prefix_terminates() {
// Whitespace beyond the drilled prefix still ends the token.
assert!(detect_with_drill("@my dir extra", 13, Some("my dir")).is_none());
}
#[test]
fn no_drill_prefix_space_still_terminates() {
// Without a prefix, behavior is identical to plain `detect`.
assert!(detect("@my dir", 7).is_none());
assert!(detect_with_drill("@my dir", 7, None).is_none());
}
#[test]
fn drill_prefix_cursor_mid_token() {
// Cursor inside the drilled name still resolves the full token range.
let ctx = detect_with_drill("@my dir/sub", 5, Some("my dir")).unwrap();
assert_eq!(ctx.range, 0..11);
assert_eq!(ctx.query, "my d");
}
#[test]
fn drill_prefix_inert_when_backspaced_out_of_prefix() {
// Self-validation: `@my di` no longer starts with `my dir`, so the
// anchor goes inert and the space re-terminates.
assert!(detect_with_drill("@my di", 6, Some("my dir")).is_none());
}
#[test]
fn drill_prefix_allows_multibyte_dir_name() {
// `é` is two bytes; guards the `after_bang + prefix.len()` byte math.
let ctx = detect_with_drill("@café dir", 10, Some("café dir")).unwrap();
assert_eq!(ctx.range, 0..10);
assert_eq!(ctx.query, "café dir");
}
#[test]
fn drill_prefix_empty_collapses_to_no_prefix() {
// Empty prefix anchors nothing → terminates as if no prefix were set.
assert!(detect_with_drill("@my dir", 7, Some("")).is_none());
}
#[test]
fn drill_prefix_allows_second_level_space_segment() {
// Both spaces fall inside the drilled prefix → one token.
let ctx = detect_with_drill("@a b/c d", 8, Some("a b/c d")).unwrap();
assert_eq!(ctx.range, 0..8);
assert_eq!(ctx.query, "a b/c d");
}
}
@@ -0,0 +1,237 @@
//! Dropdown list renderer for @-completion results.
//!
//! Renders fuzzy match results as a scrollable list with:
//! - Selection highlight (background color on selected row)
//! - Fuzzy match character highlighting (accent color on matched chars)
//! - Scrollbar when results exceed visible height
//! - Truncation with `…` for long paths
//! - Result count hint (e.g., "12/345") in the separator line
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use kigi_workspace::file_system::FuzzyMatchResult;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::theme::Theme;
use super::context::normalize_display_path;
use super::state::FileSearchState;
/// Maximum number of visible rows in the dropdown (excluding separator).
pub const MAX_DROPDOWN_ROWS: u16 = 8;
/// Render the file search dropdown items into the given area.
///
/// This renders ONLY the result rows (no borders or separators).
/// Panel chrome (clear, borders, count hint) is handled by the caller
/// (AgentView). The `area` covers just the item rows.
pub fn render_dropdown(buf: &mut Buffer, area: Rect, file_search: &FileSearchState, theme: &Theme) {
if area.height == 0 || area.width < 4 || !file_search.is_visible() {
return;
}
let results = file_search.results();
let topk = &results.topk;
let selected = file_search.selected();
let scroll = file_search.scroll_offset();
let dir_mode = file_search.is_dir_mode();
// Reserve 2 columns on the right for scrollbar (gap + track).
let needs_scrollbar = topk.len() > area.height as usize;
let content_width = if needs_scrollbar {
area.width.saturating_sub(2)
} else {
area.width
};
let visible_rows = area.height as usize;
let hovered = file_search.hovered();
let hover_bg = theme.bg_hover;
for row in 0..visible_rows {
let idx = scroll + row;
if idx >= topk.len() {
break;
}
let item = &topk[idx];
let y = area.y + row as u16;
let is_selected = idx == selected;
let is_hovered = hovered == Some(idx) && !is_selected;
render_fuzzy_item(
buf,
area.x,
y,
content_width,
item,
is_selected,
is_hovered,
hover_bg,
dir_mode,
theme,
);
}
// ── Scrollbar ───────────────────────────────────────────────────────
if needs_scrollbar {
let scrollbar_area = Rect {
x: area.x + area.width - 1,
y: area.y,
width: 1,
height: area.height,
};
let track_style = Style::default().bg(theme.bg_dark);
let thumb_style = Style::default().fg(theme.gray_dim).bg(theme.bg_dark);
render_scrollbar_styled(
buf,
Some(scrollbar_area),
topk.len() as u16,
area.height,
scroll as u16,
track_style,
thumb_style,
);
}
}
/// Desired height for the dropdown (separator + min(results, max_rows)).
pub fn dropdown_height(file_search: &FileSearchState, max_rows: u16) -> u16 {
if !file_search.is_visible() {
return 0;
}
let result_rows = (file_search.result_count() as u16).min(max_rows);
1 + result_rows // separator + results
}
/// Non-selected prefix — same width as the arrow, just spaces.
const ITEM_PREFIX: &str = " ";
const PREFIX_WIDTH: u16 = crate::glyphs::PROMPT_ARROW_WIDTH;
/// Render a single fuzzy match item with character-level match highlighting.
#[allow(clippy::too_many_arguments)]
fn render_fuzzy_item(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
item: &FuzzyMatchResult,
is_selected: bool,
is_hovered: bool,
hover_bg: ratatui::style::Color,
dir_mode: bool,
theme: &Theme,
) {
if width < PREFIX_WIDTH + 1 {
return;
}
let path_str = item.path.to_string();
let path = normalize_display_path(&path_str);
let embed = crate::views::modal_window::embedded_row_style(theme, is_selected);
let row_bg = match embed {
Some(e) => e.bg,
None if is_selected => theme.bg_visual,
None if is_hovered => hover_bg,
None => theme.bg_light,
};
let text_fg = embed.map_or(theme.text_primary, |e| e.fg(theme.text_primary));
let bold = if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
};
// Fill the row with background.
for col in x..x + width {
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_char(' ');
cell.set_style(Style::default().bg(row_bg));
}
}
// Arrow on the selected row, blank gutter on the rest.
let prefix = if is_selected {
crate::glyphs::prompt_arrow()
} else {
ITEM_PREFIX
};
let prefix_style = Style::default().fg(text_fg).bg(row_bg).add_modifier(bold);
for (i, ch) in prefix.chars().enumerate() {
let px = x + i as u16;
if px < x + width
&& let Some(cell) = buf.cell_mut((px, y))
{
cell.set_char(ch);
cell.set_style(if is_selected {
prefix_style
} else {
Style::default().bg(row_bg)
});
}
}
// Styles: primary FG for text (not dimmed), BLUE for match chars.
// Selected rows get bold via the modifier.
let match_style = Style::default()
.fg(embed.map_or(theme.fuzzy_accent, |e| e.fg(theme.fuzzy_accent)))
.bg(row_bg)
.add_modifier(bold);
let normal_style = Style::default().fg(text_fg).bg(row_bg).add_modifier(bold);
// Render path characters after prefix, with match highlighting.
let mut indices = &item.indices[..];
let mut col = x + PREFIX_WIDTH;
let max_col = x + width;
for (char_idx, (byte_idx, ch)) in path.char_indices().enumerate() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if col + ch_width > max_col {
// Truncation: replace last visible char with '…'
if col > x + PREFIX_WIDTH
&& let Some(cell) = buf.cell_mut((col.saturating_sub(1), y))
{
cell.set_char('…');
}
break;
}
let is_match = indices.first() == Some(&(char_idx as u32));
if is_match {
indices = &indices[1..];
}
let style = if is_match { match_style } else { normal_style };
// Write the character.
let ch_str = &path[byte_idx..byte_idx + ch.len_utf8()];
if let Some(cell) = buf.cell_mut((col, y)) {
cell.set_symbol(ch_str);
cell.set_style(style);
}
// For wide chars, fill continuation cell.
if ch_width > 1 {
for w in 1..ch_width {
if let Some(cell) = buf.cell_mut((col + w, y)) {
cell.set_char(' ');
cell.set_style(style);
}
}
}
col += ch_width;
}
// In dir mode, append '/' after the path.
if dir_mode
&& col < max_col
&& let Some(cell) = buf.cell_mut((col, y))
{
cell.set_char('/');
cell.set_style(normal_style);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
//! @-provider: fuzzy file completion for `@foo/bar` references.
//!
//! # Architecture
//!
//! - [`context`] — parses `@query` tokens from text + cursor position
//! - [`state`] — owns the fuzzy matcher daemon, results, and dropdown state
//! - [`dropdown`] — dropdown list rendering (ListPane wrapper, Phase 1)
//! - [`line_viewer`] — centered popup file viewer (Phase 3, not yet implemented)
//! - [`preview`] — file preview alongside dropdown (Phase 4, not yet implemented)
pub mod context;
pub mod dropdown;
pub mod line_viewer;
mod state;
pub use context::AtContext;
pub use state::{FileSearchReplacement, FileSearchState};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use crate::theme::Theme;
/// Build a styled `@path` or `@path:N-M` display line.
///
/// Style: `@` and `:` in `theme.gray`, path in `theme.path`, numbers in `theme.gray_bright`.
/// Set `at_prefix` to include the leading `@` (prompt chip) or omit it (viewer title).
/// Used by both the prompt element chip and the line viewer title bar.
pub fn styled_file_ref<'a>(
path: &str,
line_range: Option<&str>,
theme: &Theme,
at_prefix: bool,
) -> Line<'a> {
let dim = Style::default().fg(theme.gray);
let path_style = Style::default().fg(theme.path);
let num_style = Style::default().fg(theme.gray_bright);
let mut spans = Vec::new();
if at_prefix {
spans.push(Span::styled("@", dim));
}
spans.push(Span::styled(path.to_owned(), path_style));
if let Some(range) = line_range {
spans.push(Span::styled(":", dim));
spans.push(Span::styled(range.to_owned(), num_style));
}
Line::from(spans)
}
@@ -0,0 +1,386 @@
//! File search state: owns the fuzzy matcher daemon, results, and dropdown state.
//!
//! This is the core engine for @-completion. It manages:
//! - A background [`FuzzyFileMatcherDaemon`] that walks the directory tree
//! - The current [`AtContext`] (parsed from prompt text + cursor)
//! - Cached fuzzy match results (polled on tick)
//! - Dropdown selection state (selected index, scroll offset)
//! - Text replacement logic when a result is accepted
use std::path::{Path, PathBuf};
use std::sync::Arc;
use kigi_workspace::file_system::{
FuzzyFileMatcher, FuzzyFileMatcherDaemon, FuzzyMatchResult, FuzzyMatcherDaemonResults,
};
use super::context::{self, AtContext, normalize_display_path};
/// Top-K results to request from the fuzzy matcher.
const MATCHER_TOP_K: usize = 1000;
/// Replacement to apply to the prompt text after accepting a fuzzy result.
#[derive(Debug, Clone)]
pub struct FileSearchReplacement {
/// Byte range in the prompt text to replace (excludes the `@`).
pub range: std::ops::Range<usize>,
/// Replacement text (the normalized path, possibly with trailing space or `/`).
pub text: String,
/// Where to place the cursor after replacement.
pub cursor: usize,
/// Whether the @-context should be cleared (file accepted, not dir drill-down).
pub dismiss: bool,
}
/// File search state for @-completion.
pub struct FileSearchState {
/// Directory the matcher walks. Mirrors the daemon's root (which is
/// otherwise moved into its worker thread) so callers can introspect
/// where `@`-completion is currently pointed.
root: PathBuf,
/// Background fuzzy matcher daemon.
daemon: FuzzyFileMatcherDaemon,
/// Latest results snapshot from the daemon.
results: FuzzyMatcherDaemonResults,
/// Current @-context (if cursor is inside an @-token).
context: Option<AtContext>,
/// Selected index in the dropdown list (keyboard-driven).
selected: usize,
/// Hovered index in the dropdown list (mouse-driven).
/// `None` when the mouse is not over any item.
hovered: Option<usize>,
/// Scroll offset for the dropdown list.
scroll_offset: usize,
/// Generation counter to prevent stale results from flickering in.
min_generation: usize,
/// Directory being drilled into; keeps the @-token alive when its name has
/// whitespace (`my dir`). Self-validating — applies only while the path matches.
drill_prefix: Option<String>,
}
impl FileSearchState {
/// Create a new file search state rooted at the given path.
pub fn new(root: &Path) -> Self {
Self {
root: root.to_owned(),
daemon: FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K),
results: FuzzyMatcherDaemonResults::default(),
context: None,
selected: 0,
hovered: None,
scroll_offset: 0,
min_generation: 0,
drill_prefix: None,
}
}
/// Replace the underlying matcher with a new one rooted at `root`.
///
/// Used after worktree creation to point @-completion at the new tree.
pub fn retarget(&mut self, root: &Path) {
self.root = root.to_owned();
self.daemon = FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K);
self.results = FuzzyMatcherDaemonResults::default();
self.context = None;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
self.min_generation = 0;
self.drill_prefix = None;
}
/// The directory the matcher currently walks (the `@`-completion root).
pub fn root(&self) -> &Path {
&self.root
}
// ── Visibility ──────────────────────────────────────────────────────
/// Whether the dropdown should be visible.
pub fn is_visible(&self) -> bool {
self.context.is_some() && !self.results.topk.is_empty()
}
/// The current @-context, if any.
pub fn context(&self) -> Option<&AtContext> {
self.context.as_ref()
}
/// The current results snapshot.
pub fn results(&self) -> &FuzzyMatcherDaemonResults {
&self.results
}
/// Currently selected index in the results.
pub fn selected(&self) -> usize {
self.selected
}
/// Scroll offset for the dropdown.
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
/// Currently hovered index (mouse-driven), if any.
pub fn hovered(&self) -> Option<usize> {
self.hovered
}
/// Set the hovered index. Returns `true` if changed.
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
let clamped = index.filter(|&i| i < self.results.topk.len());
let changed = clamped != self.hovered;
self.hovered = clamped;
changed
}
/// Whether the current query is in directory-only mode.
pub fn is_dir_mode(&self) -> bool {
self.context.as_ref().is_some_and(|c| c.is_dir_mode())
}
// ── Context updates ─────────────────────────────────────────────────
/// Anchor (or clear) the drilled directory for whitespace-aware detection.
pub fn set_drill_prefix(&mut self, prefix: Option<String>) {
self.drill_prefix = prefix;
}
/// Recompute the @-context from the current prompt text and cursor position.
///
/// Called after every text change or cursor movement.
pub fn update_context(&mut self, text: &str, cursor: usize) {
let new_ctx = context::detect_with_drill(text, cursor, self.drill_prefix.as_deref());
match (&self.context, &new_ctx) {
(None, Some(ctx)) => {
// Fresh `@` token is never a drill — drop any stale anchor.
self.drill_prefix = None;
// Entering @-mode: restart the directory walk.
self.daemon.restart_walk(ctx.is_hidden_mode());
// A trailing `/` scopes the query to a folder; it must not hide
// that folder's files, so never filter to directories only.
self.daemon.set_query(ctx.matcher_query(), false);
self.min_generation += 1;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
}
(Some(old), Some(new)) => {
// Drop a stale anchor once the @-token's path content no longer
// starts with it (e.g. undo/paste reverted the drill), so it
// can't silently re-match on a later edit.
let anchor_stale = self.drill_prefix.as_deref().is_some_and(|prefix| {
!text
.get(new.path_range().start..)
.is_some_and(|rest| rest.starts_with(prefix))
});
if anchor_stale {
self.drill_prefix = None;
}
// Staying in @-mode: check if hidden mode toggled (needs re-walk).
if old.is_hidden_mode() != new.is_hidden_mode() {
self.daemon.restart_walk(new.is_hidden_mode());
}
self.daemon.set_query(new.matcher_query(), false);
self.min_generation += 1;
// Reset selection when query changes to avoid showing stale
// matches from an obscure position in the list.
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
}
(Some(_), None) => {
// Leaving @-mode: clear results and the drill anchor.
self.context = None;
self.drill_prefix = None;
self.results = FuzzyMatcherDaemonResults::default();
return;
}
(None, None) => return,
}
self.context = new_ctx;
}
/// Clear the context (e.g., on Esc).
pub fn clear_context(&mut self) {
self.context = None;
self.drill_prefix = None;
self.results = FuzzyMatcherDaemonResults::default();
}
// ── Tick / polling ──────────────────────────────────────────────────
/// Poll the daemon for new results. Returns `true` if results changed.
///
/// Should be called on every tick (~4ms) while the dropdown is potentially visible.
pub fn poll(&mut self) -> bool {
if self.context.is_none() {
return false;
}
let results = self.daemon.get();
// Check if results actually changed (pointer comparison on Arc).
if Arc::ptr_eq(&results.topk, &self.results.topk) {
return false;
}
// Avoid flickering: skip empty intermediate results unless matching is done.
if !results.topk.is_empty() || results.status.done {
// Skip stale generations (e.g., from a previous @-context).
if results.generation >= self.min_generation {
self.min_generation = results.generation;
self.results = results;
// Clamp selection to new result count.
if !self.results.topk.is_empty() {
self.selected = self.selected.min(self.results.topk.len() - 1);
}
return true;
}
}
false
}
// ── Navigation ──────────────────────────────────────────────────────
/// Move selection by `delta` items (negative = up, positive = down).
pub fn move_selection(&mut self, delta: isize) {
let len = self.results.topk.len();
if len == 0 {
return;
}
let max_idx = len - 1;
let current = self.selected.min(max_idx);
self.selected = (current as isize + delta).clamp(0, max_idx as isize) as usize;
}
/// Move selection by a page (half of visible height).
pub fn page_move(&mut self, delta: isize, visible_rows: usize) {
let half = (visible_rows / 2).max(1) as isize;
self.move_selection(delta * half);
}
/// Ensure the selected item is visible in the dropdown viewport.
pub fn ensure_visible(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected + 1 - visible_rows;
}
}
// ── Selection / replacement ─────────────────────────────────────────
/// Select the hovered item (for click-to-accept).
/// Returns `true` if there was a valid hovered item to select.
pub fn select_hovered(&mut self) -> bool {
if let Some(idx) = self.hovered
&& idx < self.results.topk.len()
{
self.selected = idx;
return true;
}
false
}
/// Get the currently selected fuzzy match result.
pub fn selected_result(&self) -> Option<&FuzzyMatchResult> {
self.results.topk.get(self.selected)
}
/// Compute the text replacement for accepting the currently selected result.
///
/// The `src` parameter is the full prompt text (needed to detect edge cases
/// like "replacement is a no-op" for directory drill-down).
pub fn try_replace(&mut self, src: &str) -> Option<FileSearchReplacement> {
let ctx = self.context.as_ref()?;
let res = self.results.topk.get(self.selected)?;
let path_str = res.path.to_string();
let mut text = normalize_display_path(&path_str).to_owned();
// Replace only the path portion of the @-token (preserving `@`
// and any hidden-mode `!` marker). See `AtContext::path_range`.
let range = ctx.path_range();
let mut cursor = range.start + text.len() + 1;
let dismiss;
if ctx.is_dir_mode() {
// Directory mode: append `/` and stay in completion for drill-down.
text = format!("{text}/");
if range.end <= src.len() && src[range.clone()] == text[..] {
// No-op replacement (same text already there) — treat as "done".
cursor += 1;
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
} else {
dismiss = false; // Stay in completion mode (drill-down).
}
} else {
// File mode: append trailing space if at end of input.
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
}
if dismiss {
self.context = None;
self.drill_prefix = None;
}
Some(FileSearchReplacement {
range,
text,
cursor,
dismiss,
})
}
/// Number of result items.
pub fn result_count(&self) -> usize {
self.results.topk.len()
}
/// Total items the matcher knows about (for "k/n" display).
pub fn total_items(&self) -> usize {
self.results.num_items
}
/// Test-only: install a fake context + results snapshot so tests can drive
/// acceptance flows without spinning up the background fuzzy daemon.
///
/// **Mixing with daemon polling is unsupported.** This helper assigns
/// `generation = self.min_generation` without bumping `min_generation`,
/// which means a real daemon poll occurring after `set_test_state` could
/// deliver same-generation results that overwrite the seeded fake state
/// non-deterministically. Tests that use this helper must not also drive
/// real daemon polls; if a future test needs both, bump
/// `self.min_generation` here so any in-flight daemon results are
/// rejected.
#[cfg(test)]
pub(crate) fn set_test_state(
&mut self,
context: AtContext,
results: Vec<FuzzyMatchResult>,
selected: usize,
) {
self.context = Some(context);
self.results = FuzzyMatcherDaemonResults {
topk: Arc::from(results),
num_items: 0,
status: Default::default(),
generation: self.min_generation,
};
self.selected = selected;
}
}