Merge remote-tracking branch 'origin/master' into fix/tui-cwd-first-frame-race

This commit is contained in:
Chinesezjc
2026-07-24 12:47:01 +08:00
790 changed files with 36599 additions and 8898 deletions

View File

@@ -10,11 +10,11 @@ Integrations that expose the agent to an external editor or client. These are **
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
| `tui/` | Interactive pi-tui terminal channel; renders session titles/events and tool intents, answers `ctx.userInteraction`, and hosts effect-owned plugin overlays | `ctx.tui` (drives `ctx.agents`) |
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.

View File

@@ -60,7 +60,7 @@ import {
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId } from '@deepseek-ai/dsh-session'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1499,7 +1499,7 @@ export class ToolPresenter {
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns a normalized tool-owned view or raw-content fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: JsonValue): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.

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

@@ -12,6 +12,11 @@ import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
@@ -279,6 +284,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const a = args as { command: string; description: string }
@@ -336,7 +342,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] }
const presenter = new ToolPresenter(registryOf(plain))
const [update] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
@@ -352,6 +358,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'mini',
description: 'm',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
@@ -398,6 +405,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -427,6 +435,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -450,6 +459,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
// A card value outside the union — forced with a cast (no valid input reaches this).
presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
@@ -468,6 +478,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'r' }),
presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
@@ -530,6 +541,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const command = (args as { command: string }).command
@@ -691,6 +703,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
}
@@ -713,6 +726,7 @@ describe('diff-card mapping', () => {
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
})
@@ -848,6 +862,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
presentResult: () => ({ card: 'diff', diffs: [] }),

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
errorResponse,
@@ -63,7 +63,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi' }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },
@@ -204,7 +204,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'kaboom',
description: 'explodes when presented',
parameters: { x: { type: 'number' } },
@@ -227,7 +227,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'boom' }), textResponse('ok')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },

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

