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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user