diff --git a/crates/pi-natives/src/fff.rs b/crates/pi-natives/src/fff.rs index df0e7c2a1..54e865bb7 100644 --- a/crates/pi-natives/src/fff.rs +++ b/crates/pi-natives/src/fff.rs @@ -12,7 +12,11 @@ use fff::{FileItem, FilePicker, FuzzySearchOptions, PaginationArgs, QueryParser} use napi::bindgen_prelude::*; use napi_derive::napi; -use crate::{fs_cache, search_db::SearchDb, task}; +use crate::{ + fs_cache, + search_db::{SearchDb, wait_for_picker_scan}, + task, +}; // ═══════════════════════════════════════════════════════════════════════════ // Public types @@ -195,9 +199,10 @@ fn search_stateful_files( query: &str, limit: usize, db: &SearchDb, + ct: &task::CancelToken, ) -> Result<(Vec, u32)> { let shared_picker = db.get_or_init_picker(root)?; - FilePicker::wait_for_scan(&shared_picker); + wait_for_picker_scan(&shared_picker, ct)?; let guard = shared_picker .read() .map_err(|_| Error::from_reason("shared picker lock poisoned"))?; @@ -250,7 +255,7 @@ fn run_fff_search( && include_hidden && respect_gitignore { - let (mut file_matches, file_total) = search_stateful_files(root, query, limit, db)?; + let (mut file_matches, file_total) = search_stateful_files(root, query, limit, db, ct)?; let (dir_matches, dir_total) = search_stateless_entries(root, entries, query, limit, false, true, None, ct)?; file_matches.extend(dir_matches); diff --git a/crates/pi-natives/src/glob.rs b/crates/pi-natives/src/glob.rs index 2d59dcddd..9fc48ff01 100644 --- a/crates/pi-natives/src/glob.rs +++ b/crates/pi-natives/src/glob.rs @@ -25,7 +25,11 @@ use napi_derive::napi; // Re-export entry types so existing `glob::FileType` / `glob::GlobMatch` paths still work. pub use crate::fs_cache::{FileType, GlobMatch}; -use crate::{fs_cache, glob_util, task}; +use crate::{ + fs_cache, glob_util, + search_db::{SearchDb, wait_for_picker_scan}, + task, +}; /// Input options for `glob`, including traversal, filtering, and cancellation. #[napi(object)] @@ -121,6 +125,66 @@ fn apply_file_type_filter(entry: &GlobMatch, config: &GlobConfig) -> Option bool { + path.split('/').any(|component| component.starts_with('.')) +} + +/// Collect file matches from the shared `SearchDb` picker. +/// +/// The picker indexes files only (no directories/symlinks), so this path is +/// currently used for `fileType=file` requests when gitignore semantics match +/// the picker configuration. +fn collect_files_from_picker( + root: &Path, + glob_set: &GlobSet, + config: &GlobConfig, + db: &SearchDb, + on_match: Option<&ThreadsafeFunction>, + ct: &task::CancelToken, +) -> Result> { + let shared_picker = db.get_or_init_picker(root)?; + wait_for_picker_scan(&shared_picker, ct)?; + + let guard = shared_picker + .read() + .map_err(|_| Error::from_reason("shared picker lock poisoned"))?; + let Some(picker) = guard.as_ref() else { + return Ok(Vec::new()); + }; + + let mut matches = Vec::new(); + for file in picker.get_files() { + ct.heartbeat()?; + let relative_path = file.relative_path.replace('\\', "/"); + if !config.include_hidden && has_hidden_component(&relative_path) { + continue; + } + if fs_cache::should_skip_path(Path::new(&relative_path), config.mentions_node_modules) { + continue; + } + if !glob_set.is_match(Path::new(&relative_path)) { + continue; + } + + let matched_entry = GlobMatch { + path: relative_path, + file_type: FileType::File, + mtime: Some((file.modified as f64) * 1000.0), + }; + + if let Some(callback) = on_match { + callback.call(Ok(matched_entry.clone()), ThreadsafeFunctionCallMode::NonBlocking); + } + matches.push(matched_entry); + if !config.sort_by_mtime && matches.len() >= config.max_results { + break; + } + } + + Ok(matches) +} + /// Filter and collect matching entries from a pre-scanned list. fn filter_entries( entries: &[GlobMatch], @@ -165,6 +229,7 @@ fn filter_entries( /// hit. fn run_glob( config: GlobConfig, + db: Option<&SearchDb>, on_match: Option<&ThreadsafeFunction>, ct: task::CancelToken, ) -> Result { @@ -173,7 +238,12 @@ fn run_glob( return Ok(GlobResult { matches: Vec::new(), total_matches: 0 }); } - let mut matches = if config.use_cache { + let mut matches = if let Some(db) = db + && config.use_gitignore + && config.file_type_filter == Some(FileType::File) + { + collect_files_from_picker(&config.root, &glob_set, &config, db, on_match, &ct)? + } else if config.use_cache { let scan = fs_cache::get_or_scan(&config.root, config.include_hidden, config.use_gitignore, &ct)?; let mut matches = filter_entries(&scan.entries, &glob_set, &config, on_match, &ct)?; @@ -234,7 +304,7 @@ pub fn glob( #[napi(ts_arg_type = "((match: GlobMatch) => void) | undefined | null")] on_match: Option< ThreadsafeFunction, >, - _db: Option<&crate::search_db::SearchDb>, + db: Option<&SearchDb>, ) -> task::Async { let GlobOptions { pattern, @@ -256,6 +326,7 @@ pub fn glob( let pattern = pattern.to_string(); let ct = task::CancelToken::new(timeout_ms, signal); + let db = db.cloned(); task::blocking("glob", ct, move |ct| { run_glob( @@ -272,6 +343,7 @@ pub fn glob( use_cache: cache.unwrap_or(false), pattern, }, + db.as_ref(), on_match.as_ref(), ct, ) diff --git a/crates/pi-natives/src/grep.rs b/crates/pi-natives/src/grep.rs index cc45462eb..0ace6fbe7 100644 --- a/crates/pi-natives/src/grep.rs +++ b/crates/pi-natives/src/grep.rs @@ -28,7 +28,11 @@ use napi_derive::napi; use rayon::prelude::*; use smallvec::SmallVec; -use crate::{fs_cache, glob_util, search_db::SearchDb, task}; +use crate::{ + fs_cache, glob_util, + search_db::{SearchDb, wait_for_picker_scan}, + task, +}; const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; @@ -793,16 +797,13 @@ fn collect_files_from_picker( include_hidden: bool, ct: &task::CancelToken, ) -> Result> { - use fff::FilePicker; - let shared_picker = db.get_or_init_picker(root)?; ct.heartbeat()?; // Wait for the background scan to finish. On repeated calls this is a // no-op (the signal is already cleared). On first call it blocks until // the initial directory walk completes, which is equivalent in latency // to a fresh fs_cache::force_rescan but is then never repeated. - FilePicker::wait_for_scan(&shared_picker); - ct.heartbeat()?; + wait_for_picker_scan(&shared_picker, ct)?; let guard = shared_picker .read() diff --git a/crates/pi-natives/src/search_db.rs b/crates/pi-natives/src/search_db.rs index 5f9fbf02e..d4f51cf6f 100644 --- a/crates/pi-natives/src/search_db.rs +++ b/crates/pi-natives/src/search_db.rs @@ -3,13 +3,20 @@ //! This owns search-side shared state that should outlive individual native //! calls: frecency tracking plus a per-root cache of `fff` file pickers. -use std::{collections::HashMap, path::Path, sync::Arc}; +use std::{ + collections::HashMap, + path::Path, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; use fff::{FFFMode, FileItem, FilePicker, FrecencyTracker, SharedFrecency, SharedPicker}; use napi::{Error, bindgen_prelude::Result}; use napi_derive::napi; use parking_lot::Mutex; +use crate::task; + struct SearchDbInner { path: String, shared_frecency: SharedFrecency, @@ -106,3 +113,23 @@ impl SearchDb { let _ = item.update_frecency_scores(tracker, FFFMode::Ai); } } + +pub fn wait_for_picker_scan(shared_picker: &SharedPicker, ct: &task::CancelToken) -> Result<()> { + let signal = { + let guard = shared_picker + .read() + .map_err(|_| Error::from_reason("shared picker lock poisoned"))?; + let Some(picker) = guard.as_ref() else { + return Ok(()); + }; + picker.scan_signal() + }; + + while signal.load(Ordering::Acquire) { + ct.heartbeat()?; + std::thread::sleep(Duration::from_millis(10)); + } + + ct.heartbeat()?; + Ok(()) +} diff --git a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts index 212effd3d..514406c7a 100644 --- a/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts +++ b/packages/coding-agent/src/commit/agentic/tools/analyze-file.ts @@ -43,6 +43,7 @@ function buildToolSession( settings: options.settings, authStorage: options.authStorage, modelRegistry: options.modelRegistry, + searchDb: ctx.searchDb, }; } diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 60f34d684..415c1cbcc 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -6,6 +6,7 @@ */ import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import type { SearchDb } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Rule } from "../../capability/rule"; @@ -71,6 +72,8 @@ export interface CustomToolContext { modelRegistry: ModelRegistry; /** Current model (may be undefined if no model is selected yet) */ model: Model | undefined; + /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */ + searchDb?: SearchDb; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Whether there are queued messages waiting to be processed */ diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 87e980c3e..9758a3b8d 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -3,6 +3,7 @@ */ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model } from "@oh-my-pi/pi-ai"; +import type { SearchDb } from "@oh-my-pi/pi-natives"; import type { KeyId } from "@oh-my-pi/pi-tui"; import { logger } from "@oh-my-pi/pi-utils"; import type { ModelRegistry } from "../../config/model-registry"; @@ -160,6 +161,7 @@ export class ExtensionRunner { #uiContext: ExtensionUIContext; #errorListeners: Set = new Set(); #getModel: () => Model | undefined = () => undefined; + #getSearchDbFn: () => SearchDb | undefined = () => undefined; #isIdleFn: () => boolean = () => true; #waitForIdleFn: () => Promise = async () => {}; #abortFn: () => void = () => {}; @@ -205,6 +207,7 @@ export class ExtensionRunner { // Context actions (required) this.#getModel = contextActions.getModel; + this.#getSearchDbFn = contextActions.getSearchDb ?? (() => undefined); this.#isIdleFn = contextActions.isIdle; this.#abortFn = contextActions.abort; this.#hasPendingMessagesFn = contextActions.hasPendingMessages; @@ -376,6 +379,7 @@ export class ExtensionRunner { createContext(): ExtensionContext { const getModel = this.#getModel; + const getSearchDb = this.#getSearchDbFn; return { ui: this.#uiContext, getContextUsage: () => this.#getContextUsageFn(), @@ -387,6 +391,9 @@ export class ExtensionRunner { get model() { return getModel(); }, + get searchDb() { + return getSearchDb(); + }, isIdle: () => this.#isIdleFn(), abort: () => this.#abortFn(), hasPendingMessages: () => this.#hasPendingMessagesFn(), diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 9cfdae574..3a657cac3 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -22,6 +22,7 @@ import type { ToolResultMessage, } from "@oh-my-pi/pi-ai"; import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent"; +import type { SearchDb } from "@oh-my-pi/pi-natives"; import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Rule } from "../../capability/rule"; @@ -229,6 +230,8 @@ export interface ExtensionContext { modelRegistry: ModelRegistry; /** Current model (may be undefined) */ model: Model | undefined; + /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */ + searchDb?: SearchDb; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Abort the current agent operation */ @@ -1295,6 +1298,7 @@ export interface ExtensionActions { /** Actions for ExtensionContext (ctx.* in event handlers). */ export interface ExtensionContextActions { getModel: () => Model | undefined; + getSearchDb?: () => SearchDb | undefined; isIdle: () => boolean; abort: () => void; hasPendingMessages: () => boolean; diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 1b5303ca8..4dfd21547 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -832,6 +832,7 @@ export class AcpAgent implements Agent { }, { getModel: () => this.#session.model, + getSearchDb: () => this.#session.searchDb, isIdle: () => !this.#session.isStreaming, abort: () => { void this.#session.abort(); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index cad0e31f1..b8a022874 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -123,6 +123,7 @@ export class ExtensionUiController { }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, + getSearchDb: () => this.ctx.session.searchDb, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, @@ -384,6 +385,7 @@ export class ExtensionUiController { }; const contextActions: ExtensionContextActions = { getModel: () => this.ctx.session.model, + getSearchDb: () => this.ctx.session.searchDb, isIdle: () => !this.ctx.session.isStreaming, abort: () => this.ctx.session.abort(), hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, @@ -581,6 +583,7 @@ export class ExtensionUiController { sessionManager: this.ctx.session.sessionManager, modelRegistry: this.ctx.session.modelRegistry, model: this.ctx.session.model, + searchDb: this.ctx.session.searchDb, isIdle: () => !this.ctx.session.isStreaming, hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0, hasQueuedMessages: () => this.ctx.session.queuedMessageCount > 0, diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index d1a160a79..ed66f0a51 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -550,6 +550,7 @@ export class InputController { return createPromptActionAutocompleteProvider({ commands, basePath, + searchDb: this.ctx.session.searchDb, keybindings: this.ctx.keybindings, copyCurrentLine: () => this.handleCopyCurrentLine(), copyPrompt: () => this.handleCopyPrompt(), diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index a588713b6..315a35740 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -76,6 +76,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti // ExtensionContextActions { getModel: () => session.model, + getSearchDb: () => session.searchDb, isIdle: () => !session.isStreaming, abort: () => session.abort(), hasPendingMessages: () => session.queuedMessageCount > 0, diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 23e972b8d..0737d60bf 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -404,6 +404,7 @@ export async function runRpcMode(session: AgentSession): Promise { // ExtensionContextActions { getModel: () => session.agent.state.model, + getSearchDb: () => session.searchDb, isIdle: () => !session.isStreaming, abort: () => session.abort(), hasPendingMessages: () => session.queuedMessageCount > 0, diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 2b53a93cc..ecb84becd 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -140,6 +140,8 @@ export interface CreateAgentSessionOptions { authStorage?: AuthStorage; /** Model registry. Default: discoverModels(authStorage, agentDir) */ modelRegistry?: ModelRegistry; + /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */ + searchDb?: SearchDb; /** Model to use. Default: from settings, else first available */ model?: Model; @@ -390,6 +392,7 @@ function createCustomToolContext(ctx: ExtensionContext): CustomToolContext { sessionManager: ctx.sessionManager, modelRegistry: ctx.modelRegistry, model: ctx.model, + searchDb: ctx.searchDb, isIdle: ctx.isIdle, hasQueuedMessages: ctx.hasPendingMessages, abort: ctx.abort, @@ -871,7 +874,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }) : undefined; - const searchDb = new SearchDb(getSearchDbDir(agentDir)); + const searchDb = options.searchDb ?? new SearchDb(getSearchDbDir(agentDir)); const pendingActionStore = new PendingActionStore(); const toolSession: ToolSession = { cwd, @@ -1184,6 +1187,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} sessionManager, modelRegistry, model: agent?.state.model, + searchDb, isIdle: () => !session?.isStreaming, hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0, abort: () => session?.abort(), @@ -1550,6 +1554,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} obfuscator, asyncJobManager, pendingActionStore, + searchDb, }); if (model?.api === "openai-codex-responses") { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9798e69b0..d9e4737d9 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -50,6 +50,7 @@ import { modelsAreEqual, parseRateLimitReason, } from "@oh-my-pi/pi-ai"; +import type { SearchDb } from "@oh-my-pi/pi-natives"; import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils"; import type { AsyncJob, AsyncJobManager } from "../async"; import type { Rule } from "../capability/rule"; @@ -237,6 +238,8 @@ export interface AgentSessionConfig { obfuscator?: SecretObfuscator; /** Pending action store for preview/apply workflows */ pendingActionStore?: PendingActionStore; + /** Shared native search DB for grep/glob/fuzzyFind-backed workflows. */ + searchDb?: SearchDb; } /** Options for AgentSession.prompt() */ @@ -348,6 +351,7 @@ export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; readonly settings: Settings; + readonly searchDb: SearchDb | undefined; #asyncJobManager: AsyncJobManager | undefined = undefined; #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; @@ -462,6 +466,7 @@ export class AgentSession { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settings = config.settings; + this.searchDb = config.searchDb; this.#asyncJobManager = config.asyncJobManager; this.#scopedModels = config.scopedModels ?? []; this.#thinkingLevel = config.thinkingLevel; @@ -1888,6 +1893,7 @@ export class AgentSession { sessionManager: this.sessionManager, modelRegistry: this.#modelRegistry, model: this.model, + searchDb: this.searchDb, isIdle: () => !this.isStreaming, hasQueuedMessages: () => this.queuedMessageCount > 0, abort: () => { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 9b01b826c..24a299893 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -5,6 +5,7 @@ */ import path from "node:path"; import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { SearchDb } from "@oh-my-pi/pi-natives"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import Ajv, { type ValidateFunction } from "ajv"; @@ -147,6 +148,7 @@ export interface ExecutorOptions { mcpManager?: MCPManager; authStorage?: AuthStorage; modelRegistry?: ModelRegistry; + searchDb?: SearchDb; settings?: Settings; } @@ -950,6 +952,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.model, + getSearchDb: () => session.searchDb, isIdle: () => !session.isStreaming, abort: () => session.abort(), hasPendingMessages: () => session.queuedMessageCount > 0, diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 7e4e5c7bc..73197e3ad 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -775,6 +775,7 @@ export class TaskTool implements AgentTool { }, authStorage: this.session.authStorage, modelRegistry: this.session.modelRegistry, + searchDb: this.session.searchDb, settings: this.session.settings, mcpManager: this.session.mcpManager, contextFiles, @@ -828,6 +829,7 @@ export class TaskTool implements AgentTool { }, authStorage: this.session.authStorage, modelRegistry: this.session.modelRegistry, + searchDb: this.session.searchDb, settings: this.session.settings, mcpManager: this.session.mcpManager, contextFiles,