@@ -0,0 +1,128 @@
/**
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
* rethrows a config-file parse error would kill a live app on one bad
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
* callback nobody else catches). These tests pin the vendored
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
* keeps the last good tree, and a valid re-read re-applies overlay patches
* exactly like the initial load.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { Include } from '@cordisjs/plugin-include'
import { boot } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
interface TreeFixture {
ctx: Context
dir: string
include: Include
}
async function bootTree(configBody: string): Promise<TreeFixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'cordis.yml'), configBody)
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
return { ctx, dir, include: entry.subtree as Include }
}
function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
describe('include refresh with an invalid file', () => {
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
await expect(include.refresh()).resolves.toBeUndefined()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
// An empty file parses to `undefined` without a YAML error; it must be
// treated exactly like a parse failure, not crash the entry walk.
writeFileSync(join(dir, 'cordis.yml'), '')
await expect(include.refresh()).resolves.toBeUndefined()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('include refresh with overlay patches', () => {
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
'- id: base',
" name: 'cordis:include'",
' config:',
' path: ./base.yml',
' patches:',
' - id: noop',
' name: ./noop.mjs',
' config:',
' value: patched',
' - insert:',
' - id: extra',
' name: ./noop.mjs',
'',
].join('\n'))
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
const include = entry.subtree as Include
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
expect(entryConfig(ctx, 'extra')).toBeUndefined()
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
// Hot-update of the include entry's own config (the `internal/update`
// path): the new patches must apply now AND stick for later re-reads —
// the listener vetoes the fiber restart, so it must persist the new
// config itself or the next refresh() re-applies the old overlay.
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
await include.refresh()
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
// Removing every patch must revert to the file's own values: patching
// may not bake earlier patch results into the cached parse.
await entry.update({ config: { path: './base.yml', patches: [] } })
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -13,7 +13,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role
@@ -52,4 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
- **Answers return as JSON text** — the seam's structured `AskUserQuestionAnswer` is serialized into the tool result rather than carried as typed content blocks.
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.

View File

@@ -27,6 +27,7 @@ export function apply(ctx: Context): void {
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
additionalProperties: true,
properties: {
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
@@ -39,6 +40,7 @@ export function apply(ctx: Context): void {
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
items: {
type: 'object',
additionalProperties: true,
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
@@ -53,6 +55,28 @@ export function apply(ctx: Context): void {
},
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
answers: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
selected: { type: 'array', required: true, items: { type: 'string' } },
custom: { type: 'string' },
},
},
},
},
},
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
questions: args.questions.map(question => ({
@@ -65,7 +89,13 @@ export function apply(ctx: Context): void {
...exec.agent !== undefined ? { agent: exec.agent } : {},
signal: exec.signal,
})
return [{ type: 'text', text: JSON.stringify(result) }]
return {
answers: result.answers.map(answer => ({
id: answer.id,
selected: [...answer.selected],
...answer.custom !== undefined ? { custom: answer.custom } : {},
})),
}
},
}))
}

View File

@@ -164,6 +164,14 @@ describe('ask_user_question tool', () => {
},
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected ask_user_question success')
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
@@ -226,7 +234,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
})
})
@@ -242,7 +250,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
})
})

View File

@@ -2,19 +2,23 @@
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.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
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.
@@ -42,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. |
@@ -55,9 +62,10 @@ 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 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.
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.
## Color
@@ -79,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
@@ -127,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.

View File

@@ -0,0 +1,165 @@
/**
* Public interactive-extension contract for one mounted TUI front door.
*
* Plugins receive terminal-specific rendering primitives without access to
* the live pi-tui tree, focus controller, overlay handles, or terminal
* lifecycle. Registrations and open overlays remain owned by the calling
* Cordis fiber.
* @module @deepseek-ai/dsh-tui/extension
*/
/** Terminal component shape accepted from a trusted TUI extension. */
export interface TuiComponent {
/**
* Render this component for the supplied viewport width.
* @param width - Available terminal columns.
* @returns terminal lines owned by this component.
*/
render(width: number): string[]
/**
* Handle one terminal input sequence while this component owns focus.
* @param data - Raw terminal input sequence.
*/
handleInput?(data: string): void
/** Receive key-release events instead of having them filtered by the host. */
wantsKeyRelease?: boolean
/** Drop cached rendering derived from theme, size, or component state. */
invalidate(): void
}
/** Optional focus state forwarded by the host to a component. */
export interface TuiFocusable {
/** Whether the component currently owns terminal focus. */
focused: boolean
}
/** Read-only semantic color roles supplied by the mounted TUI. */
export interface TuiTheme {
/** Render ordinary foreground text. */
readonly text: (value: string) => string
/** Render secondary information. */
readonly muted: (value: string) => string
/** Render low-emphasis hints. */
readonly dim: (value: string) => string
/** Render the active accent role. */
readonly accent: (value: string) => string
/** Render a successful outcome. */
readonly success: (value: string) => string
/** Render a warning. */
readonly warning: (value: string) => string
/** Render an error. */
readonly error: (value: string) => string
/** Apply the host's bold role. */
readonly bold: (value: string) => string
}
/** Current terminal viewport exposed without the mutable Terminal object. */
export interface TuiViewport {
/** Terminal columns. */
readonly columns: number
/** Terminal rows. */
readonly rows: number
}
/** Supported overlay anchor points. */
export type TuiOverlayAnchor =
| 'center'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'top-center'
| 'bottom-center'
| 'left-center'
| 'right-center'
/** Terminal-edge spacing for an overlay. */
export interface TuiOverlayMargin {
/** Rows reserved above the overlay. */
readonly top?: number
/** Columns reserved to the right of the overlay. */
readonly right?: number
/** Rows reserved below the overlay. */
readonly bottom?: number
/** Columns reserved to the left of the overlay. */
readonly left?: number
}
/** Position and size constraints retained under TUI host ownership. */
export interface TuiOverlayOptions {
/** Width in columns or as a percentage of terminal width. */
readonly width?: number | `${number}%`
/** Minimum width in columns. */
readonly minWidth?: number
/** Maximum height in rows or as a percentage of terminal height. */
readonly maxHeight?: number | `${number}%`
/** Overlay anchor; defaults to the terminal center. */
readonly anchor?: TuiOverlayAnchor
/** Terminal-edge spacing. */
readonly margin?: number | TuiOverlayMargin
}
/** Capabilities available while an overlay component is queued or visible. */
export interface TuiOverlayHost {
/**
* Aborts when the request, caller fiber, overlay session, or TUI closes.
* Extension work started for the overlay must cooperate with this signal.
*/
readonly signal: AbortSignal
/** Current viewport; a fresh immutable value is returned on every read. */
readonly viewport: TuiViewport
/** Semantic styles that follow terminal color-scheme changes. */
readonly theme: TuiTheme
/**
* Escape control characters in untrusted display text.
* @param value - text crossing into terminal presentation.
* @returns a printable representation that cannot emit terminal controls.
*/
display(value: string): string
/** Invalidate the component and schedule one contained terminal redraw. */
invalidate(): void
/** Close this overlay normally; repeated calls are no-ops. */
close(): void
}
/** One effect-owned request to create an interactive overlay. */
export interface TuiOverlayRequest {
/**
* Construct the component when this request reaches the front of the modal
* queue. A throw closes the session with `reason: "error"`.
*/
readonly create: (host: TuiOverlayHost) => TuiComponent & Partial<TuiFocusable>
/** Host-owned position and size constraints. */
readonly options?: TuiOverlayOptions
/** Optional request cancellation in addition to caller and TUI ownership. */
readonly signal?: AbortSignal
}
/** Stable reason an overlay stopped being queued or visible. */
export type TuiOverlayCloseReason =
| 'closed'
| 'aborted'
| 'owner-disposed'
| 'tui-disposed'
| 'error'
/** Settled overlay outcome; component failures retain their original value. */
export type TuiOverlayOutcome =
| { readonly reason: Exclude<TuiOverlayCloseReason, 'error'> }
| { readonly reason: 'error'; readonly error: unknown }
/** Live state of an overlay operation. */
export type TuiOverlayState = 'queued' | 'active' | 'closed'
/** Handle returned to the extension that opened an overlay. */
export interface TuiOverlaySession {
/** Current queue/display state. */
readonly state: TuiOverlayState
/** Settles exactly once after the overlay leaves the queue or display. */
readonly closed: Promise<TuiOverlayOutcome>
/**
* Close the overlay normally and await its settled outcome.
* @returns the same immutable value exposed through {@link closed}.
*/
close(): Promise<TuiOverlayOutcome>
}

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

