feat(tui): add path-only @file autocomplete

This commit is contained in:
Yichen Jiang
2026-07-23 17:39:30 +08:00
parent 1eecf025ff
commit 23fc9cc226
13 changed files with 921 additions and 31 deletions

View File

@@ -0,0 +1,329 @@
/**
* Host-workspace discovery for TUI `@file` completion. The index contains
* paths only: selected values remain ordinary prompt text and file contents
* stay behind the model-facing `read` tool.
*
* @module @deepseek-ai/dsh-tui/file-autocomplete
*/
import { readdir } from 'node:fs/promises'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
/** Default maximum file and directory candidates rendered for one query. */
export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
/** Default maximum entries retained in one workspace search index. */
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000
/** Directory basenames omitted from traversal unless the deployment overrides them. */
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const
/** Resolved limits and exclusions for one TUI workspace index. */
export interface FileSearchConfig {
/** Maximum ranked candidates returned for one query. */
maxResults: number
/** Maximum indexed files and directories. */
maxEntries: number
/** Directory basenames never traversed or offered. */
excludedDirectories: readonly string[]
}
/** One path-only completion candidate inside the session cwd. */
export interface FileSearchCandidate {
/** User-facing path accepted by the normal prompt and filesystem tools. */
path: string
/** Directories keep completion open; files finish the mention. */
kind: 'file' | 'directory'
}
/** Active `@` token ending at the editor cursor. */
export interface ActiveAtToken {
/** Complete token replaced when the user accepts a completion. */
prefix: string
/** Path query after `@` or `@"`. */
query: string
/** Whether the user opened a quoted path. */
quoted: boolean
}
interface IndexedPath extends FileSearchCandidate {}
interface RankedPath {
candidate: FileSearchCandidate
score: number
}
interface IndexGeneration {
controller: AbortController
promise: Promise<IndexedPath[]>
}
/**
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
* inside another token, such as an email address, is not a completion trigger.
* @param line - current editor line.
* @param cursorCol - cursor column within that line.
* @returns the active token, or `undefined` outside an `@` token.
*/
export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined {
const beforeCursor = line.slice(0, cursorCol)
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor)
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
return { prefix: quoted[1], query: quoted[2], quoted: true }
}
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor)
if (plain?.[1] === undefined || plain[2] === undefined) return undefined
return { prefix: plain[1], query: plain[2], quoted: false }
}
/**
* Format a selected path as prompt text. Whitespace uses Pi's quoted
* `@"path"` grammar; directories retain a trailing slash so completion can
* descend another level.
* @param candidate - selected file or directory.
* @param preserveQuote - retain an explicitly opened quote even when unnecessary.
* @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely.
*/
export function formatFileMention(
candidate: FileSearchCandidate,
preserveQuote: boolean,
): string | undefined {
const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
const quoted = preserveQuote || /\s/u.test(path)
if (!quoted) return `@${path}`
return `@"${path}"`
}
/**
* Cancellable, reusable fuzzy index rooted at one agent working directory.
* Directory-scoped queries list live state; bare fuzzy queries share one
* bounded traversal until the `@` interaction ends or a tool result invalidates it.
*/
export class WorkspaceFileSearch {
private readonly excludedDirectories: ReadonlySet<string>
private generation: IndexGeneration | undefined
private disposed = false
constructor(
private readonly root: string,
private readonly config: FileSearchConfig,
) {
if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
throw new Error('file search maxResults must be a positive safe integer')
}
if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
throw new Error('file search maxEntries must be a positive safe integer')
}
if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
throw new Error('file search excludedDirectories entries must be non-empty directory basenames')
}
this.excludedDirectories = new Set(config.excludedDirectories)
}
/**
* Return ranked path candidates for the current token.
* @param rawQuery - path text following `@` or `@"`.
* @param signal - cancels this caller's wait without killing an index shared by a newer query.
* @returns at most `maxResults` deterministic candidates.
*/
async list(rawQuery: string, signal: AbortSignal): Promise<FileSearchCandidate[]> {
signal.throwIfAborted()
if (this.disposed) return []
const query = rawQuery.replaceAll('\\', '/')
const slash = query.lastIndexOf('/')
if (query === '' || slash >= 0) {
const directory = slash < 0 ? '' : query.slice(0, slash + 1)
const fragment = slash < 0 ? '' : query.slice(slash + 1)
return this.listDirectory(directory, fragment, signal)
}
const indexed = await waitForPromise(this.ensureIndex(), signal)
return rankCandidates(
indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)),
query,
this.config.maxResults,
)
}
/** Discard the current index so the next bare query observes a fresh tree. */
invalidate(): void {
this.generation?.controller.abort(new Error('file search index invalidated'))
this.generation = undefined
}
/** Abort traversal and make later queries return no candidates. */
dispose(): void {
if (this.disposed) return
this.disposed = true
this.invalidate()
}
private ensureIndex(): Promise<IndexedPath[]> {
if (this.generation !== undefined) return this.generation.promise
const controller = new AbortController()
const generation = {
controller,
promise: Promise.resolve([] as IndexedPath[]),
} satisfies IndexGeneration
generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => {
/* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */
if (this.generation === generation) this.generation = undefined
throw error
})
this.generation = generation
return generation.promise
}
private async scanWorkspace(signal: AbortSignal): Promise<IndexedPath[]> {
const indexed: IndexedPath[] = []
const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }]
for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) {
signal.throwIfAborted()
const directory = directories[cursor]
/* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */
if (directory === undefined) {
throw new Error('file search selected a missing directory')
}
const entries = await readDirectory(directory.absolute, signal)
for (const entry of entries) {
signal.throwIfAborted()
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
if (entry.isDirectory()) {
if (this.excludedDirectories.has(entry.name)) continue
indexed.push({ path, kind: 'directory' })
directories.push({ absolute: join(directory.absolute, entry.name), relative: path })
} else if (entry.isFile()) {
indexed.push({ path, kind: 'file' })
}
if (indexed.length >= this.config.maxEntries) break
}
}
return indexed
}
private async listDirectory(
displayDirectory: string,
fragment: string,
signal: AbortSignal,
): Promise<FileSearchCandidate[]> {
if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return []
const absolute = resolveDisplayDirectory(this.root, displayDirectory)
if (absolute === undefined) return []
const entries = await readDirectory(absolute, signal)
const candidates: FileSearchCandidate[] = []
for (const entry of entries) {
if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue
if (entry.isDirectory()) {
if (this.excludedDirectories.has(entry.name)) continue
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' })
} else if (entry.isFile()) {
candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' })
}
}
return rankCandidates(candidates, fragment, this.config.maxResults)
}
}
function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined {
const resolvedRoot = resolve(root)
const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory)
const fromRoot = relative(resolvedRoot, absolute)
if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined
/* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */
if (isAbsolute(fromRoot)) return undefined
return absolute
}
async function readDirectory(absolute: string, signal: AbortSignal) {
signal.throwIfAborted()
try {
const entries = await readdir(absolute, { withFileTypes: true })
signal.throwIfAborted()
return entries.sort((left, right) => compareText(left.name, right.name))
} catch (_error: unknown) {
signal.throwIfAborted()
// An unreadable/missing subtree contributes no candidates; other readable
// branches remain useful and autocomplete is advisory.
return []
}
}
function visibleForGlobalQuery(path: string, query: string): boolean {
if (query.startsWith('.') || query.includes('/.')) return true
return !path.split('/').some(segment => segment.startsWith('.'))
}
function rankCandidates(
candidates: readonly FileSearchCandidate[],
query: string,
limit: number,
): FileSearchCandidate[] {
const ranked: RankedPath[] = []
for (const candidate of candidates) {
const score = scoreCandidate(candidate, query)
if (score !== undefined) ranked.push({ candidate, score })
}
ranked.sort((left, right) =>
right.score - left.score
|| kindRank(left.candidate.kind) - kindRank(right.candidate.kind)
|| (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length)
|| compareText(left.candidate.path, right.candidate.path))
return ranked.slice(0, limit).map(entry => entry.candidate)
}
function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined {
if (query === '') return 0
const path = candidate.path.toLowerCase()
const name = path.slice(path.lastIndexOf('/') + 1)
const needle = query.toLowerCase()
const directoryBonus = candidate.kind === 'directory' ? 25 : 0
if (name === needle) return 1_000 + directoryBonus
if (name.startsWith(needle)) return 900 + directoryBonus
if (name.includes(needle)) return 700 + directoryBonus
if (path.includes(needle)) return 500 + directoryBonus
const subsequence = subsequenceScore(path, needle)
return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus
}
function subsequenceScore(target: string, query: string): number | undefined {
let targetIndex = 0
let gap = 0
for (const character of query) {
const found = target.indexOf(character, targetIndex)
if (found < 0) return undefined
gap += found - targetIndex
targetIndex = found + 1
}
return Math.max(0, 100 - gap)
}
function kindRank(kind: FileSearchCandidate['kind']): number {
return kind === 'directory' ? 0 : 1
}
function compareText(left: string, right: string): number {
/* v8 ignore next -- entries and candidates are unique; host enumeration
* order determines which comparison direction sort requests. */
return left < right ? -1 : left > right ? 1 : 0
}
function waitForPromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
/* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */
if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted'))
return new Promise<T>((resolvePromise, rejectPromise) => {
const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) }
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolvePromise(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
rejectPromise(errorReason(error, 'file search index failed'))
},
)
})
}
function errorReason(reason: unknown, fallback: string): Error {
return reason instanceof Error ? reason : new Error(fallback, { cause: reason })
}

