Merge remote-tracking branch 'origin/master' into codex/pr357-merge-review-20260719
This commit is contained in:
@@ -5,11 +5,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
|
||||
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` |
|
||||
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with terminal and ACP front-door clusters and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
|
||||
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
|
||||
- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-stdio-demo
|
||||
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`.
|
||||
|
||||
It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
|
||||
It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
@@ -10,12 +10,13 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the exact app-owned agent/session identity and rendering it as `main` |
|
||||
| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path |
|
||||
| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity |
|
||||
| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity |
|
||||
|
||||
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
|
||||
|
||||
@@ -36,10 +37,11 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `welcome` | `ready.` | terminal banner / TUI subtitle |
|
||||
| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header.
|
||||
Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd.
|
||||
|
||||
## The bin
|
||||
|
||||
@@ -67,6 +69,8 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd` an
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a coding assistant powered by the {{model}} model.'
|
||||
ui:
|
||||
mode: auto
|
||||
```
|
||||
|
||||
Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app".
|
||||
@@ -75,9 +79,9 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
|
||||
|
||||
### Composed terminal agent request
|
||||
|
||||
**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message.
|
||||
**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
|
||||
|
||||
**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens.
|
||||
**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens.
|
||||
|
||||
### Human-answer result
|
||||
|
||||
@@ -87,6 +91,6 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo —
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One pre-created `main` agent drives the readline UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
|
||||
- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
|
||||
- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package.
|
||||
- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio-demo",
|
||||
"description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml",
|
||||
"description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -32,8 +32,8 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-stdio": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
@@ -51,8 +52,8 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-stdio": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the
|
||||
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
|
||||
* dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
|
||||
* dsh-app-boot. The echo and coding-agent demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-stdio-demo/bin
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
|
||||
* coupled front-door cluster a terminal chat needs — a console logger, the independently
|
||||
* packaged readline UI, JSONL session persistence, the user-interaction seam with its
|
||||
* coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline
|
||||
* presentation, JSONL session persistence, the user-interaction seam with its
|
||||
* `ask_user_question` tool, and one pre-created agent whose exact shared
|
||||
* agent/session identity the UI drives under its `main` display label.
|
||||
* agent/session identity the selected UI drives under its `main` display label.
|
||||
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
|
||||
* Loader plugin intentionally exposes named exports only; a default export
|
||||
* would hide its `Config` schema (see docs/postmortem/0001).
|
||||
@@ -22,11 +22,46 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiStdio from '@deepseek-ai/dsh-stdio'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'stdio-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
const DEFAULT_WELCOME = 'ready.'
|
||||
|
||||
/** Terminal front door selected by the app bundle. */
|
||||
export type TerminalMode = 'auto' | 'readline' | 'tui'
|
||||
|
||||
/** App-level terminal selection with nested TUI presentation settings. */
|
||||
export interface UiConfig {
|
||||
/** Select a concrete front door or infer it from the process streams. */
|
||||
mode?: TerminalMode
|
||||
/** Settings forwarded only when the pi-tui front door is selected. */
|
||||
tui?: uiTui.TuiConfig
|
||||
}
|
||||
|
||||
const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto')
|
||||
|
||||
/** Schemastery schema for app-level terminal selection. */
|
||||
export const UiConfigSchema: z<UiConfig> = z.object({
|
||||
mode: terminalModeSchema,
|
||||
tui: uiTui.TuiConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the app's terminal front door.
|
||||
* @param config - app-level terminal selection.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
* @returns the concrete UI package to mount.
|
||||
*/
|
||||
export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude<TerminalMode, 'auto'> {
|
||||
const mode = config?.mode ?? 'auto'
|
||||
if (mode === 'auto') return isTTY ? 'tui' : 'readline'
|
||||
if (mode === 'tui' && !isTTY) {
|
||||
throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes')
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||
@@ -35,7 +70,7 @@ const DEFAULT_WELCOME = 'ready.'
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner.
|
||||
* `welcome` is the UI banner and `ui` configures terminal mode/presentation.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Provider route for the `main` agent. */
|
||||
@@ -56,6 +91,8 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Terminal front-door selection and pi-tui presentation settings. */
|
||||
ui?: UiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
@@ -85,6 +122,7 @@ export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
ui: UiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: agentCore.ToolTasksConfigSchema,
|
||||
@@ -93,23 +131,34 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Compose the spine with the stdio front door. Console logging, persistence,
|
||||
* and user interaction mount first; the readline UI then waits on the agent
|
||||
* registry and subscribes to config-start failures before agent-core can start
|
||||
* the configured identity. The ask-user tool waits on the completed spine.
|
||||
* The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is
|
||||
* not mounted here.
|
||||
* Compose the spine with one terminal front door. Persistence and user
|
||||
* interaction mount first; the selected UI then waits on the exact session id
|
||||
* and subscribes to config-start failures before agent-core starts it. Console
|
||||
* logging is readline-only because fullscreen output belongs to pi-tui. The
|
||||
* ask-user tool waits on the completed spine, and HMR remains a leaf concern.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - app configuration routed to the spine and front door.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
ctx.plugin(ConsoleExporter)
|
||||
const mode = resolveTerminalMode(config.ui, isTTY)
|
||||
if (mode === 'readline') ctx.plugin(ConsoleExporter)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(uiStdio, {
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
if (mode === 'tui') {
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui?.tui,
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
} else {
|
||||
ctx.plugin(uiStdio, {
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
ctx.plugin(agentCore, {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
agents: [{
|
||||
@@ -122,3 +171,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
}
|
||||
|
||||
/** Compose the configured terminal front door with the agent app. */
|
||||
/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered,
|
||||
and the coding-agent PTY smoke covers the interactive process path */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -11,8 +11,8 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
|
||||
* agent-spine-demo spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
|
||||
* Unit coverage for app composition and config forwarding: pre-created main agent,
|
||||
* agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the
|
||||
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
|
||||
* survive namespace collapse while silently losing its schema.
|
||||
*/
|
||||
@@ -66,6 +66,63 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
describe('dsh-stdio-demo app', () => {
|
||||
it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => {
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui')
|
||||
expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout')
|
||||
})
|
||||
|
||||
it('binds only the selected terminal package to the app-owned exact session identity', () => {
|
||||
const calls: Array<{ name: string; config: unknown }> = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
} as unknown as Context
|
||||
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
welcome: 'TUI ready',
|
||||
ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
|
||||
}, true)
|
||||
expect(calls.map(call => call.name)).toContain('ui-tui')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
|
||||
const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-/)
|
||||
const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as {
|
||||
agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }>
|
||||
}
|
||||
expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
ui: { mode: 'tui' },
|
||||
}, true)
|
||||
expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
|
||||
sessionId: 'persisted-session', welcome: 'ready.',
|
||||
})
|
||||
expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0])
|
||||
.toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' },
|
||||
}, false)
|
||||
expect(calls.map(call => call.name)).toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).toContain('ConsoleExporter')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-tui')
|
||||
})
|
||||
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
// The spine services (brought up by the agent-spine-demo bundle) are all present.
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
{
|
||||
"path": "../../ui/stdio"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tui"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
|
||||
@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
|
||||
@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
|
||||
## Export shape
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ 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`) |
|
||||
| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) |
|
||||
| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (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). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects.
|
||||
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). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects.
|
||||
|
||||
`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.
|
||||
|
||||
The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
## Service / plugin
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera
|
||||
| `welcome` | `ready.` | Banner printed before the first prompt |
|
||||
| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown |
|
||||
|
||||
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects.
|
||||
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects.
|
||||
|
||||
```yaml
|
||||
- id: stdio
|
||||
|
||||
64
packages/ui/tui/README.md
Normal file
64
packages/ui/tui/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
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 compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead.
|
||||
|
||||
The implemented [TUI feature RFC](../../../docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot RFC](../../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, 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.
|
||||
|
||||
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 as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
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.
|
||||
|
||||
While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `welcome` | `ready.` | Header subtitle |
|
||||
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
|
||||
| `showReasoning` | `true` | Render reasoning blocks |
|
||||
| `maxToolOutputLines` | `12` | Collapsed tool-card output limit |
|
||||
| `maxQuestionOptions` | `8` | Visible options in a question overlay |
|
||||
| `questionDialogWidth` | `72` | Question-overlay width in columns |
|
||||
| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows |
|
||||
| `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` | Terminal window title |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config:
|
||||
welcome: 'Coding agent ready.'
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 12
|
||||
```
|
||||
|
||||
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 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
|
||||
|
||||
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interactive prompt input
|
||||
|
||||
**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
|
||||
|
||||
**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens.
|
||||
|
||||
### Interactive user-question answers
|
||||
|
||||
**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
|
||||
|
||||
**Token effect**: Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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 select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback.
|
||||
52
packages/ui/tui/package.json
Normal file
52
packages/ui/tui/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tui",
|
||||
"description": "Interactive pi-tui terminal front door for DeepSeek Harness agents",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
1355
packages/ui/tui/src/index.ts
Normal file
1355
packages/ui/tui/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
131
packages/ui/tui/tests/harness.ts
Normal file
131
packages/ui/tui/tests/harness.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createTuiChat, type Config } from '../src/index.ts'
|
||||
|
||||
interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
steered: ContentBlock[][]
|
||||
cancelled: string[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
status?: AgentStatus
|
||||
config?: Config
|
||||
tools?: Record<string, ToolDefinition>
|
||||
configureContext?: (ctx: Context) => Promise<void>
|
||||
beforeMount?: (session: Session) => void
|
||||
cwd?: string | null
|
||||
}
|
||||
|
||||
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
|
||||
ctx: Context
|
||||
session: Session
|
||||
agent: FakeAgent
|
||||
terminal: TerminalType
|
||||
exit: Exit
|
||||
controller: ReturnType<typeof createTuiChat>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the production TUI around an in-memory session and controllable agent.
|
||||
* @param terminal - Terminal boundary driven by the test.
|
||||
* @param exit - Process-exit observer.
|
||||
* @param options - Initial session, agent, tool, and TUI configuration.
|
||||
* @returns The mounted TUI and every boundary the test may drive or inspect.
|
||||
*/
|
||||
export async function createTuiTestHarness<TerminalType extends Terminal, Exit extends (code: number) => void>(
|
||||
terminal: TerminalType,
|
||||
exit: Exit,
|
||||
options: TuiHarnessOptions = {},
|
||||
): Promise<TuiHarness<TerminalType, Exit>> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.configureContext === undefined) {
|
||||
const tools = options.tools ?? {}
|
||||
ctx.provide('tools', {
|
||||
get(name: string) {
|
||||
return tools[name]
|
||||
},
|
||||
} as never)
|
||||
} else {
|
||||
await options.configureContext(ctx)
|
||||
}
|
||||
const sessionId = SessionId('main-session')
|
||||
const session = ctx.sessions.create(
|
||||
sessionId,
|
||||
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
|
||||
)
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const cancelled: string[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: { model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
sent,
|
||||
steered,
|
||||
cancelled,
|
||||
send(content) {
|
||||
sent.push(content)
|
||||
},
|
||||
steer(content) {
|
||||
steered.push(content)
|
||||
},
|
||||
inject() {},
|
||||
cancel(reason) {
|
||||
cancelled.push(reason ?? '')
|
||||
},
|
||||
whenIdle() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
const controller = createTuiChat(ctx, Object.assign({
|
||||
welcome: 'Coding agent ready.',
|
||||
sessionId,
|
||||
color: false,
|
||||
}, options.config), { terminal, exit })
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
|
||||
/** Dispose the mounted TUI before its owning Cordis context. */
|
||||
export async function disposeTuiTestHarness(
|
||||
setup: Pick<TuiHarness<Terminal, (code: number) => void>, 'controller' | 'ctx'>,
|
||||
): Promise<void> {
|
||||
await setup.controller.dispose()
|
||||
await setup.ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
/** Append a production-shaped user message to the active session surface. */
|
||||
export function appendUser(session: Session, text: string): void {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the active session surface. */
|
||||
export function appendAssistant(
|
||||
session: Session,
|
||||
content: ContentBlock[],
|
||||
usage?: { inputTokens: number; outputTokens: number },
|
||||
): void {
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content,
|
||||
...usage === undefined ? {} : { usage },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
318
packages/ui/tui/tests/headless-terminal.ts
Normal file
318
packages/ui/tui/tests/headless-terminal.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import { Terminal as XtermTerminal, type IBufferCell } from '@xterm/headless'
|
||||
|
||||
const FRAME_END = '\x1b[?2026l'
|
||||
const FRAME_TIMEOUT_MS = 2_000
|
||||
|
||||
const ANSI_COLORS = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white',
|
||||
] as const
|
||||
|
||||
interface FrameWaiter {
|
||||
target: number
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface RowSnapshot {
|
||||
text: string
|
||||
wrapped: boolean
|
||||
styles: string[]
|
||||
}
|
||||
|
||||
export interface TerminalSnapshotOptions {
|
||||
/** Include the whole active buffer instead of only the visible viewport. */
|
||||
includeScrollback?: boolean
|
||||
}
|
||||
|
||||
function occurrenceCount(value: string, needle: string): number {
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const match = value.indexOf(needle, offset)
|
||||
if (match < 0) return count
|
||||
count += 1
|
||||
offset = match + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
function colorLabel(cell: IBufferCell, kind: 'fg' | 'bg'): string | undefined {
|
||||
const isDefault = kind === 'fg' ? cell.isFgDefault() : cell.isBgDefault()
|
||||
if (isDefault) return undefined
|
||||
const isRgb = kind === 'fg' ? cell.isFgRGB() : cell.isBgRGB()
|
||||
const value = kind === 'fg' ? cell.getFgColor() : cell.getBgColor()
|
||||
if (isRgb) return `${kind}=#${value.toString(16).padStart(6, '0')}`
|
||||
const name = ANSI_COLORS[value]
|
||||
return `${kind}=${name ?? `ansi-${value}`}`
|
||||
}
|
||||
|
||||
function styleLabel(cell: IBufferCell): string {
|
||||
const labels = [
|
||||
colorLabel(cell, 'fg'),
|
||||
colorLabel(cell, 'bg'),
|
||||
cell.isBold() !== 0 ? 'bold' : undefined,
|
||||
cell.isDim() !== 0 ? 'dim' : undefined,
|
||||
cell.isItalic() !== 0 ? 'italic' : undefined,
|
||||
cell.isUnderline() !== 0 ? 'underline' : undefined,
|
||||
cell.isBlink() !== 0 ? 'blink' : undefined,
|
||||
cell.isInverse() !== 0 ? 'inverse' : undefined,
|
||||
cell.isInvisible() !== 0 ? 'invisible' : undefined,
|
||||
cell.isStrikethrough() !== 0 ? 'strike' : undefined,
|
||||
cell.isOverline() !== 0 ? 'overline' : undefined,
|
||||
].filter((label): label is string => label !== undefined)
|
||||
return labels.join(' ')
|
||||
}
|
||||
|
||||
function snapshotRow(terminal: XtermTerminal, row: number): RowSnapshot {
|
||||
const line = terminal.buffer.active.getLine(row)
|
||||
if (line === undefined) return { text: '', wrapped: false, styles: [] }
|
||||
const styles: string[] = []
|
||||
let activeStyle = ''
|
||||
let activeStart = 0
|
||||
for (let column = 0; column <= terminal.cols; column++) {
|
||||
const cell = column < terminal.cols ? line.getCell(column) : undefined
|
||||
const style = cell === undefined ? '' : styleLabel(cell)
|
||||
if (style === activeStyle) continue
|
||||
if (activeStyle !== '') styles.push(`${activeStart}-${column - 1} ${activeStyle}`)
|
||||
activeStyle = style
|
||||
activeStart = column
|
||||
}
|
||||
return {
|
||||
text: line.translateToString(true),
|
||||
wrapped: line.isWrapped,
|
||||
styles,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(rows: readonly RowSnapshot[], firstRow: number): string[] {
|
||||
const rendered: string[] = []
|
||||
let blankStart: number | undefined
|
||||
const flushBlanks = (end: number): void => {
|
||||
if (blankStart === undefined) return
|
||||
rendered.push(blankStart === end ? `${blankStart}| <blank>` : `${blankStart}-${end}| <blank>`)
|
||||
blankStart = undefined
|
||||
}
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const absoluteRow = firstRow + index
|
||||
const row = rows[index] as RowSnapshot
|
||||
if (row.text === '' && row.styles.length === 0 && !row.wrapped) {
|
||||
blankStart ??= absoluteRow
|
||||
continue
|
||||
}
|
||||
flushBlanks(absoluteRow - 1)
|
||||
rendered.push(`${absoluteRow}${row.wrapped ? '~' : ''}| ${JSON.stringify(row.text)}`)
|
||||
for (const style of row.styles) rendered.push(` style ${style}`)
|
||||
}
|
||||
flushBlanks(firstRow + rows.length - 1)
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal emulator used by TUI snapshots. It consumes the same ANSI stream as
|
||||
* a real terminal and exposes completed synchronized frames as an awaitable boundary.
|
||||
*/
|
||||
export class HeadlessTerminal implements Terminal {
|
||||
readonly kittyProtocolActive = false
|
||||
readonly drainInput = (): Promise<void> => Promise.resolve()
|
||||
started = 0
|
||||
stopped = 0
|
||||
title = ''
|
||||
progress = false
|
||||
cursorVisible = true
|
||||
frames = 0
|
||||
private readonly emulator: XtermTerminal
|
||||
private onInput: (data: string) => void = () => {}
|
||||
private onResize: () => void = () => {}
|
||||
private pendingWrite: Promise<void> = Promise.resolve()
|
||||
private readonly frameWaiters = new Set<FrameWaiter>()
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this.emulator = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows,
|
||||
scrollback: 1_000,
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors: false,
|
||||
logLevel: 'off',
|
||||
})
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.emulator.cols
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.emulator.rows
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.started += 1
|
||||
this.onInput = onInput
|
||||
this.onResize = onResize
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped += 1
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const completedFrames = occurrenceCount(data, FRAME_END)
|
||||
this.pendingWrite = new Promise((resolve) => {
|
||||
this.emulator.write(data, () => {
|
||||
this.frames += completedFrames
|
||||
for (const waiter of this.frameWaiters) {
|
||||
if (this.frames < waiter.target) continue
|
||||
clearTimeout(waiter.timer)
|
||||
this.frameWaiters.delete(waiter)
|
||||
waiter.resolve()
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) this.write(`\x1b[${lines}B`)
|
||||
if (lines < 0) this.write(`\x1b[${-lines}A`)
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.cursorVisible = false
|
||||
this.write('\x1b[?25l')
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.cursorVisible = true
|
||||
this.write('\x1b[?25h')
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.write('\x1b[K')
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.write('\x1b[J')
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.write('\x1b[2J\x1b[H')
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title
|
||||
this.write(`\x1b]0;${title}\x07`)
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
this.progress = active
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.onInput(data)
|
||||
}
|
||||
|
||||
resize(columns: number, rows = this.rows): void {
|
||||
this.emulator.resize(columns, rows)
|
||||
this.onResize()
|
||||
}
|
||||
|
||||
/** Wait until pi-tui completes a synchronized frame newer than `after`. */
|
||||
async waitForFrame(after = this.frames): Promise<void> {
|
||||
if (this.frames <= after) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: FrameWaiter = {
|
||||
target: after + 1,
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => {
|
||||
this.frameWaiters.delete(waiter)
|
||||
reject(new Error(`TUI did not complete frame ${after + 1} within ${FRAME_TIMEOUT_MS}ms`))
|
||||
}, FRAME_TIMEOUT_MS),
|
||||
}
|
||||
this.frameWaiters.add(waiter)
|
||||
})
|
||||
}
|
||||
await this.flush()
|
||||
}
|
||||
|
||||
/** Await every terminal write queued through the current task. */
|
||||
async flush(): Promise<void> {
|
||||
let pending: Promise<void>
|
||||
do {
|
||||
pending = this.pendingWrite
|
||||
await pending
|
||||
} while (pending !== this.pendingWrite)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject palette output that would become theme-specific in a user's terminal.
|
||||
* @returns One location per RGB, extended-palette, or explicit-background cell.
|
||||
*/
|
||||
themeViolations(): string[] {
|
||||
const violations: string[] = []
|
||||
const buffer = this.emulator.buffer.active
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
const line = buffer.getLine(row)
|
||||
if (line === undefined) continue
|
||||
for (let column = 0; column < this.columns; column++) {
|
||||
const cell = line.getCell(column)
|
||||
if (cell === undefined) continue
|
||||
const reasons = [
|
||||
cell.isFgRGB() ? 'rgb-fg' : undefined,
|
||||
cell.isBgRGB() ? 'rgb-bg' : undefined,
|
||||
cell.isFgPalette() && cell.getFgColor() > 15 ? `extended-fg-${cell.getFgColor()}` : undefined,
|
||||
cell.isBgPalette() && cell.getBgColor() > 15 ? `extended-bg-${cell.getBgColor()}` : undefined,
|
||||
!cell.isBgDefault() ? 'explicit-bg' : undefined,
|
||||
].filter((reason): reason is string => reason !== undefined)
|
||||
if (reasons.length > 0) violations.push(`${row}:${column} ${reasons.join(',')}`)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/** Serialize terminal cells and metadata into a stable, reviewable golden. */
|
||||
async snapshot(options: TerminalSnapshotOptions = {}): Promise<string> {
|
||||
await this.flush()
|
||||
const buffer = this.emulator.buffer.active
|
||||
const firstRow = options.includeScrollback === true ? 0 : buffer.viewportY
|
||||
const rowCount = options.includeScrollback === true ? buffer.length : this.rows
|
||||
const rows = Array.from({ length: rowCount }, (_, index) => snapshotRow(this.emulator, firstRow + index))
|
||||
const cursorBufferRow = buffer.baseY + buffer.cursorY
|
||||
const cursorViewportRow = cursorBufferRow - buffer.viewportY
|
||||
return [
|
||||
`terminal ${this.columns}x${this.rows} buffer=${buffer.type} length=${buffer.length} base=${buffer.baseY} viewport=${buffer.viewportY}`,
|
||||
`lifecycle started=${this.started} stopped=${this.stopped} progress=${this.progress ? 'active' : 'inactive'}`,
|
||||
`title ${JSON.stringify(this.title)}`,
|
||||
`cursor ${this.cursorVisible ? 'visible' : 'hidden'} column=${buffer.cursorX} viewportRow=${cursorViewportRow} bufferRow=${cursorBufferRow}`,
|
||||
options.includeScrollback === true ? 'buffer' : 'viewport',
|
||||
...renderRows(rows, firstRow),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.flush()
|
||||
for (const waiter of this.frameWaiters) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.reject(new Error('terminal disposed before the requested frame completed'))
|
||||
}
|
||||
this.frameWaiters.clear()
|
||||
this.emulator.dispose()
|
||||
}
|
||||
}
|
||||
19
packages/ui/tui/tests/plugin-shape.spec.ts
Normal file
19
packages/ui/tui/tests/plugin-shape.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as tui from '../src/index.ts'
|
||||
|
||||
/** Real Loader export-path guard for the namespace TUI plugin. */
|
||||
describe('dsh-tui plugin export shape', () => {
|
||||
it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
|
||||
expect('default' in tui).toBe(false)
|
||||
expect(typeof tui.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tui)
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
terminal 100x40 buffer=normal length=41 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=37 bufferRow=38
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=green
|
||||
7| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
8| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
9| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
10| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
11| "▌ … 4 more lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
12| "▌ "
|
||||
style 0-0 fg=green
|
||||
13| <blank>
|
||||
14| "▌ "
|
||||
style 0-0 fg=green
|
||||
15| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
16| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
17| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
18| "▌ - keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
19| "▌ … 5 more lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| <blank>
|
||||
22| "▌ "
|
||||
style 0-0 fg=green
|
||||
23| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
24| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
25| "▌ "
|
||||
style 0-0 fg=green
|
||||
26| <blank>
|
||||
27| "▌ "
|
||||
style 0-0 fg=green
|
||||
28| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
29| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
30| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
31| "▌ "
|
||||
style 0-0 fg=green
|
||||
32| <blank>
|
||||
33| "▌ "
|
||||
style 0-0 fg=green
|
||||
34| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
35| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
36| "▌ "
|
||||
style 0-0 fg=green
|
||||
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
38| " "
|
||||
style 1-1 inverse
|
||||
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
@@ -0,0 +1,127 @@
|
||||
terminal 100x40 buffer=normal length=50 base=10 viewport=10
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=37 bufferRow=47
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=green
|
||||
7| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
8| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
9| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
10| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
11| "▌ 4016 tests passed "
|
||||
style 0-0 fg=green
|
||||
12| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
13| "▌ coverage complete "
|
||||
style 0-0 fg=green
|
||||
14| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
15| "▌ "
|
||||
style 0-0 fg=green
|
||||
16| <blank>
|
||||
17| "▌ "
|
||||
style 0-0 fg=green
|
||||
18| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
19| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
20| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
21| "▌ - keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
22| "▌ + new line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=green
|
||||
23| "▌ + keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=green
|
||||
24| "▌ "
|
||||
style 0-0 fg=green
|
||||
25| "▌ tests/view.spec.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-19 bold
|
||||
26| "▌ + expect(screen).toMatchSnapshot() "
|
||||
style 0-0 fg=green
|
||||
style 2-35 fg=green
|
||||
27| "▌ "
|
||||
style 0-0 fg=green
|
||||
28| <blank>
|
||||
29| "▌ "
|
||||
style 0-0 fg=green
|
||||
30| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
31| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
32| "▌ "
|
||||
style 0-0 fg=green
|
||||
33| <blank>
|
||||
34| "▌ "
|
||||
style 0-0 fg=green
|
||||
35| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
36| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
37| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
38| "▌ "
|
||||
style 0-0 fg=green
|
||||
39| <blank>
|
||||
40| "▌ "
|
||||
style 0-0 fg=green
|
||||
41| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
42| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
43| "▌ "
|
||||
style 0-0 fg=green
|
||||
44| <blank>
|
||||
45| " Tool cards expanded. "
|
||||
style 1-20 fg=bright-black
|
||||
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
47| " "
|
||||
style 1-1 inverse
|
||||
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded"
|
||||
style 0-24 dim
|
||||
style 66-99 dim
|
||||
52
packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt
Normal file
52
packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=15 bufferRow=15
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-95 bold
|
||||
8| "▌ const second = await tools.bas "
|
||||
style 0-0 fg=yellow
|
||||
style 2-31 bold
|
||||
9| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ console.log(first, second) "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ return `${first}+${second}` "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| " "
|
||||
style 1-1 inverse
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
18-35| <blank>
|
||||
@@ -0,0 +1,52 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Show the live update. "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| " Reasoning "
|
||||
style 1-9 fg=bright-black italic
|
||||
12| " Inspecting width and styles. "
|
||||
style 1-28 fg=bright-black italic
|
||||
13| <blank>
|
||||
14| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
15| " Streaming visible state… "
|
||||
style 11-23 bold
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| " "
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
20-35| <blank>
|
||||
@@ -0,0 +1,59 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=18 bufferRow=18
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ ◌ Inspect cordis runtime: tools "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-32 bold
|
||||
7| <blank>
|
||||
8| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ ◌ Mount plugin into live cordis runtime "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-40 bold
|
||||
10| "▌ { "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ ready: true }) } }\" "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ } "
|
||||
style 0-0 fg=yellow
|
||||
14| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
15| <blank>
|
||||
16| "▌ ◌ Unmount dyn-1 "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-16 bold
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
21-35| <blank>
|
||||
52
packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt
Normal file
52
packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 91-91 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 91-91 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 91-91 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-91 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
@@ -0,0 +1,55 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ ◌ workflow: tui-matrix "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-23 bold
|
||||
8| "▌ phase('Inspect') "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ const reports = await parallel([ "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ ]) "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ phase('Verify') "
|
||||
style 0-0 fg=yellow
|
||||
14| "▌ return { reports, verdict: 'covered' } "
|
||||
style 0-0 fg=yellow
|
||||
15| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| " "
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
20-35| <blank>
|
||||
52
packages/ui/tui/tests/snapshots/errors-and-help.golden.txt
Normal file
52
packages/ui/tui/tests/snapshots/errors-and-help.golden.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
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=18 bufferRow=18
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 91-91 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 91-91 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 91-91 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-91 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
@@ -0,0 +1,69 @@
|
||||
terminal 56x20 buffer=normal length=20 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=13 bufferRow=13
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────╮"
|
||||
style 0-55 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 55-55 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 55-55 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 55-55 fg=bright-blue
|
||||
4| "╰───╭ Coverage ────────────────────────────────────╮───╯"
|
||||
style 0-55 fg=bright-blue
|
||||
5| "────│ Which advanced TUI states belong in the │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-50 bold
|
||||
style 51-51 fg=bright-blue bold
|
||||
style 52-55 dim
|
||||
6| " │ required matrix? │ "
|
||||
style 1-1 inverse
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-21 bold
|
||||
style 51-51 fg=bright-blue
|
||||
7| "────│ │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-6 fg=bright-blue inverse
|
||||
style 7-20 inverse
|
||||
style 21-49 fg=bright-black inverse
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
9| " │ [ ] Workflows — phases and parallel agents │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 21-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
10| " │ [ ] Cordis tools — inspect, mount, and unm │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 24-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
11| " │ 1/4 │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-8 dim
|
||||
style 51-51 fg=bright-blue
|
||||
12| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 dim
|
||||
style 51-51 fg=bright-blue
|
||||
13| " │ Select at least one option, or press C for a │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 fg=red
|
||||
style 51-51 fg=bright-blue
|
||||
14| " ╰──────────────────────────────────────────────╯ "
|
||||
style 4-51 fg=bright-blue
|
||||
15-19| <blank>
|
||||
67
packages/ui/tui/tests/snapshots/question-dialog.golden.txt
Normal file
67
packages/ui/tui/tests/snapshots/question-dialog.golden.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
terminal 56x20 buffer=normal length=20 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=19 bufferRow=19
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────╮"
|
||||
style 0-55 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 55-55 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 55-55 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 55-55 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────╯"
|
||||
style 0-55 fg=bright-blue
|
||||
5| "────╭ Coverage ────────────────────────────────────╮────"
|
||||
style 0-3 dim
|
||||
style 4-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
6| " │ Which advanced TUI states belong in the │ "
|
||||
style 1-1 inverse
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-50 bold
|
||||
style 51-51 fg=bright-blue bold
|
||||
7| "────│ required matrix? │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-21 bold
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
8| "/wor│ │:com"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
9| " │ › [ ] Code Mode — run_code programs and capt │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-6 fg=bright-blue inverse
|
||||
style 7-20 inverse
|
||||
style 21-49 fg=bright-black inverse
|
||||
style 51-51 fg=bright-blue
|
||||
10| " │ [ ] Workflows — phases and parallel agents │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 21-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
11| " │ [ ] Cordis tools — inspect, mount, and unm │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 24-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
12| " │ 1/4 │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-8 dim
|
||||
style 51-51 fg=bright-blue
|
||||
13| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 dim
|
||||
style 51-51 fg=bright-blue
|
||||
14| " ╰──────────────────────────────────────────────╯ "
|
||||
style 4-51 fg=bright-blue
|
||||
15-19| <blank>
|
||||
@@ -0,0 +1,41 @@
|
||||
terminal 44x18 buffer=normal length=18 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=11 bufferRow=11
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────╮"
|
||||
style 0-43 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 43-43 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 43-43 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 43-43 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────╯"
|
||||
style 0-43 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Context · compact "
|
||||
style 1-17 dim
|
||||
7| " Compacted summary: the prior command "
|
||||
style 1-43 fg=bright-black
|
||||
8| " completed and its details were retired "
|
||||
style 1-43 fg=bright-black
|
||||
9| " from the active surface. "
|
||||
style 1-24 fg=bright-black
|
||||
10| "────────────────────────────────────────────"
|
||||
style 0-43 dim
|
||||
11| " "
|
||||
style 1-1 inverse
|
||||
12| "────────────────────────────────────────────"
|
||||
style 0-43 dim
|
||||
13| "/workspace/project ↑0 ↓0 idle reasoning:o"
|
||||
style 0-24 dim
|
||||
style 27-43 dim
|
||||
14-17| <blank>
|
||||
@@ -0,0 +1,37 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=9 bufferRow=9
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-103 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 103-103 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 103-103 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 103-103 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-103 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Context · compact "
|
||||
style 1-17 dim
|
||||
7| " Compacted summary: the prior command completed and its details were retired from the active surface. "
|
||||
style 1-100 fg=bright-black
|
||||
8| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-103 dim
|
||||
9| " "
|
||||
style 1-1 inverse
|
||||
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-103 dim
|
||||
11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 71-103 dim
|
||||
12-29| <blank>
|
||||
@@ -0,0 +1,67 @@
|
||||
terminal 80x24 buffer=normal length=25 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=21 bufferRow=22
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-79 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 79-79 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 79-79 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 79-79 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-79 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Old prompt with a long line that exercises wrapping before compaction. "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| "▌ "
|
||||
style 0-0 fg=green
|
||||
12| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
13| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
14| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
15| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
16| "▌ 4016 tests passed "
|
||||
style 0-0 fg=green
|
||||
17| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
18| "▌ coverage complete "
|
||||
style 0-0 fg=green
|
||||
19| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| "────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-79 dim
|
||||
22| " "
|
||||
style 1-1 inverse
|
||||
23| "────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-79 dim
|
||||
24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 47-79 dim
|
||||
106
packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt
Normal file
106
packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt
Normal file
@@ -0,0 +1,106 @@
|
||||
terminal 100x34 buffer=normal length=40 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
cursor hidden column=100 viewportRow=33 bufferRow=39
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-61 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| " Reasoning "
|
||||
style 1-9 fg=bright-black italic
|
||||
12| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-62 fg=bright-black italic
|
||||
13| <blank>
|
||||
14| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
15| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
16| <blank>
|
||||
17| "▌ "
|
||||
style 0-0 fg=green
|
||||
18| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-61 bold
|
||||
19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=green
|
||||
style 2-65 fg=bright-black
|
||||
20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ "
|
||||
style 0-0 fg=green
|
||||
style 2-13 dim
|
||||
style 14-85 fg=bright-blue
|
||||
21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ "
|
||||
style 0-0 fg=green
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-76 bold
|
||||
style 85-85 fg=bright-blue
|
||||
22| "▌ [signal SIG\\│ │ "
|
||||
style 0-0 fg=green
|
||||
style 2-13 fg=red
|
||||
style 14-14 fg=bright-blue
|
||||
style 85-85 fg=bright-blue
|
||||
23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ "
|
||||
style 0-0 fg=green
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-16 fg=bright-blue inverse
|
||||
style 17-17 inverse
|
||||
style 18-18 fg=bright-blue inverse
|
||||
style 19-78 inverse
|
||||
style 79-83 fg=bright-black inverse
|
||||
style 85-85 fg=bright-blue
|
||||
24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ "
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-65 dim
|
||||
style 85-85 fg=bright-blue
|
||||
25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ "
|
||||
style 1-13 dim
|
||||
style 14-85 fg=bright-blue
|
||||
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-60 fg=bright-black
|
||||
27| <blank>
|
||||
28| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-75 fg=yellow
|
||||
29| <blank>
|
||||
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-63 fg=red
|
||||
31| <blank>
|
||||
32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-63 fg=red
|
||||
33| <blank>
|
||||
34| "Plan"
|
||||
style 0-3 fg=bright-blue bold
|
||||
35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 2-2 fg=yellow
|
||||
36| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
37| " "
|
||||
style 1-1 inverse
|
||||
38| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
499
packages/ui/tui/tests/tui.snapshot.ts
Normal file
499
packages/ui/tui/tests/tui.snapshot.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import 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'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
createTuiTestHarness,
|
||||
disposeTuiTestHarness,
|
||||
type TuiHarness,
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts'
|
||||
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
const CHECKPOINTS = [
|
||||
'conversation-streaming',
|
||||
'code-mode-pending',
|
||||
'dynamic-workflow-pending',
|
||||
'cordis-tools-pending',
|
||||
'advanced-cards-collapsed',
|
||||
'advanced-cards-expanded',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-validation',
|
||||
'surface-before-compaction',
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'errors-and-help',
|
||||
'disposed-terminal',
|
||||
] as const
|
||||
|
||||
type Checkpoint = typeof CHECKPOINTS[number]
|
||||
type SnapshotHarness = TuiHarness<HeadlessTerminal, (code: number) => void>
|
||||
|
||||
const observedCheckpoints = new Set<Checkpoint>()
|
||||
|
||||
async function checkpoint(
|
||||
name: Checkpoint,
|
||||
terminal: HeadlessTerminal,
|
||||
options: TerminalSnapshotOptions = {},
|
||||
): Promise<void> {
|
||||
observedCheckpoints.add(name)
|
||||
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
|
||||
const snapshot = await terminal.snapshot(options)
|
||||
const path = join(SNAPSHOTS_DIR, `${name}.golden.txt`)
|
||||
if (REFRESHING) {
|
||||
await mkdir(SNAPSHOTS_DIR, { recursive: true })
|
||||
await writeFile(path, snapshot)
|
||||
}
|
||||
await expect(snapshot).toMatchFileSnapshot(path)
|
||||
}
|
||||
|
||||
async function setupSnapshot(
|
||||
options: TuiHarnessOptions = {},
|
||||
size: { columns?: number; rows?: number } = {},
|
||||
): Promise<SnapshotHarness> {
|
||||
const terminal = new HeadlessTerminal(size.columns ?? 96, size.rows ?? 36)
|
||||
const before = terminal.frames
|
||||
const result = await createTuiTestHarness(terminal, () => {}, {
|
||||
...options,
|
||||
cwd: options.cwd === undefined ? '/workspace/project' : options.cwd,
|
||||
config: Object.assign({
|
||||
welcome: 'Snapshot agent ready.',
|
||||
color: true,
|
||||
title: 'DSH snapshot',
|
||||
}, options.config),
|
||||
})
|
||||
await terminal.waitForFrame(before)
|
||||
return result
|
||||
}
|
||||
|
||||
async function renderAfter(harness: SnapshotHarness, action: () => void): Promise<void> {
|
||||
const before = harness.terminal.frames
|
||||
action()
|
||||
await harness.terminal.waitForFrame(before)
|
||||
}
|
||||
|
||||
async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
|
||||
await disposeTuiTestHarness(harness)
|
||||
await harness.terminal.dispose()
|
||||
}
|
||||
|
||||
async function configureAdvancedTools(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
ctx.provide('workflows', {} as never)
|
||||
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
|
||||
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
|
||||
}
|
||||
|
||||
interface ToolCallFixture {
|
||||
id: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
|
||||
function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): void {
|
||||
appendAssistant(session, calls.map(call => ({
|
||||
type: 'tool-call',
|
||||
id: CallId(call.id),
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
})))
|
||||
for (const call of calls) {
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId(call.id),
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function appendToolResult(
|
||||
session: Session,
|
||||
id: string,
|
||||
content: ContentBlock[],
|
||||
options: { isError?: boolean; meta?: unknown } = {},
|
||||
): void {
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId(id),
|
||||
content,
|
||||
isError: options.isError ?? false,
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function visualTool(
|
||||
name: string,
|
||||
call: NonNullable<ToolDefinition['presentCall']>,
|
||||
result?: NonNullable<ToolDefinition['presentResult']>,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `${name} snapshot fixture`,
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([]),
|
||||
presentCall: call,
|
||||
...result === undefined ? {} : { presentResult: result },
|
||||
}
|
||||
}
|
||||
|
||||
const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
|
||||
bash: visualTool(
|
||||
'bash',
|
||||
() => ({ card: 'terminal', title: 'pnpm run test:coverage', description: 'Run the coverage gate', cwd: '/workspace/project' }),
|
||||
() => ({ card: 'terminal', output: 'packages/ui/tui 100%\n4016 tests passed\n1 test skipped\ncoverage complete', exitCode: 0 }),
|
||||
),
|
||||
edit: visualTool(
|
||||
'edit',
|
||||
() => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }),
|
||||
(): ToolResultView => ({
|
||||
card: 'diff',
|
||||
diffs: [
|
||||
{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' },
|
||||
{ path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
subagent: visualTool('subagent', args => ({
|
||||
card: 'generic',
|
||||
title: 'Delegate renderer audit',
|
||||
rawInput: (args as { prompt: string }).prompt,
|
||||
})),
|
||||
task_output: visualTool('task_output', args => ({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: `Read output from background task ${(args as { task_id: string }).task_id}`,
|
||||
rawInput: (args as { task_id: string }).task_id,
|
||||
})),
|
||||
skill: visualTool('skill', args => ({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: `Load skill ${(args as { name: string }).name}`,
|
||||
rawInput: (args as { name: string }).name,
|
||||
})),
|
||||
}
|
||||
|
||||
const CONTROL_PROBE = '\u001b]2;snapshot-controlled\u0007\t\u007f\u009b31m'
|
||||
const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7f\x9b31m`
|
||||
|
||||
describe('TUI terminal-state snapshots', () => {
|
||||
it('pins an in-flight reasoning and Markdown stream', async () => {
|
||||
const harness = await setupSnapshot()
|
||||
await renderAfter(harness, () => {
|
||||
appendUser(harness.session, 'Show the live update.')
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
|
||||
})
|
||||
})
|
||||
await checkpoint('conversation-streaming', harness.terminal)
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins Code Mode run_code with its production presenter', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
id: 'code-1',
|
||||
name: 'run_code',
|
||||
arguments: {
|
||||
code: "const first = await tools.bash({ command: 'echo CODE_ONE' })\nconst second = await tools.bash({ command: 'echo CODE_TWO' })\nconsole.log(first, second)\nreturn `${first}+${second}`",
|
||||
},
|
||||
}
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) })
|
||||
await checkpoint('code-mode-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins a dynamic workflow with phases, parallel agents, and structured output', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
id: 'workflow-1',
|
||||
name: 'workflow',
|
||||
arguments: {
|
||||
meta: {
|
||||
name: 'tui-matrix',
|
||||
description: 'Audit terminal states from independent angles',
|
||||
phases: [
|
||||
{ title: 'Inspect', detail: 'Map renderer branches' },
|
||||
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
],
|
||||
},
|
||||
args: { packages: ['ui/tui', 'workflow/tool-workflow'] },
|
||||
script: "phase('Inspect')\nconst reports = await parallel([\n () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }),\n () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }),\n])\nphase('Verify')\nreturn { reports, verdict: 'covered' }",
|
||||
},
|
||||
}
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) })
|
||||
await checkpoint('dynamic-workflow-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const calls = [
|
||||
{ id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } },
|
||||
{
|
||||
id: 'cordis-2',
|
||||
name: 'cordis_mount',
|
||||
arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" },
|
||||
},
|
||||
{ id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } },
|
||||
]
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, calls) })
|
||||
await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
const calls = [
|
||||
{ id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } },
|
||||
{ id: 'advanced-2', name: 'edit', arguments: { file_path: 'src/view.ts' } },
|
||||
{ id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } },
|
||||
{ id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } },
|
||||
{ id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } },
|
||||
]
|
||||
await renderAfter(harness, () => {
|
||||
appendToolCalls(harness.session, calls)
|
||||
appendToolResult(harness.session, 'advanced-1', [{ type: 'text', text: 'raw process output' }])
|
||||
appendToolResult(harness.session, 'advanced-2', [{ type: 'text', text: 'edit complete' }])
|
||||
appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }])
|
||||
appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }])
|
||||
appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }])
|
||||
})
|
||||
await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await checkpoint('advanced-cards-expanded', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
|
||||
const tools = {
|
||||
unsafe: visualTool(
|
||||
'unsafe',
|
||||
() => ({
|
||||
card: 'terminal',
|
||||
title: `Unsafe title ${CONTROL_PROBE}`,
|
||||
description: `Unsafe description ${CONTROL_PROBE}`,
|
||||
cwd: `/unsafe/${CONTROL_PROBE}`,
|
||||
}),
|
||||
() => ({
|
||||
card: 'terminal',
|
||||
output: `Unsafe output ${CONTROL_PROBE}`,
|
||||
signal: `SIG${CONTROL_PROBE}`,
|
||||
}),
|
||||
),
|
||||
}
|
||||
const harness = await setupSnapshot({
|
||||
tools,
|
||||
config: {
|
||||
welcome: `Unsafe welcome ${CONTROL_PROBE}`,
|
||||
title: `Unsafe terminal title ${CONTROL_PROBE}`,
|
||||
},
|
||||
beforeMount(session) {
|
||||
appendUser(session, `Unsafe user ${CONTROL_PROBE}`)
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: `Unsafe reasoning ${CONTROL_PROBE}` },
|
||||
{ type: 'text', text: `Unsafe assistant ${CONTROL_PROBE}` },
|
||||
])
|
||||
appendToolCalls(session, [{ id: 'unsafe-1', name: 'unsafe', arguments: { value: CONTROL_PROBE } }])
|
||||
appendToolResult(session, 'unsafe-1', [{ type: 'text', text: `Unsafe fallback ${CONTROL_PROBE}` }])
|
||||
session.append('todo/write', {
|
||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
|
||||
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('prompt/blocked', {
|
||||
content: [{ type: 'text', text: 'blocked' }],
|
||||
source: { kind: 'user' },
|
||||
reason: `Unsafe policy ${CONTROL_PROBE}`,
|
||||
})
|
||||
session.append('turn/end', {
|
||||
turn: 7,
|
||||
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
|
||||
})
|
||||
},
|
||||
}, { columns: 100, rows: 34 })
|
||||
expect(harness.terminal.title).toContain(DISPLAYED_CONTROL_PROBE)
|
||||
expect(harness.terminal.title).not.toContain('\u001b')
|
||||
expect(harness.terminal.title).not.toContain('\u009b')
|
||||
|
||||
const controller = new AbortController()
|
||||
const beforeQuestion = harness.terminal.frames
|
||||
const answer = harness.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'unsafe-question',
|
||||
header: `Unsafe header ${CONTROL_PROBE}`,
|
||||
question: `Unsafe question ${CONTROL_PROBE}`,
|
||||
options: [{ label: `Unsafe option ${CONTROL_PROBE}`, description: `Unsafe detail ${CONTROL_PROBE}` }],
|
||||
}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await harness.terminal.waitForFrame(beforeQuestion)
|
||||
await renderAfter(harness, () => {
|
||||
harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
|
||||
})
|
||||
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
|
||||
|
||||
controller.abort()
|
||||
await rejected
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins a constrained multi-select question and its validation state', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
config: {
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 48,
|
||||
questionDialogMaxHeight: 16,
|
||||
},
|
||||
}, { columns: 56, rows: 20 })
|
||||
const controller = new AbortController()
|
||||
const beforeQuestion = harness.terminal.frames
|
||||
const answer = harness.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'coverage',
|
||||
header: 'Coverage',
|
||||
question: 'Which advanced TUI states belong in the required matrix?',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Code Mode', description: 'run_code programs and captured output' },
|
||||
{ label: 'Workflows', description: 'phases and parallel agents' },
|
||||
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
|
||||
{ label: 'Compaction', description: 'surface replacement and reflow' },
|
||||
],
|
||||
}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await harness.terminal.waitForFrame(beforeQuestion)
|
||||
await checkpoint('question-dialog', harness.terminal)
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.send('\r') })
|
||||
await checkpoint('question-dialog-validation', harness.terminal)
|
||||
controller.abort()
|
||||
await rejected
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
|
||||
let replacementStart = 0
|
||||
let replacementEnd = 0
|
||||
let replacementSources: number[] = []
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
const user = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
replacementStart = user.seq
|
||||
replacementEnd = result.seq
|
||||
replacementSources = [user.seq, assistant.seq, result.seq]
|
||||
},
|
||||
}, { columns: 80, rows: 24 })
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('context/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' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
|
||||
sourceEventSeqs: replacementSources,
|
||||
})
|
||||
harness.terminal.resize(44, 18)
|
||||
})
|
||||
await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.resize(104, 30) })
|
||||
await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => {
|
||||
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/help')
|
||||
harness.terminal.send('\r')
|
||||
harness.terminal.send('/unknown-advanced-command')
|
||||
harness.terminal.send('\r')
|
||||
harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output'))
|
||||
harness.session.append('turn/end', {
|
||||
turn: 3,
|
||||
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
|
||||
})
|
||||
harness.session.append('turn/end', {
|
||||
turn: 4,
|
||||
reason: { kind: 'interrupted' },
|
||||
})
|
||||
})
|
||||
await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await harness.controller.dispose()
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true })
|
||||
await harness.ctx.fiber.dispose()
|
||||
await harness.terminal.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
expect([...observedCheckpoints].sort()).toEqual([...CHECKPOINTS].sort())
|
||||
const files = (await readdir(SNAPSHOTS_DIR))
|
||||
.filter(file => file.endsWith('.golden.txt'))
|
||||
.sort()
|
||||
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.golden.txt`).sort())
|
||||
})
|
||||
939
packages/ui/tui/tests/tui.spec.ts
Normal file
939
packages/ui/tui/tests/tui.spec.ts
Normal file
@@ -0,0 +1,939 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
createTuiChat,
|
||||
mountTui,
|
||||
resolveTuiConfig,
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
createTuiTestHarness,
|
||||
disposeTuiTestHarness,
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
|
||||
class FakeTerminal implements Terminal {
|
||||
columns = 88
|
||||
rows = 32
|
||||
kittyProtocolActive = false
|
||||
output = ''
|
||||
title = ''
|
||||
progress: boolean[] = []
|
||||
started = 0
|
||||
stopped = 0
|
||||
drainInput = vi.fn(() => Promise.resolve())
|
||||
private onInput: (data: string) => void = () => {}
|
||||
private onResize: () => void = () => {}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.started += 1
|
||||
this.onInput = onInput
|
||||
this.onResize = onResize
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped += 1
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.output += data
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
this.output += `[move:${lines}]`
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.output += '[hide]'
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.output += '[show]'
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.output += '[clear-line]'
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.output += '[clear-rest]'
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.output += '[clear-screen]'
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
this.progress.push(active)
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.onInput(data)
|
||||
}
|
||||
|
||||
resize(columns: number, rows = this.rows): void {
|
||||
this.columns = columns
|
||||
this.rows = rows
|
||||
this.onResize()
|
||||
}
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
async function setup(options: TuiHarnessOptions = {}) {
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
const result = await createTuiTestHarness(terminal, exit, {
|
||||
...options,
|
||||
cwd: options.cwd === undefined ? process.cwd() : options.cwd,
|
||||
})
|
||||
await tick()
|
||||
return result
|
||||
}
|
||||
|
||||
async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
|
||||
await disposeTuiTestHarness(setupResult)
|
||||
}
|
||||
|
||||
describe('TUI config', () => {
|
||||
it('defaults every direct-call TUI option', () => {
|
||||
expect(resolveTuiConfig(undefined)).toEqual({
|
||||
showReasoning: true,
|
||||
maxToolOutputLines: 12,
|
||||
maxQuestionOptions: 8,
|
||||
questionDialogWidth: 72,
|
||||
questionDialogMaxHeight: 20,
|
||||
showHardwareCursor: false,
|
||||
color: true,
|
||||
title: 'DeepSeek Harness',
|
||||
})
|
||||
expect(resolveTuiConfig({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
})).toEqual({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'restored prompt')
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: 'restored thought' },
|
||||
{ type: 'text', text: '**restored answer**' },
|
||||
], { inputTokens: 1_250, outputTokens: 42 })
|
||||
session.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'read code', status: 'completed' },
|
||||
{ content: 'write tests', status: 'in_progress' },
|
||||
{ content: 'ship', status: 'pending' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.started).toBe(1)
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
expect(result.terminal.output).toContain('DEEPSEEK')
|
||||
expect(result.terminal.output).toContain('Coding agent ready.')
|
||||
expect(result.terminal.output).toContain('restored prompt')
|
||||
expect(result.terminal.output).toContain('restored thought')
|
||||
expect(result.terminal.output).toContain('restored answer')
|
||||
expect(result.terminal.output).toContain('write tests')
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.ctx.emit('agent/status', result.agent, 'running')
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
|
||||
appendAssistant(result.session, [])
|
||||
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
|
||||
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
|
||||
result.session.append('step/start', { turn: 11, step: 0 })
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('live thought')
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Working')
|
||||
expect(result.terminal.output).toContain('Steering')
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Prompt blocked')
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
|
||||
})
|
||||
result.terminal.send('/clear')
|
||||
result.terminal.send('\r')
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('answer after clear')
|
||||
|
||||
result.agent.status = 'idle'
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
await tick()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
expect(result.terminal.stopped).toBe(1)
|
||||
expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
|
||||
})
|
||||
|
||||
it('renders the ANSI palette and every markdown/content style', async () => {
|
||||
const result = await setup({
|
||||
config: { color: true },
|
||||
beforeMount(session) {
|
||||
session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
|
||||
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
|
||||
{ type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
|
||||
{ type: 'future-block' } as never,
|
||||
{} as never,
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: 'styled reasoning' },
|
||||
{ type: 'text', text: 'styled answer' },
|
||||
], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'done', status: 'completed' },
|
||||
{ content: 'active', status: 'in_progress' },
|
||||
{ content: 'later', status: 'pending' },
|
||||
] })
|
||||
},
|
||||
})
|
||||
result.terminal.send('/')
|
||||
await tick()
|
||||
result.terminal.send('zz')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('\x1b[')
|
||||
expect(result.terminal.output).toContain('Heading')
|
||||
expect(result.terminal.output).toContain('nested_tool({})')
|
||||
expect(result.terminal.output).toContain('nested result')
|
||||
expect(result.terminal.output).toContain('[future-block]')
|
||||
expect(result.terminal.output).toContain('[content]')
|
||||
expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'first prompt')
|
||||
appendUser(session, 'second prompt')
|
||||
session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.output).not.toContain('stale partial response')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('formats large token totals and cwd variants', async () => {
|
||||
const home = homedir()
|
||||
const homeResult = await setup({
|
||||
cwd: home,
|
||||
beforeMount(session) {
|
||||
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
|
||||
},
|
||||
})
|
||||
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
|
||||
await dispose(homeResult)
|
||||
|
||||
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
|
||||
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
||||
await dispose(childResult)
|
||||
|
||||
const unsetResult = await setup({ cwd: null })
|
||||
expect(unsetResult.terminal.output).toContain('cwd unset')
|
||||
await dispose(unsetResult)
|
||||
|
||||
const outsideResult = await setup({ cwd: '/opt' })
|
||||
expect(outsideResult.terminal.output).toContain('/opt')
|
||||
await dispose(outsideResult)
|
||||
})
|
||||
|
||||
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
|
||||
const result = await setup()
|
||||
|
||||
result.terminal.send('do the work')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]])
|
||||
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send('steer it')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
|
||||
|
||||
result.terminal.send('\x1b')
|
||||
result.terminal.send('\x04')
|
||||
result.terminal.send('\x03')
|
||||
result.terminal.send('\x12')
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('/cancel')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.cancelled).toContain('cancelled from terminal')
|
||||
|
||||
result.agent.status = 'idle'
|
||||
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
}
|
||||
for (const command of ['/clear', '/cancel', '/wat']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
}
|
||||
await tick()
|
||||
result.terminal.send('draft')
|
||||
result.terminal.send('\x03')
|
||||
result.terminal.send('\x04')
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Keyboard shortcuts')
|
||||
expect(result.terminal.output).toContain('Reasoning blocks')
|
||||
expect(result.terminal.output).toContain('Tool cards')
|
||||
expect(result.terminal.output).toContain('already idle')
|
||||
expect(result.terminal.output).toContain('Unknown command')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
await result.controller.dispose()
|
||||
await result.ctx.fiber.dispose()
|
||||
|
||||
const ctrlCExit = await setup()
|
||||
ctrlCExit.terminal.send('\x03')
|
||||
await tick()
|
||||
expect(ctrlCExit.exit).toHaveBeenCalledWith(0)
|
||||
await ctrlCExit.controller.dispose()
|
||||
await ctrlCExit.ctx.fiber.dispose()
|
||||
|
||||
const disposedAgent = await setup()
|
||||
disposedAgent.agent.status = 'disposed'
|
||||
disposedAgent.terminal.send('late input')
|
||||
disposedAgent.terminal.send('\r')
|
||||
await tick()
|
||||
expect(disposedAgent.terminal.output).toContain('is disposed')
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('cancels before /exit while running and handles agent errors/disposal', async () => {
|
||||
const result = await setup({ status: 'running' })
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.cancelled).toContain('terminal exit requested')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
|
||||
const events = await setup()
|
||||
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
|
||||
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
|
||||
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
||||
events.ctx.emit('agent/status', unrelatedAgent, 'running')
|
||||
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
|
||||
events.ctx.emit('agent/disposed', unrelatedAgent)
|
||||
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
|
||||
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } })
|
||||
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
|
||||
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
|
||||
events.ctx.emit('agent/disposed', events.agent)
|
||||
await tick()
|
||||
expect(events.terminal.output).toContain('live failure')
|
||||
expect(events.terminal.output).toContain('durable failure')
|
||||
expect(events.terminal.output).toContain('stopped')
|
||||
expect(events.terminal.output).toContain('output-token limit')
|
||||
expect(events.terminal.output).toContain('Turn rejected')
|
||||
expect(events.terminal.output).toContain('previous process ended')
|
||||
expect(events.terminal.output).toContain('was disposed')
|
||||
await dispose(events)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool cards and surface replay', () => {
|
||||
const tools: Record<string, ToolDefinition> = {
|
||||
bash: {
|
||||
name: 'bash', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
|
||||
},
|
||||
signal: {
|
||||
name: 'signal', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
|
||||
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
|
||||
},
|
||||
edit: {
|
||||
name: 'edit', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({
|
||||
card: 'diff',
|
||||
title: 'Edit files',
|
||||
diffs: [
|
||||
{ path: 'a.txt', oldText: 'old', newText: 'new' },
|
||||
{ path: 'b.txt', oldText: 'before', newText: 'after' },
|
||||
],
|
||||
}),
|
||||
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
|
||||
},
|
||||
generic: {
|
||||
name: 'generic', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
||||
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
|
||||
},
|
||||
throwing: {
|
||||
name: 'throwing', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => { throw new Error('call presenter boom') },
|
||||
presentResult: () => { throw new Error('result presenter boom') },
|
||||
},
|
||||
rawTerminal: {
|
||||
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
|
||||
},
|
||||
undefinedViews: {
|
||||
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => undefined,
|
||||
presentResult: () => undefined,
|
||||
},
|
||||
empty: {
|
||||
name: 'empty', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
|
||||
},
|
||||
terminalResult: {
|
||||
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||||
},
|
||||
symbolic: {
|
||||
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||||
},
|
||||
}
|
||||
|
||||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
|
||||
const calls = [
|
||||
['c1', 'bash', '{"command":"printf hello"}'],
|
||||
['c2', 'signal', '{}'],
|
||||
['c3', 'edit', '{}'],
|
||||
['c4', 'generic', '{}'],
|
||||
['c5', 'throwing', '{}'],
|
||||
['c6', 'unknown', 'not-json'],
|
||||
['c7', 'rawTerminal', '{"value":"raw"}'],
|
||||
['c8', 'undefinedViews', '{"value":8}'],
|
||||
['c10', 'empty', '{}'],
|
||||
['c11', 'terminalResult', '{}'],
|
||||
['c12', 'symbolic', '{}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
...calls.map(([id, name, args]) => ({
|
||||
type: 'tool-call' as const, id: id as never, name, arguments: args,
|
||||
})),
|
||||
])
|
||||
for (const [id, name, args] of calls) {
|
||||
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
|
||||
}
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('$ raw command')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('call presenter boom')
|
||||
expect(result.terminal.output).toContain('Symbol(input)')
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
|
||||
meta: { value: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c7' as never,
|
||||
content: [
|
||||
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
|
||||
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
|
||||
{ type: 'future-result' } as never,
|
||||
],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
|
||||
const output = result.terminal.output
|
||||
expect(output).toContain('Run command')
|
||||
expect(output).toContain('printf hello')
|
||||
expect(output).toContain('more lines')
|
||||
expect(output).toContain('SIGTERM')
|
||||
expect(output).toContain('Edit files')
|
||||
expect(output).toContain('Inspected')
|
||||
expect(output).toContain('result text')
|
||||
expect(output).toContain('Presenter failed')
|
||||
expect(output).toContain('not-json')
|
||||
expect(output).toContain('nested output')
|
||||
expect(output).toContain('[future-result]')
|
||||
expect(output).toContain('undefined presenter output')
|
||||
expect(output).toContain('Empty card')
|
||||
expect(output).toContain('converted terminal')
|
||||
expect(output).toContain('orphan result')
|
||||
|
||||
result.terminal.send('/redraw')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('world')
|
||||
expect(result.terminal.output).toContain('+ created')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendUser(result.session, 'old prompt')
|
||||
const assistant = result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/call', {
|
||||
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
|
||||
})
|
||||
const toolResult = result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const start = result.session.surface.nodes[0] as number
|
||||
result.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'summary replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end: toolResult.seq },
|
||||
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
|
||||
})
|
||||
await tick()
|
||||
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(lastFullRender).toContain('summary replacement')
|
||||
expect(lastFullRender).not.toContain('old output')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI user-interaction dialogs', () => {
|
||||
it('answers single-select, multi-select, custom, and optionless questions', async () => {
|
||||
const result = await setup({ config: { maxQuestionOptions: 1 } })
|
||||
|
||||
const single = result.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode', header: 'Mode', question: 'Choose a mode',
|
||||
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
|
||||
}],
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Choose a mode')
|
||||
expect(result.terminal.output).toContain('1/2')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\r')
|
||||
await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] })
|
||||
|
||||
const multi = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })
|
||||
|
||||
const custom = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('c')
|
||||
result.terminal.send('my choice')
|
||||
result.terminal.send('\r')
|
||||
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
|
||||
|
||||
const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] })
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Enter an answer before submitting')
|
||||
result.terminal.send('ship it')
|
||||
result.terminal.send('\r')
|
||||
await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('handles option wrapping, deselection errors, and returning from custom input', async () => {
|
||||
const result = await setup({ config: { color: true } })
|
||||
const single = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }],
|
||||
})
|
||||
const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Two')
|
||||
result.terminal.send('\x03')
|
||||
await singleRejected
|
||||
|
||||
const answer = result.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'options',
|
||||
question: 'Exercise options',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
|
||||
}],
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send(' ')
|
||||
await tick()
|
||||
result.terminal.send('x')
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Select at least one option')
|
||||
result.terminal.send('c')
|
||||
await tick()
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Space toggle')
|
||||
result.terminal.send('\x03')
|
||||
await rejected
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('asks batches in order and rejects cancelled or aborted work', async () => {
|
||||
const result = await setup()
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
await expect(result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }],
|
||||
signal: preAborted.signal,
|
||||
})).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
const batch = result.ctx.userInteraction.ask({
|
||||
questions: [
|
||||
{ id: 'first', question: 'First?', options: [{ label: 'Yes' }] },
|
||||
{ id: 'second', question: 'Second?' },
|
||||
],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Second?')
|
||||
result.terminal.send('done')
|
||||
result.terminal.send('\r')
|
||||
await expect(batch).resolves.toEqual({ answers: [
|
||||
{ id: 'first', selected: ['Yes'] },
|
||||
{ id: 'second', selected: [], custom: 'done' },
|
||||
] })
|
||||
|
||||
const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] })
|
||||
const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
result.terminal.send('\x1b')
|
||||
await cancelledExpectation
|
||||
|
||||
const controller = new AbortController()
|
||||
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal })
|
||||
const queuedController = new AbortController()
|
||||
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal })
|
||||
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
queuedController.abort()
|
||||
controller.abort()
|
||||
await activeExpectation
|
||||
await queuedExpectation
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rejects active and queued dialogs on disposal', async () => {
|
||||
const result = await setup()
|
||||
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
|
||||
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
|
||||
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
await result.controller.dispose()
|
||||
await activeExpectation
|
||||
await queuedExpectation
|
||||
await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal mounting', () => {
|
||||
it('starts immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
|
||||
await tick()
|
||||
expect(terminal.started).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for its configured agent before starting the TUI', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() })
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
expect(terminal.started).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit })
|
||||
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed'))
|
||||
expect(terminal.output).toBe('')
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
||||
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('renders an uncoercible startup failure without escaping the display boundary', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
|
||||
mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit })
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), {
|
||||
toString(): string { throw new Error('coercion failed') },
|
||||
})
|
||||
|
||||
expect(terminal.started).toBe(0)
|
||||
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable thrown value>\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
|
||||
.toThrow('terminal startup failed')
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.output).not.toContain('must not render')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('throws when createTuiChat is called without the configured agent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
36
packages/ui/tui/tsconfig.json
Normal file
36
packages/ui/tui/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
Reference in New Issue
Block a user