@@ -31,13 +31,12 @@ import {
type EditorTheme,
type Focusable,
type MarkdownTheme,
type OverlayHandle,
type SelectListTheme,
type SlashCommand,
type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import { Service, type Context, type Fiber } from 'cordis'
import z from 'schemastery'
import {
installAgentLlmTarget,
@@ -61,6 +60,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
import {
displayPromptContent,
SessionId,
type JsonValue,
type Session,
type SessionEvent,
type SessionHeader,
@@ -90,11 +90,84 @@ import {
type AskUserQuestionItem,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
} from './overlay-manager.ts'
import type {
TuiOverlayRequest,
TuiOverlaySession,
TuiTheme,
} from './extension.ts'
export type {
TuiComponent,
TuiFocusable,
TuiOverlayAnchor,
TuiOverlayCloseReason,
TuiOverlayHost,
TuiOverlayMargin,
TuiOverlayOptions,
TuiOverlayOutcome,
TuiOverlayRequest,
TuiOverlaySession,
TuiOverlayState,
TuiTheme,
TuiViewport,
} from './extension.ts'
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
tui: TuiExtensionService
}
}
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
* The concrete provider retains pi-tui, focus, and terminal lifecycle state.
* Plugins receive only effect-owned overlay sessions.
*/
export abstract class TuiExtensionService extends Service {
/** Exact agent driven by this terminal instance. */
abstract readonly agent: Agent
/**
* Queue an interactive overlay owned by the calling plugin fiber.
*
* The TUI displays one overlay at a time in FIFO order. Disposing the caller
* removes a queued overlay or closes an active one before plugin teardown
* settles. This live presentation is neither logged nor replayed.
*
* @param request - component factory, layout constraints, and cancellation.
* @returns the effect-owned overlay session.
* @throws when the TUI has begun shutting down.
*/
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
@@ -112,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. */
@@ -135,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,
@@ -151,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 {
@@ -177,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. */
@@ -201,6 +291,9 @@ export interface ResolvedTuiConfig {
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
showHardwareCursor: boolean
color: boolean
truecolor: boolean
@@ -239,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,
@@ -745,7 +841,7 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
}
class ToolCardComponent implements Component {
private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
private expanded = false
private callView: ToolCallView
private resultView: ToolResultView | undefined
@@ -1290,14 +1386,15 @@ interface PendingQuestion {
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
overlay: OverlayHandle | undefined
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,
) {}
@@ -1311,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)
@@ -1332,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(
@@ -1502,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<string, ToolCardComponent>()
@@ -1511,7 +1630,8 @@ export function createTuiChat(
const commandControllers = new Set<AbortController>()
const referenceControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: OverlayHandle | undefined
let modelOverlay: TuiOverlaySession | undefined
let tuiServiceFiber: Fiber | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
let contextResolution: Promise<
@@ -1569,6 +1689,41 @@ export function createTuiChat(
requestRender()
}
const extensionTheme: TuiTheme = Object.freeze({
text: (value: string) => palette.text(value),
muted: (value: string) => palette.muted(value),
dim: (value: string) => palette.dim(value),
accent: (value: string) => palette.accent(value),
success: (value: string) => palette.success(value),
warning: (value: string) => palette.warning(value),
error: (value: string) => palette.error(value),
bold: (value: string) => palette.bold(value),
})
const overlayManager = new TuiOverlayManager({
viewport: () => Object.freeze({
columns: runtime.terminal.columns,
rows: runtime.terminal.rows,
}),
theme: () => extensionTheme,
display: displayText,
show: (component, options) => ui.showOverlay(component, options === undefined
? undefined
: {
...options,
...typeof options.margin === 'object'
? { margin: { ...options.margin } }
: {},
}),
invalidate: requestRender,
reportError: (error) => {
const message = errorChain(error)
ctx.logger.warn(`ui-tui: overlay failed: ${message}`)
/* v8 ignore next -- shutdown removes overlays before the terminal stops */
if (disposed) return
appendNotice(`TUI overlay failed: ${message}`, 'error')
},
})
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
@@ -1608,29 +1763,29 @@ export function createTuiChat(
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
return
}
modelOverlay?.hide()
modelOverlay = undefined
const close = (): void => {
modelOverlay?.hide()
modelOverlay = undefined
requestRender()
}
const dialog = new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
close()
selectModel(selected)
void modelOverlay?.close()
const session = overlayManager.open({
create: () => new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
void session.close()
selectModel(selected)
},
() => { void session.close() },
),
options: {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
},
close,
)
modelOverlay = ui.showOverlay(dialog, {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
})
modelOverlay = session
void session.closed.then(() => {
if (modelOverlay === session) modelOverlay = undefined
})
requestRender()
}
@@ -1933,7 +2088,7 @@ export function createTuiChat(
}
const rejectQuestion = (pending: PendingQuestion): void => {
pending.overlay?.hide()
void pending.overlay?.close()
pending.overlay = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
@@ -1956,31 +2111,48 @@ export function createTuiChat(
startNextQuestion()
return
}
const dialog = new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay?.hide()
pending.overlay = undefined
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
const session = overlayManager.open({
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
create: () => new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay = undefined
void session.close()
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
),
options: {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
)
pending.overlay = ui.showOverlay(dialog, {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
})
pending.overlay = session
void session.closed.then((result) => {
if (pending.overlay !== session) return
pending.overlay = undefined
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
if (result.reason !== 'error') return
activeQuestion = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
`ask_user_question TUI failed: ${errorChain(result.error)}`,
'ASK_ABORTED',
))
startNextQuestion()
})
requestRender()
}
@@ -2051,20 +2223,23 @@ export function createTuiChat(
const shutdown = (exitProcess: boolean): Promise<void> => {
shuttingDown ??= (async () => {
disposed = true
overlayManager.beginShutdown()
contextResolution = undefined
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
referenceControllers.clear()
await tuiServiceFiber?.dispose()
tuiServiceFiber = undefined
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
rejectQuestion(pending)
}
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
await overlayManager.dispose()
modelOverlay = undefined
disposeUserInteraction()
await runtime.terminal.drainInput(100, 20)
ui.stop()
@@ -2209,15 +2384,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()
@@ -2301,6 +2480,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()
@@ -2510,7 +2699,7 @@ export function createTuiChat(
}
const removeInputListener = ui.addInputListener((data) => {
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
if (overlayManager.hasActiveOverlay()) return undefined
if (matchesKey(data, Key.ctrl('o'))) {
toggleTools()
return { consume: true }
@@ -2548,6 +2737,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') {
@@ -2596,6 +2786,7 @@ export function createTuiChat(
const detachListeners = (): void => {
skillAbort.abort()
fileSearch.dispose()
removeInputListener()
disposeCommandChanges()
stopBannerReveal()
@@ -2643,10 +2834,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()
@@ -2654,13 +2848,19 @@ export function createTuiChat(
ui.stop()
throw error
}
tuiServiceFiber = ctx.inject([], (serviceCtx) => {
new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
})
startBannerReveal()
return {
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
await Promise.all([
commandFiber.dispose(),
fileReferencePromptFiber.dispose(),
])
},
}
}

View File

@@ -0,0 +1,369 @@
/**
* Private bridge between the public TUI extension contract and pi-tui.
*
* The manager serializes modal ownership, guards extension callbacks, and
* settles every queued or active operation before terminal teardown.
* @module @deepseek-ai/dsh-tui/overlay-manager
*/
import { Service, type Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TuiExtensionService } from './index.ts'
import type {
Component,
Focusable,
OverlayHandle,
} from '@earendil-works/pi-tui'
import type {
TuiComponent,
TuiFocusable,
TuiOverlayCloseReason,
TuiOverlayHost,
TuiOverlayOutcome,
TuiOverlayOptions,
TuiOverlayRequest,
TuiOverlaySession,
TuiOverlayState,
TuiTheme,
TuiViewport,
} from './extension.ts'
/** pi-tui operations retained by the front door instead of exposed to plugins. */
export interface TuiOverlayDriver {
/** Current terminal viewport. */
viewport(): TuiViewport
/** Current semantic theme facade. */
theme(): TuiTheme
/** Escape text at the terminal display boundary. */
display(value: string): string
/** Mount one guarded component and return its private pi-tui handle. */
show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle
/** Invalidate the mounted UI and request a render. */
invalidate(): void
/** Report a contained extension failure. */
reportError(error: unknown): void
}
interface OverlayEntry {
readonly request: TuiOverlayRequest
readonly controller: AbortController
readonly signal: AbortSignal
readonly closed: Promise<TuiOverlayOutcome>
readonly resolveClosed: (outcome: TuiOverlayOutcome) => void
readonly session: TuiOverlaySession
state: TuiOverlayState
component?: GuardedOverlayComponent
handle?: OverlayHandle
removeRequestAbort?: () => void
outcome?: TuiOverlayOutcome
failing?: boolean
}
/** Turn a close reason into its immutable public outcome. */
function outcome(reason: Exclude<TuiOverlayCloseReason, 'error'>): TuiOverlayOutcome {
return Object.freeze({ reason })
}
/** Retain only supported layout fields before a queued request returns to its caller. */
function retainOptions(options: TuiOverlayOptions): TuiOverlayOptions {
return Object.freeze({
...options.width === undefined ? {} : { width: options.width },
...options.minWidth === undefined ? {} : { minWidth: options.minWidth },
...options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight },
...options.anchor === undefined ? {} : { anchor: options.anchor },
...options.margin === undefined
? {}
: {
margin: typeof options.margin === 'object'
? Object.freeze({ ...options.margin })
: options.margin,
},
})
}
/** Guard plugin component methods while preserving focus and key-release state. */
class GuardedOverlayComponent implements Component, Focusable {
constructor(
private readonly component: TuiComponent & Partial<TuiFocusable>,
private readonly fail: (error: unknown) => void,
) {}
get focused(): boolean {
try {
return this.component.focused ?? false
} catch (error) {
this.fail(error)
return false
}
}
set focused(value: boolean) {
try {
if ('focused' in this.component) this.component.focused = value
} catch (error) {
this.fail(error)
}
}
get wantsKeyRelease(): boolean {
try {
return this.component.wantsKeyRelease ?? false
} catch (error) {
this.fail(error)
return false
}
}
render(width: number): string[] {
try {
return this.component.render(width)
} catch (error) {
this.fail(error)
return []
}
}
handleInput(data: string): void {
try {
this.component.handleInput?.(data)
} catch (error) {
this.fail(error)
}
}
invalidate(): boolean {
try {
this.component.invalidate()
return true
} catch (error) {
this.fail(error)
return false
}
}
}
/** FIFO modal owner for one mounted TUI. */
export class TuiOverlayManager {
private readonly queue: OverlayEntry[] = []
private active: OverlayEntry | undefined
private accepting = true
private disposeTask: Promise<void> | undefined
constructor(private readonly driver: TuiOverlayDriver) {}
/**
* Whether one extension or built-in overlay currently owns terminal focus.
* @returns `true` while an overlay is active.
*/
hasActiveOverlay(): boolean {
return this.active !== undefined
}
/** Reject new work while the TUI unloads dependent extension fibers. */
beginShutdown(): void {
this.accepting = false
}
/**
* Queue one overlay without assigning Cordis ownership.
* @param request - component factory, constraints, and request signal.
* @returns an internal session that can close with an ownership reason.
*/
open(request: TuiOverlayRequest): TuiOverlaySession & {
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
} {
if (!this.accepting) throw new Error('TUI is shutting down')
const requestSignal = request.signal
const retainedRequest: TuiOverlayRequest = Object.freeze({
create: request.create,
...request.options === undefined ? {} : { options: retainOptions(request.options) },
...requestSignal === undefined ? {} : { signal: requestSignal },
})
const controller = new AbortController()
const signal = requestSignal === undefined
? controller.signal
: AbortSignal.any([requestSignal, controller.signal])
const deferred = Promise.withResolvers<TuiOverlayOutcome>()
const session: TuiOverlaySession & {
closeWith(reason: Exclude<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
} = {
get state(): TuiOverlayState {
return entry.state
},
closed: deferred.promise,
close: () => this.close(entry, outcome('closed')),
closeWith: (reason: Exclude<TuiOverlayCloseReason, 'error'>) =>
this.close(entry, outcome(reason)),
}
const entry: OverlayEntry = {
request: retainedRequest,
controller,
signal,
closed: deferred.promise,
resolveClosed: deferred.resolve,
session,
state: 'queued',
}
if (requestSignal?.aborted === true) {
void this.close(entry, outcome('aborted'))
return session
}
if (requestSignal !== undefined) {
const onAbort = (): void => { void this.close(entry, outcome('aborted')) }
requestSignal.addEventListener('abort', onAbort, { once: true })
entry.removeRequestAbort = () => { requestSignal.removeEventListener('abort', onAbort) }
}
this.queue.push(entry)
this.activateNext()
return session
}
/** Stop accepting work and settle every active or queued overlay. */
dispose(): Promise<void> {
if (this.disposeTask !== undefined) return this.disposeTask
this.beginShutdown()
const entries = [
...this.active === undefined ? [] : [this.active],
...this.queue,
]
return this.disposeTask = Promise.all(
entries.map(entry => this.close(entry, outcome('tui-disposed'))),
).then(() => {})
}
private activateNext(): void {
if (!this.accepting || this.active !== undefined) return
const entry = this.queue.shift()
if (entry === undefined) return
this.active = entry
entry.state = 'active'
const host = this.host(entry)
let component: TuiComponent & Partial<TuiFocusable>
try {
component = entry.request.create(host)
} catch (error) {
this.fail(entry, error)
return
}
if (this.active !== entry) return
const guarded = new GuardedOverlayComponent(component, (error) => {
this.fail(entry, error)
})
entry.component = guarded
try {
const handle = this.driver.show(guarded, entry.request.options)
if (this.active !== entry) {
this.hide(handle)
return
}
entry.handle = handle
this.driver.invalidate()
} catch (error) {
this.fail(entry, error)
}
}
private host(entry: OverlayEntry): TuiOverlayHost {
const driver = this.driver
return Object.freeze({
get signal(): AbortSignal {
return entry.signal
},
get viewport(): TuiViewport {
return Object.freeze({ ...driver.viewport() })
},
get theme(): TuiTheme {
return driver.theme()
},
display: (value: string) => this.driver.display(value),
invalidate: () => {
if (this.active !== entry || entry.component === undefined || entry.failing === true) return
if (!entry.component.invalidate() || this.active !== entry) return
try {
this.driver.invalidate()
} catch (error) {
this.fail(entry, error)
}
},
close: () => { void this.close(entry, outcome('closed')) },
})
}
private fail(entry: OverlayEntry, error: unknown): void {
if (entry.state === 'closed' || entry.failing === true) return
entry.failing = true
this.report(error)
queueMicrotask(() => {
void this.close(entry, Object.freeze({ reason: 'error', error }))
})
}
private report(error: unknown): void {
try {
this.driver.reportError(error)
} catch {
// Error reporting is a containment boundary, never a second failure path.
}
}
private hide(handle: OverlayHandle): void {
try {
handle.hide()
} catch (error) {
this.report(error)
}
}
private close(entry: OverlayEntry, result: TuiOverlayOutcome): Promise<TuiOverlayOutcome> {
if (entry.outcome !== undefined) return entry.closed
entry.outcome = result
entry.state = 'closed'
entry.removeRequestAbort?.()
delete entry.removeRequestAbort
if (!entry.controller.signal.aborted) entry.controller.abort(result)
const queuedIndex = this.queue.indexOf(entry)
if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1)
if (this.active === entry) {
this.active = undefined
if (entry.handle !== undefined) this.hide(entry.handle)
delete entry.handle
}
delete entry.component
entry.resolveClosed(result)
try {
this.driver.invalidate()
} catch (error) {
this.report(error)
}
queueMicrotask(() => { this.activateNext() })
return entry.closed
}
}
/** Cordis service whose method effects bind to the calling plugin fiber. */
export class TuiExtensionServiceImpl extends Service implements TuiExtensionService {
constructor(
ctx: Context,
readonly agent: Agent,
private readonly overlays: TuiOverlayManager,
) {
super(ctx, 'tui')
}
/** @inheritdoc */
openOverlay(request: TuiOverlayRequest): TuiOverlaySession {
let operation: ReturnType<TuiOverlayManager['open']> | undefined
const disposeOwner = this.ctx.effect(
() => () => operation?.closeWith('owner-disposed'),
'tui.openOverlay()',
)
try {
operation = this.overlays.open(request)
} catch (error) {
void disposeOwner()
throw error
}
void operation.closed.then(() => { void disposeOwner() })
return operation
}
}

View File

@@ -0,0 +1,587 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
Component,
OverlayHandle,
} from '@earendil-works/pi-tui'
import type {
TuiComponent,
TuiOverlayHost,
TuiOverlayOptions,
TuiOverlaySession,
TuiTheme,
} from '../src/extension.ts'
import {
TuiExtensionServiceImpl,
TuiOverlayManager,
type TuiOverlayDriver,
} from '../src/overlay-manager.ts'
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,
success: (value: string) => `success:${value}`,
warning: (value: string) => `warning:${value}`,
error: (value: string) => `error:${value}`,
bold: (value: string) => `bold:${value}`,
})
interface ShownOverlay {
component: Component
options: TuiOverlayOptions | undefined
hidden: boolean
focused: boolean
}
interface DriverFixture {
driver: TuiOverlayDriver
shown: ShownOverlay[]
errors: unknown[]
invalidations: number
showError?: unknown
onShow?: (component: Component) => void
}
function driverFixture(): DriverFixture {
const fixture: DriverFixture = {
shown: [],
errors: [],
invalidations: 0,
driver: undefined as never,
}
fixture.driver = {
viewport: () => ({ columns: 96, rows: 32 }),
theme: () => theme,
display: value => `safe:${value}`,
show(component, options) {
if (fixture.showError !== undefined) throw fixture.showError
const shown: ShownOverlay = {
component,
options,
hidden: false,
focused: true,
}
fixture.shown.push(shown)
const handle: OverlayHandle = {
hide() {
shown.hidden = true
shown.focused = false
},
setHidden(hidden) {
shown.hidden = hidden
},
isHidden: () => shown.hidden,
focus() {
shown.focused = true
},
unfocus() {
shown.focused = false
},
isFocused: () => shown.focused,
}
fixture.onShow?.(component)
return handle
},
invalidate() {
fixture.invalidations += 1
},
reportError(error) {
fixture.errors.push(error)
},
}
return fixture
}
function component(lines = ['overlay']): TuiComponent {
return {
render: () => lines,
invalidate() {},
}
}
async function microtask(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('TuiOverlayManager', () => {
it('serializes overlays, exposes the constrained host, and settles normal close once', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let firstHost: TuiOverlayHost | undefined
const firstComponent = {
focused: false,
wantsKeyRelease: true,
inputs: [] as string[],
invalidated: 0,
render: (width: number) => [`first:${String(width)}`],
handleInput(data: string) {
this.inputs.push(data)
},
invalidate() {
this.invalidated += 1
},
}
const first = manager.open({
create(host) {
firstHost = host
return firstComponent
},
options: { width: '75%', minWidth: 24, maxHeight: 20, anchor: 'center', margin: { bottom: 1 } },
})
const secondOptions: TuiOverlayOptions = { width: 40, margin: { bottom: 2 } }
const second = manager.open({
create: () => component(['second']),
options: secondOptions,
})
;(secondOptions as { width: number }).width = 80
;(secondOptions.margin as { bottom: number }).bottom = 4
expect(manager.hasActiveOverlay()).toBe(true)
expect(first.state).toBe('active')
expect(second.state).toBe('queued')
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.options).toEqual({
width: '75%',
minWidth: 24,
maxHeight: 20,
anchor: 'center',
margin: { bottom: 1 },
})
expect(firstHost?.viewport).toEqual({ columns: 96, rows: 32 })
expect(Object.isFrozen(firstHost?.viewport)).toBe(true)
expect(firstHost?.theme.accent('x')).toBe('accent:x')
expect(firstHost?.display('\u001b')).toBe('safe:\u001b')
firstHost?.invalidate()
expect(firstComponent.invalidated).toBe(1)
expect(fixture.shown[0]?.component.render(40)).toEqual(['first:40'])
fixture.shown[0]!.component.handleInput?.('x')
fixture.shown[0]!.component.invalidate()
expect(firstComponent.inputs).toEqual(['x'])
expect(firstComponent.invalidated).toBe(2)
expect(fixture.shown[0]?.component.wantsKeyRelease).toBe(true)
;(fixture.shown[0]?.component as Component & { focused: boolean }).focused = true
expect(firstComponent.focused).toBe(true)
expect((fixture.shown[0]?.component as Component & { focused: boolean }).focused).toBe(true)
const firstOutcome = await first.close()
expect(firstOutcome).toEqual({ reason: 'closed' })
expect(await first.close()).toBe(firstOutcome)
expect(firstHost?.signal.aborted).toBe(true)
const beforeClosedInvalidation = fixture.invalidations
firstHost?.invalidate()
expect(fixture.invalidations).toBe(beforeClosedInvalidation)
await microtask()
expect(first.state).toBe('closed')
expect(second.state).toBe('active')
expect(fixture.shown[0]?.hidden).toBe(true)
expect(fixture.shown[1]?.options).toEqual({ width: 40, margin: { bottom: 2 } })
expect(Object.isFrozen(fixture.shown[1]?.options)).toBe(true)
expect(Object.isFrozen(fixture.shown[1]?.options?.margin)).toBe(true)
expect(fixture.shown[1]?.component.wantsKeyRelease).toBe(false)
expect((fixture.shown[1]?.component as Component & { focused: boolean }).focused).toBe(false)
;(fixture.shown[1]?.component as Component & { focused: boolean }).focused = true
fixture.shown[1]!.component.handleInput?.('ignored')
await second.close()
await microtask()
const numericMargin = manager.open({
create: () => component(['numeric margin']),
options: { margin: 1 },
})
expect(fixture.shown[2]?.options).toEqual({ margin: 1 })
await numericMargin.close()
await microtask()
const emptyOptions = manager.open({
create: () => component(['empty options']),
options: {},
})
expect(fixture.shown[3]?.options).toEqual({})
await emptyOptions.close()
await microtask()
expect(manager.hasActiveOverlay()).toBe(false)
await manager.dispose()
await manager.dispose()
})
it('removes pre-aborted, active, and queued requests without activating cancelled work', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const preAborted = new AbortController()
preAborted.abort()
const pre = manager.open({
signal: preAborted.signal,
create: () => component(['never']),
})
expect(await pre.closed).toEqual({ reason: 'aborted' })
expect(fixture.shown).toHaveLength(0)
const activeAbort = new AbortController()
let activeHost: TuiOverlayHost | undefined
const active = manager.open({
signal: activeAbort.signal,
create(host) {
activeHost = host
return component(['active'])
},
})
const queuedAbort = new AbortController()
const queued = manager.open({
signal: queuedAbort.signal,
create: () => component(['queued']),
})
queuedAbort.abort()
expect(await queued.closed).toEqual({ reason: 'aborted' })
expect(queued.state).toBe('closed')
activeAbort.abort()
expect(await active.closed).toEqual({ reason: 'aborted' })
expect(activeHost?.signal.aborted).toBe(true)
await microtask()
expect(fixture.shown).toHaveLength(1)
expect(manager.hasActiveOverlay()).toBe(false)
})
it('does not mount entries closed or aborted during component construction', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const closed = manager.open({
create(host) {
host.invalidate()
host.close()
return component(['closed during construction'])
},
})
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
const controller = new AbortController()
const aborted = manager.open({
signal: controller.signal,
create() {
controller.abort()
return component(['aborted during construction'])
},
})
await expect(aborted.closed).resolves.toEqual({ reason: 'aborted' })
const after = manager.open({ create: () => component(['after construction closes']) })
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.component.render(40)).toEqual(['after construction closes'])
await after.close()
})
it('hides a handle returned after reentrant closure during mounting', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
fixture.onShow = (shown) => {
;(shown as Component & { focused: boolean }).focused = true
}
const closed = manager.open({
create(host) {
return {
get focused(): boolean {
return false
},
set focused(_value: boolean) {
host.close()
},
render: () => ['closed during mount'],
invalidate() {},
}
},
})
await expect(closed.closed).resolves.toEqual({ reason: 'closed' })
expect(fixture.shown[0]?.hidden).toBe(true)
expect(manager.hasActiveOverlay()).toBe(false)
delete fixture.onShow
const after = manager.open({ create: () => component(['after mount close']) })
expect(fixture.shown[1]?.hidden).toBe(false)
expect(fixture.shown[1]?.component.render(40)).toEqual(['after mount close'])
await after.close()
})
it('stops admission and disposes active and queued overlays with the TUI', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const active = manager.open({ create: () => component(['active']) })
const queued = manager.open({ create: () => component(['queued']) })
manager.beginShutdown()
expect(() => manager.open({ create: () => component() })).toThrow('TUI is shutting down')
await manager.dispose()
expect(await active.closed).toEqual({ reason: 'tui-disposed' })
expect(await queued.closed).toEqual({ reason: 'tui-disposed' })
expect(fixture.shown).toHaveLength(1)
expect(fixture.shown[0]?.hidden).toBe(true)
await manager.dispose()
})
it('contains factory, mount, render, input, and invalidation failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const factoryError = new Error('factory failed')
const factory = manager.open({
create() {
throw factoryError
},
})
const afterFactory = manager.open({ create: () => component(['after factory']) })
expect(await factory.closed).toEqual({ reason: 'error', error: factoryError })
await microtask()
expect(afterFactory.state).toBe('active')
await afterFactory.close()
await microtask()
const showError = new Error('show failed')
fixture.showError = showError
const show = manager.open({ create: () => component(['show']) })
expect(await show.closed).toEqual({ reason: 'error', error: showError })
delete fixture.showError
await microtask()
const renderError = new Error('render failed')
const rendering = manager.open({
create: () => ({
render() {
throw renderError
},
invalidate() {
throw new Error('must be suppressed after the first failure')
},
}),
})
const renderComponent = fixture.shown.at(-1)!.component
expect(renderComponent.render(20)).toEqual([])
renderComponent.invalidate()
expect(fixture.errors.filter(error => error === renderError)).toHaveLength(1)
expect(await rendering.closed).toEqual({ reason: 'error', error: renderError })
await microtask()
const inputError = new Error('input failed')
const input = manager.open({
create: () => ({
render: () => ['input'],
handleInput() {
throw inputError
},
invalidate() {},
}),
})
fixture.shown.at(-1)!.component.handleInput?.('x')
expect(await input.closed).toEqual({ reason: 'error', error: inputError })
await microtask()
const invalidateError = new Error('invalidate failed')
let invalidatingHost: TuiOverlayHost | undefined
const invalidating = manager.open({
create(host) {
invalidatingHost = host
return {
render: () => ['invalidate'],
invalidate() {
throw invalidateError
},
}
},
})
const invalidationsBeforeFailure = fixture.invalidations
invalidatingHost?.invalidate()
invalidatingHost?.invalidate()
expect(fixture.invalidations).toBe(invalidationsBeforeFailure)
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidateError })
await microtask()
const focusError = new Error('focus failed')
const focus = manager.open({
create: () => ({
get focused(): boolean {
throw focusError
},
set focused(_value: boolean) {
throw new Error('focus assignment failed')
},
get wantsKeyRelease(): boolean {
throw new Error('key-release query failed')
},
render: () => ['focus'],
invalidate() {},
}),
})
const guarded = fixture.shown.at(-1)!.component as Component & { focused: boolean }
expect(guarded.focused).toBe(false)
guarded.focused = true
expect(guarded.wantsKeyRelease).toBe(false)
expect(await focus.closed).toEqual({ reason: 'error', error: focusError })
expect(fixture.errors).toEqual([
factoryError,
showError,
renderError,
inputError,
invalidateError,
focusError,
])
})
it('contains host redraw, overlay removal, and error-reporter failures', async () => {
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
let host: TuiOverlayHost | undefined
const invalidationError = new Error('redraw failed')
let redrawFails = false
fixture.driver.invalidate = () => {
if (redrawFails) throw invalidationError
}
fixture.driver.reportError = () => { throw new Error('report failed') }
const invalidating = manager.open({
create(value) {
host = value
return component()
},
})
redrawFails = true
host?.invalidate()
expect(await invalidating.closed).toEqual({ reason: 'error', error: invalidationError })
await microtask()
redrawFails = false
fixture.driver.invalidate = () => {}
const hideError = new Error('hide failed')
fixture.driver.show = () => ({
hide() { throw hideError },
setHidden() {},
isHidden: () => false,
focus() {},
unfocus() {},
isFocused: () => true,
})
const hiding = manager.open({
create(value) {
host = value
return component()
},
})
host?.close()
expect(await hiding.closed).toEqual({ reason: 'closed' })
})
})
describe('TuiExtensionService', () => {
it('binds an open overlay to the calling plugin fiber', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const agent = {} as Agent
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, manager)
})
await provider
let session: TuiOverlaySession | undefined
let host: TuiOverlayHost | undefined
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(consumerCtx.tui.agent).toBe(agent)
session = consumerCtx.tui.openOverlay({
create(value) {
host = value
return component(['plugin'])
},
})
})
await consumer
expect(session?.state).toBe('active')
await consumer.dispose()
expect(await session?.closed).toEqual({ reason: 'owner-disposed' })
expect(host?.signal.aborted).toBe(true)
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('unloads and reloads dependent plugins with the mounted TUI service', async () => {
const ctx = new Context()
const agent = {} as Agent
const sessions: TuiOverlaySession[] = []
let starts = 0
const consumer = ctx.inject(['tui'], (consumerCtx) => {
starts += 1
sessions.push(consumerCtx.tui.openOverlay({ create: () => component([`start:${String(starts)}`]) }))
})
const firstFixture = driverFixture()
const firstManager = new TuiOverlayManager(firstFixture.driver)
const firstProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, firstManager)
})
await firstProvider
await consumer
expect(starts).toBe(1)
await firstProvider.dispose()
expect(await sessions[0]?.closed).toEqual({ reason: 'owner-disposed' })
const secondFixture = driverFixture()
const secondManager = new TuiOverlayManager(secondFixture.driver)
const secondProvider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, agent, secondManager)
})
await secondProvider
await vi.waitFor(() => { expect(starts).toBe(2) })
await sessions[1]?.close()
await consumer.dispose()
await secondProvider.dispose()
await firstManager.dispose()
await secondManager.dispose()
await ctx.fiber.dispose()
})
it('rejects new service work after terminal shutdown begins', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
manager.beginShutdown()
const consumer = ctx.inject(['tui'], (consumerCtx) => {
expect(() => consumerCtx.tui.openOverlay({ create: () => component() }))
.toThrow('TUI is shutting down')
})
await consumer
await consumer.dispose()
await provider.dispose()
await manager.dispose()
await ctx.fiber.dispose()
})
it('does not admit an overlay when called from an unloading plugin', async () => {
const ctx = new Context()
const fixture = driverFixture()
const manager = new TuiOverlayManager(fixture.driver)
const provider = ctx.plugin((providerCtx) => {
new TuiExtensionServiceImpl(providerCtx, {} as Agent, manager)
})
await provider
let error: unknown
const consumer = ctx.inject(['tui'], (consumerCtx) => {
consumerCtx.effect(() => () => {
try {
consumerCtx.tui.openOverlay({ create: () => component() })
} catch (value) {
error = value
}
})
})
await consumer
await consumer.dispose()
expect(error).toMatchObject({ code: 'INACTIVE_EFFECT' })
expect(fixture.shown).toHaveLength(0)
await provider.dispose()
await manager.dispose()
await ctx.fiber.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'
@@ -6,8 +7,7 @@ import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -32,6 +32,7 @@ const CHECKPOINTS = [
'retry-cancelled',
'retry-exhausted',
'banner-gradient',
'file-autocomplete',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -157,7 +158,7 @@ function appendToolResult(
session: Session,
id: string,
content: ContentBlock[],
options: { isError?: boolean; meta?: unknown } = {},
options: { isError?: boolean; meta?: JsonValue } = {},
): void {
session.append('tool/result', {
turn: 1,
@@ -178,6 +179,7 @@ function visualTool(
name,
description: `${name} snapshot fixture`,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: () => Promise.resolve([]),
presentCall: call,
...result === undefined ? {} : { presentResult: result },
@@ -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,16 +12,19 @@ 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,
type TuiOverlayHost,
type TuiOverlaySession,
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
import {
appendAssistant,
appendUser,
@@ -28,6 +32,12 @@ import {
disposeTuiTestHarness,
type TuiHarnessOptions,
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
class FakeTerminal implements Terminal {
columns = 88
@@ -146,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,
@@ -160,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,
@@ -173,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,
@@ -1024,7 +1043,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
@@ -1068,13 +1087,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')
@@ -1106,7 +1244,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)
},
})
@@ -1171,7 +1309,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)
},
})
@@ -1291,7 +1429,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'))
},
@@ -1341,7 +1479,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'))
},
@@ -1391,6 +1529,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
result.terminal.send('/model')
result.terminal.send('\r')
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select model')
result.terminal.send('\x1b')
await tick()
result.agent.status = 'running'
result.terminal.send('/model')
result.terminal.send('\r')
@@ -1571,6 +1718,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()
@@ -1858,17 +2010,17 @@ describe('renderSkillInvocation', () => {
describe('tool cards and surface replay', () => {
const tools: Record<string, ToolDefinition> = {
bash: {
name: 'bash', description: '', parameters: {}, execute: async () => [],
name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
},
signal: {
name: 'signal', description: '', parameters: {}, execute: async () => [],
name: 'signal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
},
edit: {
name: 'edit', description: '', parameters: {}, execute: async () => [],
name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({
card: 'diff',
title: 'Edit files',
@@ -1880,35 +2032,35 @@ describe('tool cards and surface replay', () => {
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
},
generic: {
name: 'generic', description: '', parameters: {}, execute: async () => [],
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
},
throwing: {
name: 'throwing', description: '', parameters: {}, execute: async () => [],
name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => { throw new Error('call presenter boom') },
presentResult: () => { throw new Error('result presenter boom') },
},
rawTerminal: {
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
},
undefinedViews: {
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => undefined,
presentResult: () => undefined,
},
empty: {
name: 'empty', description: '', parameters: {}, execute: async () => [],
name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
},
terminalResult: {
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
},
}
@@ -2205,6 +2357,141 @@ describe('TUI user-interaction dialogs', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
await result.ctx.fiber.dispose()
})
it('rejects malformed questions when a dialog cannot be constructed', async () => {
const result = await setup()
const broken = {
id: 'broken',
question: 'Broken question',
get options(): never {
throw new Error('question setup failed')
},
}
const answer = result.ctx.userInteraction.ask({ questions: [broken] })
await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
await tick()
expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
await dispose(result)
})
})
describe('TUI extension service', () => {
it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const hosts: TuiOverlayHost[] = []
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
expect(pluginCtx.tui.agent).toBe(result.agent)
for (const label of ['first', 'second']) {
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
hosts.push(host)
return {
focused: false,
render: width => [
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.muted('muted'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
host.theme.error('error'),
host.theme.bold('bold'),
].join(' '),
`${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
],
handleInput(data) {
host.invalidate()
if (data === label[0]) host.close()
},
invalidate() {},
}
},
options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
}))
}
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('first plugin overlay')
})
expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
expect(hosts).toHaveLength(1)
const question = result.ctx.userInteraction.ask({
questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
})
result.terminal.send('f')
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('second plugin overlay')
})
expect(hosts).toHaveLength(2)
expect(sessions[1]?.state).toBe('active')
result.terminal.send('s')
await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Question after plugins?')
})
result.terminal.send('\r')
await expect(question).resolves.toEqual({
answers: [{ id: 'after-plugin', selected: ['Yes'] }],
})
result.terminal.send('editor works again')
result.terminal.send('\r')
expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
await plugin.dispose()
await dispose(result)
})
it('unloads and reloads dependent plugins with the mounted TUI', async () => {
const result = await setup()
const sessions: TuiOverlaySession[] = []
const signals: AbortSignal[] = []
let starts = 0
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
starts += 1
sessions.push(pluginCtx.tui.openOverlay({
create(host) {
signals.push(host.signal)
return {
render: () => [`plugin mount ${String(starts)}`],
invalidate() {},
}
},
}))
})
await plugin
await vi.waitFor(() => {
expect(result.terminal.output).toContain('plugin mount 1')
})
await result.controller.dispose()
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
expect(signals[0]?.aborted).toBe(true)
expect(result.ctx.get('tui')).toBeUndefined()
const secondTerminal = new FakeTerminal()
const secondController = createTuiChat(result.ctx, {
sessionId: result.agent.id,
color: false,
welcome: 'Mounted again.',
}, {
terminal: secondTerminal,
exit: vi.fn(),
})
await vi.waitFor(() => {
expect(starts).toBe(2)
expect(secondTerminal.output).toContain('plugin mount 2')
})
await sessions[1]?.close()
await secondController.dispose()
await plugin.dispose()
await result.ctx.fiber.dispose()
})
})
describe('terminal mounting', () => {
@@ -2367,6 +2654,7 @@ describe('terminal mounting', () => {
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
expect(ctx.get('tui')).toBeUndefined()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {

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