Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # docs/event-producer-consumer.md
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
|
||||
| `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
|
||||
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
|
||||
@@ -23,7 +23,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
|
||||
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
|
||||
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
|
||||
|
||||
@@ -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'
|
||||
@@ -1056,7 +1056,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
const { text } = referencedPrompt
|
||||
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
|
||||
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = []
|
||||
let preparedContexts: NonNullable<Parameters<Agent['followup']>[1]>['contexts'] = []
|
||||
if (referencedPrompt.references.length > 0) {
|
||||
const sessionReferences = ctx.get('sessionReferences')
|
||||
if (sessionReferences === undefined) {
|
||||
@@ -1081,14 +1081,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
assertOpen()
|
||||
}
|
||||
// Install the in-flight slot BEFORE send() (send does not synchronously
|
||||
// Install the in-flight slot BEFORE followup() (followup does not synchronously
|
||||
// flip status to running; the session/event listener records the turn
|
||||
// number and settle/rejects it). Capture the log length now as the
|
||||
// A turn that ends in error rejects this promise (the codec never
|
||||
// produces an error stop reason).
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
rec.inflight = { resolve, reject, turn: undefined }
|
||||
rec.agent.send(preparedContent, { contexts: preparedContexts })
|
||||
rec.agent.followup(preparedContent, { contexts: preparedContexts })
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
@@ -1333,8 +1333,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
||||
* generic fallback (title = tool name, raw args as input) when no registry is
|
||||
* available (e.g. pure translator tests).
|
||||
*
|
||||
* Other event types (turn/step boundaries, context/message, …) produce
|
||||
* no client update.
|
||||
* Other event types (turn/step boundaries, injected-context user messages, …)
|
||||
* produce no client update.
|
||||
* @param sessionId - the ACP session id stamped on every emitted notification.
|
||||
* @param event - the harness session event to translate.
|
||||
* @param notify - sink for each produced `session/update` notification; called
|
||||
@@ -1374,6 +1374,9 @@ export function streamSessionEventUpdate(
|
||||
}
|
||||
case 'user/message': {
|
||||
if (!includeUserMessages) return
|
||||
// Only a direct human prompt replays as a user message; injected context
|
||||
// (plugin/goal source) is not the user's turn and produces no update.
|
||||
if (event.data.source.kind !== 'user') return
|
||||
// Replay the user's prompt so a loaded session shows both sides of each
|
||||
// turn. Live prompt turns suppress this path to avoid duplicating what
|
||||
// the client just sent.
|
||||
@@ -1420,7 +1423,7 @@ export function streamSessionEventUpdate(
|
||||
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
|
||||
return
|
||||
}
|
||||
// non-error turn/step boundaries, context/message, steering,
|
||||
// non-error turn/step boundaries, injected-context user messages, steering,
|
||||
// assistant/message — no direct ACP client update.
|
||||
default:
|
||||
return
|
||||
@@ -1499,7 +1502,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.
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('acp bridge', () => {
|
||||
},
|
||||
}],
|
||||
})
|
||||
expect(target.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
|
||||
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
|
||||
expect(request).toContain('untrusted, read-only snapshot')
|
||||
expect(request).toContain('source background')
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const handle = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
handle.agent.followup([{ type: 'text', text: 'go' }])
|
||||
await handle.agent.whenIdle()
|
||||
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
|
||||
// disposed — its exit runs a final session/flush we can gate to hold the
|
||||
// teardown observably in-flight.
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
handle.agent.followup([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(handle.agent.status).toBe('running')
|
||||
let releaseFlush!: () => void
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('acp bridge — demux & config edges', () => {
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
foreign.followup([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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' } },
|
||||
@@ -285,10 +285,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
// On the queued prompt, synchronously inject a one-shot context turn (idle
|
||||
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
||||
// inject writes turn/start{injection} → user/message → turn/end). Fire
|
||||
// once so it lands between install and the prompt turn.
|
||||
let injected = false
|
||||
harness.ctx.on('agent/queued', (subject) => {
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
|
||||
@@ -6,17 +6,18 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
|
||||
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
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 +40,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.
|
||||
|
||||
@@ -80,6 +80,18 @@ export function parseResumeArg(
|
||||
return { resumeSessionId, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
|
||||
* @param argv - current arguments after command dispatch.
|
||||
* @param sessionId - selected session id.
|
||||
* @returns flag-normalized arguments for a process replacement.
|
||||
*/
|
||||
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
|
||||
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
|
||||
const { rest } = parseResumeArg(argv)
|
||||
return [...rest, RESUME_FLAG, sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
@@ -209,20 +221,24 @@ 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}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(
|
||||
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
|
||||
binName: string,
|
||||
absoluteConfigPath: string,
|
||||
patches?: PatchOptions[],
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await prepare?.(ctx)
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -55,6 +55,15 @@ describe('parseResumeArg', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceResumeArg', () => {
|
||||
it('keeps positional arguments and replaces either existing flag form', () => {
|
||||
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
@@ -196,6 +205,19 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs host preparation before the Loader tree mounts', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const prepared: Context[] = []
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
|
||||
try {
|
||||
expect(prepared).toEqual([ctx])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
|
||||
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal file
128
packages/ui/app-boot/tests/config-reload.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -152,7 +152,7 @@ export class HarnessSdkServer {
|
||||
rec.activePrompt = true
|
||||
try {
|
||||
rec.lastTurnEnd = undefined
|
||||
rec.handle.agent.send(params.contentBlocks)
|
||||
rec.handle.agent.followup(params.contentBlocks)
|
||||
await rec.handle.agent.whenIdle()
|
||||
const status = this.finishedStatus(rec.lastTurnEnd)
|
||||
this.transport.notify('session.finished', {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
@@ -152,7 +152,7 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
||||
})
|
||||
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
await orphanHandle.agent.whenIdle()
|
||||
await orphanHandle.dispose()
|
||||
expect(llmServer.requests).toHaveLength(3)
|
||||
@@ -170,16 +170,16 @@ describe('HarnessSdkServer', () => {
|
||||
const mainWhenIdle = vi.fn<() => Promise<void>>()
|
||||
.mockReturnValueOnce(firstMainIdle)
|
||||
.mockResolvedValue(undefined)
|
||||
const mainSend = vi.fn()
|
||||
const mainAgent = {
|
||||
send: mainSend,
|
||||
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
|
||||
const mainAgent = ({
|
||||
followup: mainFollowup,
|
||||
whenIdle: mainWhenIdle,
|
||||
} as unknown as Agent
|
||||
const otherSend = vi.fn()
|
||||
const otherAgent = {
|
||||
send: otherSend,
|
||||
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
|
||||
const otherAgent = ({
|
||||
followup: otherFollowup,
|
||||
whenIdle: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as Agent
|
||||
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
||||
@@ -196,7 +196,7 @@ describe('HarnessSdkServer', () => {
|
||||
})
|
||||
|
||||
const first = prompt('main', 'first')
|
||||
await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() })
|
||||
await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
|
||||
|
||||
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
|
||||
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
|
||||
@@ -208,8 +208,8 @@ describe('HarnessSdkServer', () => {
|
||||
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
|
||||
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
|
||||
|
||||
expect(mainSend).toHaveBeenCalledTimes(4)
|
||||
expect(otherSend).toHaveBeenCalledOnce()
|
||||
expect(mainFollowup).toHaveBeenCalledTimes(4)
|
||||
expect(otherFollowup).toHaveBeenCalledOnce()
|
||||
await server.shutdown()
|
||||
expect(mainHandle.dispose).toHaveBeenCalledOnce()
|
||||
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
||||
@@ -225,9 +225,9 @@ describe('HarnessSdkServer', () => {
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
const session = ctx.sessions.create(SessionId('message-outcome'))
|
||||
const agent = {
|
||||
const agent = ({
|
||||
session,
|
||||
send(content: { type: 'text'; text: string }[]) {
|
||||
followup(content: { type: 'text'; text: string }[]) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
@@ -241,14 +241,15 @@ describe('HarnessSdkServer', () => {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
return AgentMessageId('message-outcome')
|
||||
},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} as unknown as Agent
|
||||
} satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
server.sessions.set('message-outcome', {
|
||||
handle: { agent, dispose: () => Promise.resolve() },
|
||||
lastTurnEnd: undefined,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } : {},
|
||||
})),
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.followup()` from the status after that asynchronous preparation, so idle follow-ups 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.followup()`. 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.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
@@ -26,7 +30,9 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place.
|
||||
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -38,14 +44,18 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
| `maxQuestionOptions` | `8` | Visible options in a question panel |
|
||||
| `maxModelOptions` | `8` | Visible models in the model selector |
|
||||
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
|
||||
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
|
||||
| `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. |
|
||||
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
|
||||
| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
@@ -55,9 +65,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
|
||||
|
||||
@@ -69,7 +80,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -79,6 +90,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
|
||||
@@ -97,7 +128,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -123,7 +154,10 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **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.
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -48,6 +50,9 @@
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-skill": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -60,6 +65,7 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
165
packages/ui/tui/src/extension.ts
Normal file
165
packages/ui/tui/src/extension.ts
Normal 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>
|
||||
}
|
||||
346
packages/ui/tui/src/file-autocomplete.ts
Normal file
346
packages/ui/tui/src/file-autocomplete.ts
Normal 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 })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
369
packages/ui/tui/src/overlay-manager.ts
Normal file
369
packages/ui/tui/src/overlay-manager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
587
packages/ui/tui/tests/extension.spec.ts
Normal file
587
packages/ui/tui/tests/extension.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
197
packages/ui/tui/tests/file-autocomplete.spec.ts
Normal file
197
packages/ui/tui/tests/file-autocomplete.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, {
|
||||
AgentMessageId,
|
||||
type Agent,
|
||||
type AgentCancelCause,
|
||||
type AgentOptions,
|
||||
@@ -14,6 +15,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
@@ -48,7 +50,13 @@ export interface TuiHarnessOptions {
|
||||
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
|
||||
}
|
||||
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
|
||||
sessionPersistence?: { list(): Promise<SessionHeader[]> }
|
||||
sessionPersistence?: {
|
||||
list(): Promise<SessionHeader[]>
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Set false to exercise the optional session-query degradation path. */
|
||||
mountSessionQuery?: boolean
|
||||
}
|
||||
|
||||
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
|
||||
@@ -118,7 +126,22 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
}
|
||||
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
|
||||
if (options.sessionPersistence !== undefined) {
|
||||
ctx.provide('sessionPersistence', options.sessionPersistence as never)
|
||||
const persistence = options.sessionPersistence
|
||||
ctx.provide('sessionPersistence', {
|
||||
...persistence,
|
||||
locate: () => undefined,
|
||||
create: () => Promise.resolve(),
|
||||
append: () => Promise.resolve(),
|
||||
load: persistence.load === undefined
|
||||
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
|
||||
inspect: persistence.load === undefined
|
||||
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
|
||||
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
|
||||
} as never)
|
||||
}
|
||||
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
}
|
||||
const sessionId = SessionId('main-session')
|
||||
const session = ctx.sessions.create(
|
||||
@@ -149,15 +172,23 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
steered,
|
||||
steeredOptions,
|
||||
cancelled,
|
||||
send(content, options) {
|
||||
followup(content, options) {
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
queue(content, options) {
|
||||
sent.push(content)
|
||||
sentOptions.push(options)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
steer(content, options) {
|
||||
steered.push(content)
|
||||
steeredOptions.push(options)
|
||||
return AgentMessageId('stub')
|
||||
},
|
||||
inject() {},
|
||||
inject: () => AgentMessageId('stub'),
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
@@ -178,6 +209,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
// test pins the clock only by passing `now` explicitly.
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
|
||||
})
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('dsh-tui plugin export shape', () => {
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual([
|
||||
'agents',
|
||||
'sessions',
|
||||
'commands',
|
||||
'userInteraction',
|
||||
'tools',
|
||||
|
||||
16
packages/ui/tui/tests/session-query.ts
Normal file
16
packages/ui/tui/tests/session-query.ts
Normal 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: [] })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -128,7 +128,7 @@ describe('TUI session-reference snapshot', () => {
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
})
|
||||
expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(target.session.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,32 +1,51 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=10 bufferRow=10
|
||||
cursor hidden column=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
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| <blank>
|
||||
4| " Resumable sessions "
|
||||
style 1-18 fg=bright-blue bold
|
||||
5| " 2024-01-02 03:04 (current) "
|
||||
style 1-16 fg=bright-black
|
||||
style 17-26 fg=green
|
||||
6| " RESUME_SESSION_ID=main-session dsh "
|
||||
7| " 2024-01-01 00:00 "
|
||||
style 1-16 fg=bright-black
|
||||
8| " RESUME_SESSION_ID=earlier-session dsh "
|
||||
9| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
10| " "
|
||||
style 1-1 inverse
|
||||
11| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 65-91 dim
|
||||
13-31| <blank>
|
||||
0| " "
|
||||
1| " Resume session (1 of 2) "
|
||||
style 2-24 fg=bright-blue bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
4| " │ ⌕ │ "
|
||||
style 2-2 dim
|
||||
style 6-6 inverse
|
||||
style 89-89 dim
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " /workspace/project "
|
||||
style 2-19 fg=bright-black
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-blue bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 fg=bright-black
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
13| " Resume selector design "
|
||||
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
|
||||
style 2-74 fg=bright-black
|
||||
15| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
16| " "
|
||||
17| " "
|
||||
18| " "
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
25| " "
|
||||
26| " "
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel "
|
||||
style 2-70 dim
|
||||
31| " "
|
||||
|
||||
@@ -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 = {
|
||||
@@ -451,7 +471,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
session.append('todo/write', {
|
||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
|
||||
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -567,7 +587,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('context/message', {
|
||||
harness.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
@@ -626,13 +646,27 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('lists this workspace\'s resumable sessions with their commands', async () => {
|
||||
it('opens the searchable resume selector with log-backed session summaries', async () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
|
||||
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
|
||||
const harness = await setupSnapshot({
|
||||
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
|
||||
sessionPersistence: { list: async () => [
|
||||
{ version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' },
|
||||
{ version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' },
|
||||
] },
|
||||
sessionPersistence: {
|
||||
list: async () => [earlier],
|
||||
load: async () => ({
|
||||
meta: earlier,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
|
||||
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
|
||||
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}, { columns: 92, rows: 32 })
|
||||
harness.terminal.send('/resume')
|
||||
harness.terminal.send('\r')
|
||||
@@ -642,6 +676,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
dateNow.mockRestore()
|
||||
})
|
||||
|
||||
it('pins the detailed session diagnostics card', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
@@ -29,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user