diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml new file mode 100644 index 0000000000..05b15028e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5 +2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md new file mode 100644 index 0000000000..1a13600921 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md @@ -0,0 +1,33 @@ +# Agent Note: TUI file-reference autocomplete + +Status: implemented + +English | [中文](2026-07-23-tui-file-reference-autocomplete.zh.md) + +## Problem + +The TUI offered structured `@session` references but no dependable way to discover workspace paths while composing a prompt. Requiring users to remember exact paths made file-oriented requests unnecessarily awkward, while eagerly attaching every selected file would spend context before the model knew whether its contents were relevant and would hide the normal `read` observation from the tool transcript. + +## Decision + +The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. + +Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. + +Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable. + +## Alternatives considered + +**Eagerly inject selected file contents.** This spends tokens before relevance is known, can capture stale content before execution reaches the reference, and bypasses the auditable `read` call/result sequence. + +**Require an external file finder.** Depending on `fd`, `rg --files`, or another executable would make baseline completion vary by host installation and complicate cancellation and cross-platform behavior. + +**Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments. + +**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely. + +## Consequences + +Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip. + +Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md new file mode 100644 index 0000000000..410f0d49db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI 文件引用自动补全 + +Status: implemented + +[English](2026-07-23-tui-file-reference-autocomplete.md) | 中文 + +## 问题 + +TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法可靠地发现工作区路径。要求用户记住准确路径会给面向文件的请求带来不必要的麻烦;如果直接附加每个选中文件,则会在模型判断其内容是否相关之前占用上下文,并在工具 transcript(文本记录)中隐藏常规的 `read` 观察结果。 + +## 决策 + +TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 + +选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 + +结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。 + +## 备选方案 + +**直接注入选中文件的内容。** 这种方式会在确定相关性前消耗 token,可能在执行到该引用前捕获到陈旧内容,并绕过可审计的 `read` 调用与结果序列。 + +**要求使用外部文件查找器。** 依赖 `fd`、`rg --files` 或其他可执行文件,会使基础补全行为随主机安装情况而变化,也会增加取消处理和跨平台支持的复杂度。 + +**使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。 + +**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。 + +## 影响 + +用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。 + +补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 24ddfea11e..8abdee7b90 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1553,7 +1553,7 @@ export interface Config extends TuiConfig { resumeCommand?: string } -/** Presentation settings for the pi-tui terminal mode. */ +/** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean @@ -1571,6 +1571,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. */ @@ -1587,7 +1593,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:217`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:248`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 624041854e..b22c1af82f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -13,14 +13,24 @@ const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) /** - * Seed the harness workspace: personal files land in the isolated Harness home - * (`.dsh`), skill bundles under the agents home's `skills/` root — the same - * trees `$DSH_HOME` / `$DSH_AGENTS_HOME` point the child at. + * Seed the isolated process workspace: ordinary files land in `cwd`, personal + * files in the Harness home (`.dsh`), and skill bundles under the agents + * home's `skills/` root — the same trees `$DSH_HOME` / + * `$DSH_AGENTS_HOME` point the child at. */ function seedWorkspace( - files: { personal?: Record; skills?: Record }, + files: { + workspace?: Record + personal?: Record + skills?: Record + }, ): (cwd: string) => Promise { return async (cwd) => { + for (const [name, content] of Object.entries(files.workspace ?? {})) { + const file = join(cwd, name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, content) + } for (const [name, content] of Object.entries(files.personal ?? {})) { const file = join(cwd, '.dsh', name) await mkdir(dirname(file), { recursive: true }) @@ -168,6 +178,27 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fuzzy-completes an @file path without reading or submitting the file', async () => { + const output = await smoke({ + label: 'tui-agent file autocomplete', + tempDirPrefix: 'tui-agent-file-autocomplete-', + prepare: seedWorkspace({ + workspace: { + 'src/terminal-special-case.ts': 'export const marker = true\n', + 'src/other.ts': 'export const other = true\n', + }, + }), + actions: [ + { waitFor: 'main-session-', send: '@tsc' }, + { waitFor: 'File · terminal-special-case.t', send: '\t' }, + { waitFor: '@src/terminal-special-case.ts', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('File · terminal-special-case.t') + expect(output).toContain('@src/terminal-special-case.ts') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { // The overlay's only keyless composition proof: the include+patch tree, // worker code runtime, and one-tool registry all mount before the banner. diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index a36b004def..5f61ab4568 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -26,7 +26,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' @@ -302,6 +302,9 @@ async function runScenario(scenario: Scenario): Promise { } const events: SessionEvent[] = [...agent.session.events] + const firstHeader = events.find(event => event.type === 'request/header') + expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) + .toContain(FILE_REFERENCE_PROMPT) expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) @@ -309,7 +312,6 @@ async function runScenario(scenario: Scenario): Promise { if (scenario.enterPlanMode === true) { expect(ctx.planMode.get(agent)).toEqual({ active: true }) const planMode = events.find(event => event.type === 'plan/mode') - const firstHeader = events.find(event => event.type === 'request/header') if (planMode === undefined || firstHeader === undefined) { throw new Error('plan-mode command snapshot needs plan/mode before its first request/header') } diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 1ef9170576..21601fa280 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -2,7 +2,7 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. -The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. +The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. @@ -16,7 +16,9 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. +Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. + +When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. @@ -44,6 +46,9 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | +| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | +| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | @@ -57,6 +62,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. @@ -81,6 +87,26 @@ Submitted text is retained under the agent loop's normal session-history and com Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### File-reference autocomplete + +#### What the model sees + +A selected file remains ordinary user text such as `@src/index.ts` or `@"docs/design notes.md"`; autocomplete adds no content block, durable context, or special reference payload. When `read` is registered, every request from this TUI agent also contains the following fixed system-prompt section. The model decides whether the task requires the file contents and calls `read` through the normal tool loop when it does; a path alone is not evidence that the file was inspected. + +##### Exact system-prompt text + +```markdown +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. +``` + +#### Token effect + +Autocomplete itself adds no tokens. The selected path contributes only its ordinary user-text tokens; the fixed instruction contributes system-prompt tokens whenever `read` is available. File contents consume context only after a model-selected `read` call returns them. + +#### KV Cache effect + +The fixed instruction is part of the stable system-prompt prefix and is reusable across turns. Each selected path is append-only user text; a later `read` result appends the requested contents through the ordinary tool transcript. + ### Session model selection #### What the model sees @@ -129,3 +155,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. - **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. +- **File discovery is host-workspace discovery** — autocomplete reads the TUI process's session `cwd`, while the selected text is later interpreted by the configured `read` tool. Deployments that mount a remote or virtual filesystem must keep those namespaces aligned or provide another completion surface. +- **File search uses explicit directory exclusions, not ignore files** — `.git` and `node_modules` are excluded by default and deployments may configure more basenames, but `.gitignore` and `.ignore` are not interpreted. Directory symlinks are not traversed. diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/file-autocomplete.ts new file mode 100644 index 0000000000..23a3a7c1fa --- /dev/null +++ b/packages/ui/tui/src/file-autocomplete.ts @@ -0,0 +1,346 @@ +/** + * 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 { lstat, 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 +} + +/** + * 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 + 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 { + 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 { + 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 { + 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 { + if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] + const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) + 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) + } +} + +async function resolveDisplayDirectory( + root: string, + displayDirectory: string, + signal: AbortSignal, +): Promise { + 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 + let current = resolvedRoot + for (const segment of fromRoot.split(sep).filter(Boolean)) { + signal.throwIfAborted() + current = join(current, segment) + try { + const status = await lstat(current) + signal.throwIfAborted() + if (status.isSymbolicLink() || !status.isDirectory()) return undefined + } catch (_error: unknown) { + signal.throwIfAborted() + 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(promise: Promise, signal: AbortSignal): Promise { + /* 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((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 }) +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a75703c61b..be0f309106 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -146,11 +146,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 @@ -168,6 +185,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. */ @@ -191,14 +214,16 @@ 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`. const truecolorSchema = z.boolean() const titleSchema = z.string().default('DeepSeek Harness') -/** Schemastery schema for presentation settings embedded by app bundles. */ -export const TuiConfigSchema: z = z.object({ +const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, @@ -207,11 +232,17 @@ export const TuiConfigSchema: z = z.object({ questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, truecolor: truecolorSchema, title: titleSchema, -}) +} + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) /** Serializable plugin configuration. */ export interface Config extends TuiConfig { @@ -233,18 +264,21 @@ export const Config: z = z.object({ welcome: z.string(), sessionId: z.string().default('main'), resumeCommand: z.string(), - showReasoning: showReasoningSchema, - maxToolOutputLines: maxToolOutputLinesSchema, - maxQuestionOptions: maxQuestionOptionsSchema, - maxModelOptions: maxModelOptionsSchema, - questionDialogWidth: questionDialogWidthSchema, - questionDialogMaxHeight: questionDialogMaxHeightSchema, - modelDialogWidth: modelDialogWidthSchema, - modelDialogMaxHeight: modelDialogMaxHeightSchema, - showHardwareCursor: showHardwareCursorSchema, - color: colorSchema, - truecolor: truecolorSchema, - title: titleSchema, + showReasoning: tuiConfigSchemaFields.showReasoning, + maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, + maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, + questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, + modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, + modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, + fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, + fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, + showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, + color: tuiConfigSchemaFields.color, + truecolor: tuiConfigSchemaFields.truecolor, + title: tuiConfigSchemaFields.title, }) /** Fully defaulted TUI presentation settings. */ @@ -257,6 +291,9 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + fileSearchMaxResults: number + fileSearchMaxEntries: number + fileSearchExcludedDirectories: string[] showHardwareCursor: boolean color: boolean truecolor: boolean @@ -295,6 +332,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, @@ -1349,11 +1389,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, ) {} @@ -1367,17 +1408,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) @@ -1388,8 +1445,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( @@ -1558,6 +1616,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() @@ -2327,9 +2390,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() @@ -2413,6 +2479,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', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, + }) + }) const runCommand = (text: string): void => { const controller = new AbortController() @@ -2660,6 +2736,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') { @@ -2708,6 +2785,7 @@ export function createTuiChat( const detachListeners = (): void => { skillAbort.abort() + fileSearch.dispose() removeInputListener() disposeCommandChanges() stopBannerReveal() @@ -2755,10 +2833,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() @@ -2775,7 +2856,10 @@ export function createTuiChat( async dispose(): Promise { detachListeners() await shutdown(false) - await commandFiber.dispose() + await Promise.all([ + commandFiber.dispose(), + fileReferencePromptFiber.dispose(), + ]) }, } } diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts new file mode 100644 index 0000000000..53dd1f4f1a --- /dev/null +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -0,0 +1,197 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + activeAtToken, + formatFileMention, + WorkspaceFileSearch, +} from '../src/file-autocomplete.ts' + +const searches: WorkspaceFileSearch[] = [] +const roots: string[] = [] + +async function workspace(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-')) + roots.push(root) + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'docs'), { recursive: true }) + await mkdir(join(root, '.hidden'), { recursive: true }) + await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true }) + await writeFile(join(root, 'README.md'), 'readme') + await writeFile(join(root, 'src', 'tui.spec.ts'), 'test') + await writeFile(join(root, 'src', 'terminal-view.ts'), 'view') + await writeFile(join(root, 'docs', 'design notes.md'), 'design') + await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden') + await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored') + try { + await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts')) + } catch { + // Windows may deny symlink creation without Developer Mode; the product + // still skips every non-file/non-directory Dirent on platforms that expose one. + } + return root +} + +function search(root: string, overrides: Partial[1]> = {}): WorkspaceFileSearch { + const instance = new WorkspaceFileSearch(root, { + maxResults: overrides.maxResults ?? 20, + maxEntries: overrides.maxEntries ?? 10_000, + excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'], + }) + searches.push(instance) + return instance +} + +afterEach(async () => { + for (const instance of searches.splice(0)) instance.dispose() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('TUI file autocomplete grammar', () => { + it('recognizes boundary and quoted mentions without treating emails as references', () => { + expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false }) + expect(activeAtToken('read @"docs/design n', 20)).toEqual({ + prefix: '@"docs/design n', + query: 'docs/design n', + quoted: true, + }) + expect(activeAtToken('mail a@b.test', 13)).toBeUndefined() + expect(activeAtToken('done @src/x" next', 17)).toBeUndefined() + }) + + it('formats files, directories, quotes, and rejects unsafe editor values', () => { + expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts') + expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/') + expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false)) + .toBe('@"docs/design notes.md"') + expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"') + expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined() + }) +}) + +describe('WorkspaceFileSearch', () => { + it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => { + const root = await workspace() + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('', signal)).toEqual([ + { path: 'docs', kind: 'directory' }, + { path: 'src', kind: 'directory' }, + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('src/', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('src/ts', signal)).toEqual([ + { path: 'src/tui.spec.ts', kind: 'file' }, + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('docs/design n', signal)).toEqual([ + { path: 'docs/design notes.md', kind: 'file' }, + ]) + expect(await files.list('node_modules/', signal)).toEqual([]) + expect(await files.list('.hidden/', signal)).toEqual([ + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/` + expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([ + { path: `${absoluteSrc}tui.spec.ts`, kind: 'file' }, + { path: `${absoluteSrc}terminal-view.ts`, kind: 'file' }, + ]) + expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([]) + expect(await files.list('../', signal)).toEqual([]) + expect(await files.list('README.md/', signal)).toEqual([]) + }) + + it('does not traverse directory symlinks during direct completion', async () => { + const root = await workspace() + const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-')) + roots.push(outside) + await writeFile(join(outside, 'outside-secret.txt'), 'secret') + await symlink( + outside, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('escape/', signal)).toEqual([]) + expect(await files.list('escape/outside', signal)).toEqual([]) + }) + + it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => { + const root = await workspace() + await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper') + const files = search(root, { maxResults: 2 }) + const signal = new AbortController().signal + + expect(await files.list('tspc', signal)).toEqual([ + { path: 'src/tspc-helper.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('README.md', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('terminal', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('secret', signal)).toEqual([]) + expect(await files.list('.hidden', signal)).toEqual([ + { path: '.hidden', kind: 'directory' }, + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + }) + + it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => { + const root = await workspace() + const capped = search(root, { maxEntries: 2 }) + const signal = new AbortController().signal + expect(await capped.list('README', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + + const files = search(root) + expect(await files.list('fresh-file', signal)).toEqual([]) + await writeFile(join(root, 'fresh-file.ts'), 'fresh') + expect(await files.list('fresh-file', signal)).toEqual([]) + files.invalidate() + expect(await files.list('fresh-file', signal)).toEqual([ + { path: 'fresh-file.ts', kind: 'file' }, + ]) + files.dispose() + expect(await files.list('fresh-file', signal)).toEqual([]) + files.dispose() + }) + + it('cancels individual callers, skips missing directories, and validates limits', async () => { + const root = await workspace() + expect(() => search(root, { maxResults: 0 })).toThrow('maxResults') + expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries') + expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames') + + const files = search(root) + expect(await files.list('missing/', new AbortController().signal)).toEqual([]) + + const preAborted = new AbortController() + preAborted.abort(new Error('pre-aborted')) + await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted') + + files.invalidate() + const running = new AbortController() + const pending = files.list('tui', running.signal) + running.abort(new Error('superseded')) + await expect(pending).rejects.toThrow('superseded') + + files.invalidate() + const nonErrorAbort = new AbortController() + const nonErrorPending = files.list('tui', nonErrorAbort.signal) + nonErrorAbort.abort('cancelled') + await expect(nonErrorPending).rejects.toThrow('file search aborted') + }) +}) diff --git a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt new file mode 100644 index 0000000000..174f21a0f4 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt @@ -0,0 +1,24 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=5 viewportRow=4 bufferRow=4 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +4| " @tsc " + style 5-5 inverse +5| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +6| " → File · terminal-special-case.t src/terminal-special-case.ts " + style 1-32 fg=bright-blue +7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +8-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 2bc2dc9e72..182f9aae14 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -1,4 +1,5 @@ -import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' @@ -31,6 +32,7 @@ const CHECKPOINTS = [ 'retry-cancelled', 'retry-exhausted', 'banner-gradient', + 'file-autocomplete', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -337,6 +339,24 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins fuzzy file candidates and the active path-only mention', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-snapshot-')) + await mkdir(join(cwd, 'src'), { recursive: true }) + await writeFile(join(cwd, 'src', 'terminal-special-case.ts'), 'export const marker = true\n') + await writeFile(join(cwd, 'src', 'terminal-state.ts'), 'export const state = true\n') + const harness = await setupSnapshot({ cwd, formatCwd: () => '/workspace/project' }) + try { + harness.terminal.send('@tsc') + await vi.waitFor(async () => { + expect(await harness.terminal.snapshot()).toContain('File · terminal-special-case.t') + }) + await checkpoint('file-autocomplete', harness.terminal) + } finally { + await disposeSnapshot(harness) + await rm(cwd, { recursive: true, force: true }) + } + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 14e54bd368..1409758e62 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,4 +1,5 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' @@ -16,6 +17,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, + FILE_REFERENCE_PROMPT, mountTui, renderSkillInvocation, resolveTuiConfig, @@ -23,6 +25,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' +import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' import { appendAssistant, appendUser, @@ -153,6 +156,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, + fileSearchMaxResults: 20, + fileSearchMaxEntries: 10_000, + fileSearchExcludedDirectories: ['.git', 'node_modules'], showHardwareCursor: false, color: true, truecolor: false, @@ -167,6 +173,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + fileSearchMaxResults: 7, + fileSearchMaxEntries: 123, + fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, color: false, truecolor: true, @@ -180,6 +189,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + fileSearchMaxResults: 7, + fileSearchMaxEntries: 123, + fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, color: false, truecolor: true, @@ -1063,6 +1075,125 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('fuzzy-completes files and directories while sending only the selected path text', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-')) + await mkdir(join(cwd, 'src'), { recursive: true }) + await mkdir(join(cwd, 'docs'), { recursive: true }) + await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') + await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') + await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') + const result = await setup({ + cwd, + tools: { + read: { + name: 'read', + description: 'Read a file.', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: () => Promise.resolve([]), + }, + }, + }) + try { + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.sections).toContainEqual({ + name: 'ui:tui-file-reference', + text: FILE_REFERENCE_PROMPT, + }) + + result.terminal.send('@sfts') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · source-file.ts') + }) + expect(result.terminal.output).toContain('src/source-file.ts') + result.terminal.send('\t') + await tick() + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) }) + expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }]) + expect(result.agent.sentOptions[0]?.contexts).toEqual([]) + + result.terminal.send('@do') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Folder · docs/') + }) + result.terminal.send('\t') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · design notes.md') + }) + result.terminal.send('\t') + await tick() + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) + expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) + expect(result.agent.sentOptions[1]?.contexts).toEqual([]) + + result.terminal.send('@unsafe') + await tick() + expect(result.terminal.output).not.toContain('File · unsafe') + result.terminal.send('\x03') + } finally { + await result.controller.dispose() + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.sections).not.toContainEqual({ + name: 'ui:tui-file-reference', + text: FILE_REFERENCE_PROMPT, + }) + await result.ctx.fiber.dispose() + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('isolates failed file discovery from editor autocomplete', async () => { + const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed')) + const result = await setup() + try { + result.terminal.send('@failed') + await vi.waitFor(() => { expect(list).toHaveBeenCalled() }) + await tick() + expect(result.agent.sent).toEqual([]) + } finally { + list.mockRestore() + await dispose(result) + } + }) + + it('shows file-reference guidance only while read is visible to the agent', async () => { + const read: ToolDefinition = { + name: 'read', + description: 'Read a file.', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: () => Promise.resolve([]), + } + let visibility: 'none' | 'global' | 'agent' = 'none' + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { + get(name: string, scope?: Agent) { + if (name !== 'read' || visibility === 'none') return undefined + return (scope === undefined) === (visibility === 'global') ? read : undefined + }, + } as never) + }, + }) + const fileReferenceText = async (): Promise => { + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text + } + try { + expect(await fileReferenceText()).toBe('') + visibility = 'global' + expect(await fileReferenceText()).toBe('') + visibility = 'agent' + expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT) + visibility = 'none' + expect(await fileReferenceText()).toBe('') + } finally { + await dispose(result) + } + }) + it('escapes session autocomplete metadata while preserving the referenced session id', async () => { const unsafeId = SessionId('evil\x1b\x07\u009b\ns') const unsafeCwd = '/x/\x1b\x07\u009b\nf'