Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -37,6 +37,20 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
@@ -221,7 +235,7 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
if (options.withSessionReferences) {
await ctx.plugin(SessionReferenceService)
}

View File

@@ -16,7 +16,7 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the cordis Loader's internal module loader when Node runs with `--expose-internals` or the optional `node-addon-require-builtin` fallback is installed; without either, consumers must install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory with no flag. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself.
@@ -39,7 +39,7 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
## Known Limitations and Deferred Work
- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping.
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.

View File

@@ -209,9 +209,8 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
* `cordis:include` builtin, loading through the ambient module pipeline
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
* config-relative. A missing fiber rejects here; a later init rejection is
* handled by {@link installFailLoud}. Built bins need `--expose-internals` or
* the Loader's native fallback for bare plugin specifiers; relative specifiers
* do not.
* handled by {@link installFailLoud}. Built bins need the Loader's native
* helper for bare plugin specifiers; relative specifiers do not.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).

View File

@@ -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:<payload>)`, 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:<payload>)`, 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
- **Images use explicit text markers** — the terminal cannot render inline raster images, so user, assistant, tool-result, and streaming image blocks render as `[image attachment <id>]` instead of disappearing.
- **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.

View File

@@ -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<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 = 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<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
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<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

@@ -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<TuiConfig> = z.object({
const tuiConfigSchemaFields = {
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
@@ -207,11 +232,17 @@ 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,
title: titleSchema,
})
}
/** Schemastery schema for presentation settings embedded by app bundles. */
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
/** Serializable plugin configuration. */
export interface Config extends TuiConfig {
@@ -233,18 +264,21 @@ export const Config: z<Config> = 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,
@@ -1363,11 +1403,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,
) {}
@@ -1381,17 +1422,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)
@@ -1402,8 +1459,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(
@@ -1572,6 +1630,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>()
@@ -2335,15 +2398,19 @@ export function createTuiChat(
...ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
...(command.input === undefined ? {} : { argumentHint: command.input.hint }),
})),
...skillCommands,
],
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()
@@ -2427,6 +2494,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()
@@ -2674,6 +2751,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') {
@@ -2722,6 +2800,7 @@ export function createTuiChat(
const detachListeners = (): void => {
skillAbort.abort()
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
stopBannerReveal()
@@ -2769,10 +2848,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()
@@ -2789,7 +2871,10 @@ export function createTuiChat(
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
await Promise.all([
commandFiber.dispose(),
fileReferencePromptFiber.dispose(),
])
},
}
}

View File

@@ -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<string> {
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<ConstructorParameters<typeof WorkspaceFileSearch>[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')
})
})

View File

@@ -0,0 +1,16 @@
import SessionQueryService from '@deepseek-ai/dsh-session-query'
/** Test-only backend-independent query service. */
export class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}

View File

@@ -11,10 +11,10 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import CommandService from '@deepseek-ai/dsh-commands'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { createTuiChat } from '../src/index.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
@@ -57,7 +57,7 @@ describe('TUI session-reference snapshot', () => {
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const adapter = new SnapshotAdapter()

View File

@@ -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| <blank>

View File

@@ -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 = {

View File

@@ -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'
@@ -11,11 +12,11 @@ import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
FILE_REFERENCE_PROMPT,
mountTui,
renderSkillInvocation,
resolveTuiConfig,
@@ -23,6 +24,7 @@ import {
type TuiOverlaySession,
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
import {
appendAssistant,
appendUser,
@@ -30,6 +32,7 @@ import {
disposeTuiTestHarness,
type TuiHarnessOptions,
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
@@ -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,
@@ -847,31 +859,43 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
},
})
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
await vi.waitFor(() => {
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
})
await dispose(homeResult)
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
await vi.waitFor(() => {
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
})
await dispose(childResult)
const unsetResult = await setup({ cwd: null })
expect(unsetResult.terminal.output).toContain('cwd unset')
await vi.waitFor(() => {
expect(unsetResult.terminal.output).toContain('cwd unset')
})
await dispose(unsetResult)
const homeParent = resolve(home, '..')
const parentResult = await setup({ cwd: homeParent })
expect(parentResult.terminal.output).toContain(homeParent)
await vi.waitFor(() => {
expect(parentResult.terminal.output).toContain(homeParent)
})
await dispose(parentResult)
const outsideResult = await setup({ cwd: '/opt' })
expect(outsideResult.terminal.output).toContain('/opt')
await vi.waitFor(() => {
expect(outsideResult.terminal.output).toContain('/opt')
})
await dispose(outsideResult)
const logicalResult = await setup({
cwd: '/w',
formatCwd: cwd => `logical:${cwd}\x1b`,
})
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
await vi.waitFor(() => {
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
})
await dispose(logicalResult)
})
@@ -1065,7 +1089,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
sourceId = source.id
@@ -1109,13 +1133,132 @@ 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<string | undefined> => {
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'
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
appendUser(source, 'safe background')
@@ -1147,7 +1290,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1212,7 +1355,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
@@ -1332,7 +1475,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1382,7 +1525,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const lateSuccess = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
@@ -1621,6 +1764,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
})
result.terminal.send('/plugin-ch')
await tick()
expect(result.terminal.output).toContain('<value> — Run a plugin command')
result.terminal.send('\x03')
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()

View File

@@ -17,7 +17,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
## Role

View File

@@ -33,7 +33,7 @@ export interface AskUserQuestionItem {
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
/** Selected option labels. Empty for custom or unanswered choices. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string