View File

@@ -145,11 +145,28 @@ export abstract class TuiExtensionService extends Service {
*/
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
}
import {
activeAtToken,
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
DEFAULT_FILE_SEARCH_MAX_RESULTS,
formatFileMention,
WorkspaceFileSearch,
} from './file-autocomplete.ts'
export {
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
DEFAULT_FILE_SEARCH_MAX_RESULTS,
} from './file-autocomplete.ts'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Presentation settings for the pi-tui terminal mode. */
/** Model guidance for path-only file references selected through the TUI. */
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
/** Interaction and presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
/** Render model reasoning blocks. */
showReasoning?: boolean
@@ -167,6 +184,12 @@ export interface TuiConfig {
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
fileSearchMaxEntries?: number
/** Directory basenames excluded from `@` traversal and completion. */
fileSearchExcludedDirectories?: string[]
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
showHardwareCursor?: boolean
/** Apply the built-in ANSI color palette. */
@@ -190,6 +213,9 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
const showHardwareCursorSchema = z.boolean().default(false)
const colorSchema = z.boolean().default(true)
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
@@ -206,6 +232,9 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
truecolor: truecolorSchema,
@@ -240,6 +269,9 @@ export const Config: z<Config> = z.object({
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
truecolor: truecolorSchema,
@@ -256,6 +288,9 @@ export interface ResolvedTuiConfig {
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
showHardwareCursor: boolean
color: boolean
truecolor: boolean
@@ -294,6 +329,9 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
showHardwareCursor: config?.showHardwareCursor ?? false,
color: config?.color ?? true,
truecolor: config?.truecolor ?? false,
@@ -1348,11 +1386,12 @@ interface PendingQuestion {
overlay: TuiOverlaySession | undefined
}
/** Add session candidates to pi-tui's existing command/file provider. */
class SessionAutocompleteProvider implements AutocompleteProvider {
/** Merge path-only file candidates and optional session snapshots with commands. */
class ReferenceAutocompleteProvider implements AutocompleteProvider {
constructor(
private readonly base: CombinedAutocompleteProvider,
private readonly sessions: SessionReferenceService,
private readonly files: WorkspaceFileSearch,
private readonly sessions: SessionReferenceService | undefined,
private readonly agent: Agent,
) {}
@@ -1366,17 +1405,33 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
const currentLine = lines[cursorLine]
/* v8 ignore next -- Editor always supplies its current state line. */
if (currentLine === undefined) return basePromise
const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1]
if (token === undefined) return basePromise
let candidates
try {
candidates = await this.sessions.listCandidates(this.agent, token.slice(1), undefined, options.signal)
} catch {
const token = activeAtToken(currentLine, cursorCol)
if (token === undefined) {
this.files.invalidate()
return basePromise
}
const base = await basePromise
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
const sessionPromise = this.sessions === undefined || token.quoted
? Promise.resolve([])
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
const [base, fileCandidates, sessionCandidates] = await Promise.all([
basePromise,
filePromise,
sessionPromise,
])
if (options.signal.aborted) return base
const items: AutocompleteItem[] = candidates.map((candidate) => {
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
const value = formatFileMention(candidate, token.quoted)
if (value === undefined) return []
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
const directory = candidate.kind === 'directory'
return [{
value,
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
description: displayInlineText(candidate.path),
}]
})
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
const mentionLabel = displayInlineText(candidate.label)
const sessionId = displayInlineText(candidate.sessionId)
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
@@ -1387,8 +1442,9 @@ class SessionAutocompleteProvider implements AutocompleteProvider {
description,
}
})
const items = [...fileItems, ...sessionItems]
if (items.length === 0) return base
return { items: [...items, ...(base?.items ?? [])], prefix: token }
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
}
applyCompletion(
@@ -1557,6 +1613,11 @@ export function createTuiChat(
// rather than declaring an injection that would make the TUI require them.
const skills = ctx.get('skills')
const cwd = agent.session.header.cwd ?? process.cwd()
const fileSearch = new WorkspaceFileSearch(cwd, {
maxResults: resolved.fileSearchMaxResults,
maxEntries: resolved.fileSearchMaxEntries,
excludedDirectories: resolved.fileSearchExcludedDirectories,
})
const skillAbort = new AbortController()
const tokens = sessionTokens(agent.session)
const toolCards = new Map<string, ToolCardComponent>()
@@ -2326,9 +2387,12 @@ export function createTuiChat(
agent.session.header.cwd ?? process.cwd(),
)
const sessionReferences = ctx.get('sessionReferences')
editor.setAutocompleteProvider(sessionReferences === undefined
? base
: new SessionAutocompleteProvider(base, sessionReferences, agent))
editor.setAutocompleteProvider(new ReferenceAutocompleteProvider(
base,
fileSearch,
sessionReferences,
agent,
))
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
@@ -2412,6 +2476,16 @@ export function createTuiChat(
handler: () => { requestExit(); return { kind: 'success' } },
})
})
const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => {
promptCtx.systemPrompt.section({
name: 'ui:tui-file-reference',
order: 99,
// Tool visibility can change dynamically or by agent scope. Empty
// sections are omitted by renderPrompt, so guidance never names a tool
// that this agent cannot call.
text: () => agent.ctx.tools.get('read') === undefined ? '' : FILE_REFERENCE_PROMPT,
})
})
const runCommand = (text: string): void => {
const controller = new AbortController()
@@ -2659,6 +2733,7 @@ export function createTuiChat(
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'tool/result') fileSearch.invalidate()
recordEventUsage(tokens, event)
advanceTurnPhase(event)
if (event.type === 'steering/message') {
@@ -2707,6 +2782,7 @@ export function createTuiChat(
const detachListeners = (): void => {
skillAbort.abort()
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
stopBannerReveal()
@@ -2754,10 +2830,13 @@ export function createTuiChat(
} catch (error: unknown) {
disposed = true
detachListeners()
void commandFiber.dispose().catch(
void Promise.all([
commandFiber.dispose(),
fileReferencePromptFiber.dispose(),
]).catch(
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
(cleanupError: unknown) => {
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`)
},
)
clearStatus()
@@ -2774,7 +2853,10 @@ export function createTuiChat(
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
await Promise.all([
commandFiber.dispose(),
fileReferencePromptFiber.dispose(),
])
},
}
}