Merge PR #500 into codex/tool-json-schema-dsl

# Conflicts:
#	docs/cordis-catalog/services.md
#	scripts/type-equiv.manifest.json
This commit is contained in:
Tianyi Cui
2026-07-22 16:57:45 +08:00
482 changed files with 37386 additions and 1120 deletions
+2 -2
View File
@@ -63,11 +63,11 @@ A log-only `session/title` event maps to ACP `session_info_update` with `title`
## Tool-call presentation
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
## Terminal card (capability-gated)
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once
+35 -18
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { join as pathJoin, resolve as pathResolve } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -50,6 +51,16 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
return { type, seq: 0, time: 0, data } as SessionEvent
}
/** ACP path fields are filesystem paths; expectations use the host separator. */
function nativePath(...segments: string[]): string {
return pathJoin(...segments)
}
/** Resolve root-relative fixtures the same way the bridge does on this host. */
function nativeAbsolute(...segments: string[]): string {
return pathResolve(...segments)
}
describe('streamSessionEventUpdate', () => {
it('maps a title event to session_info_update with the event timestamp', () => {
expect(updatesFor({
@@ -576,10 +587,10 @@ describe('terminal-card mapping (capability-gated)', () => {
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent)
// Relative workdir resolved against the session cwd — the card header matches
// where execution actually ran (tool-bash resolves the same way).
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir'))
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
@@ -792,10 +803,12 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// paths remain absolute so the editor can open the real file.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' })
const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const out: SessionNotification['update'][] = []
const rendering = { enabled: false, cwd: '/work/proj' }
const rendering = { enabled: false, cwd: workspace }
for (const event of [
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
@@ -804,8 +817,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',
status: 'completed',
title: 'Edit src/b.ts',
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
title: `Edit ${nativePath('src', 'b.ts')}`,
content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
})
await ctx.fiber.dispose()
})
@@ -856,21 +869,25 @@ describe('relative-path display titles (bridge relativizes the title against the
it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 })
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'a.ts')
const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 })
expect(update).toMatchObject({
title: 'Read src/a.ts (from line 5)',
locations: [{ path: '/work/proj/src/a.ts', line: 5 }],
title: `Read ${nativePath('src', 'a.ts')} (from line 5)`,
locations: [{ path: file, line: 5 }],
})
await ctx.fiber.dispose()
})
it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' })
const workspace = nativeAbsolute('/work/proj')
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' })
expect(update).toMatchObject({
title: 'Edit src/b.ts',
locations: [{ path: '/work/proj/src/b.ts' }],
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }],
title: `Edit ${nativePath('src', 'b.ts')}`,
locations: [{ path: file }],
content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }],
})
await ctx.fiber.dispose()
})
@@ -887,8 +904,8 @@ describe('relative-path display titles (bridge relativizes the title against the
// with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
// matching targets under `cwd + sep` in the reference adapter.
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') })
expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`)
await ctx.fiber.dispose()
})
@@ -901,8 +918,8 @@ describe('relative-path display titles (bridge relativizes the title against the
it('a relative path is passed through unchanged (already display-friendly)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' })
expect((update as { title: string }).title).toBe('Read src/a.ts')
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') })
expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`)
await ctx.fiber.dispose()
})
})
+32 -35
View File
@@ -5,8 +5,8 @@
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
export const name = 'commands'
@@ -68,6 +68,26 @@ interface RegisteredCommand {
readonly descriptor: CommandDescriptor
}
/** All command registrations owned by one global or scoped layer. */
class CommandLayer implements ScopeLayer {
readonly commands: NamedEntries<RegisteredCommand>
/**
* Create one command layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.commands = new NamedEntries(name => new Error(scope === undefined
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no command registrations. */
isEmpty(): boolean {
return this.commands.isEmpty()
}
}
declare module 'cordis' {
interface Context {
commands: CommandService
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* globals for that agent.
*/
export class CommandService extends Service {
private readonly global = new Map<string, RegisteredCommand>()
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { this.notifyChange() },
)
constructor(ctx: Context) {
super(ctx, 'commands')
@@ -218,25 +240,12 @@ export class CommandService extends Service {
* @returns the exact effect disposer that unregisters this definition.
*/
register(definition: CommandDefinition): () => void {
const scope = scopeOf(this.ctx)
const registered = normalizeDefinition(definition)
const dispose = this.ctx.effect(function* (this: CommandService) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(registered.definition.name)) {
throw new Error(scope === undefined
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
: `command "${registered.definition.name}" is already registered in this scope`)
}
layer.set(registered.definition.name, registered)
yield () => {
layer.delete(registered.definition.name)
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.notifyChange()
}
this.notifyChange()
}.bind(this), 'commands.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
return dispose
return this.layers.effect(
this.ctx,
layer => layer.commands.insert(registered.definition.name, registered),
{ label: 'commands.register()' },
)
}
/**
@@ -285,19 +294,7 @@ export class CommandService extends Service {
/** Resolve global definitions followed by exact scoped shadows. */
private view(agent: Agent): Map<string, RegisteredCommand> {
const visible = new Map(this.global)
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
return visible
}
/** Create the registration layer for one agent scope on demand. */
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
let layer = this.scoped.get(scope)
if (layer === undefined) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
return this.layers.merge(agent, layer => layer.commands)
}
/** Notify every registry observer without making UI refresh load-bearing. */
@@ -94,6 +94,19 @@ describe('CommandService', () => {
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
})
it('removes a registration when its contributing plugin fiber is disposed', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.commands.register(command('temporary'))
}, { inject: ['commands'] }))
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
await fiber.dispose()
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
})
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
+2
View File
@@ -10,6 +10,8 @@ This package owns interactive terminal presentation and input only. It injects `
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
+50 -7
View File
@@ -6,7 +6,7 @@
*/
import { homedir } from 'node:os'
import { relative, resolve, sep } from 'node:path'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import {
CombinedAutocompleteProvider,
Container,
@@ -30,6 +30,7 @@ import {
type OverlayHandle,
type SelectListTheme,
type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
@@ -169,6 +170,12 @@ export interface TuiRuntime {
terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
/**
* Override the footer's logical working-directory label without changing the session directory used by tools.
* @param cwd - Operational working directory from the session header.
* @returns Unescaped label; the TUI makes terminal controls visible.
*/
formatCwd?: (cwd: string | undefined) => string
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
now?(): number
}
@@ -237,17 +244,21 @@ function displayText(text: string): string {
* backgrounds alike; grouping uses foreground-only gutter bars and reverse
* video rather than fixed background fills.
*/
function createPalette(enabled: boolean): Palette {
function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
return {
accent: ansi('94', '39', enabled),
accent2: ansi('95', '39', enabled),
text: text => text,
muted: ansi('90', '39', enabled),
dim: ansi('2', '22', enabled),
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
// (bright black / gray) which renders as a readable muted tone on any scheme.
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
success: ansi('32', '39', enabled),
warning: ansi('33', '39', enabled),
error: ansi('31', '39', enabled),
code: ansi('36', '39', enabled),
// ANSI 36 (cyan) is difficult to read on a light background — use
// ANSI 34 (blue) which is legible on both light and dark schemes.
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
added: ansi('32', '39', enabled),
removed: ansi('31', '39', enabled),
bold: ansi('1', '22', enabled),
@@ -692,8 +703,10 @@ function formatCwd(cwd: string | undefined): string {
const home = homedir()
const rel = relative(resolve(home), resolve(cwd))
if (rel === '') return '~'
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`)
return displayText(cwd)
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
if (isAbsolute(rel)) return cwd
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
return cwd
}
interface SessionTokenTotals {
@@ -737,6 +750,7 @@ class FooterComponent implements Component {
private readonly toolsExpanded: () => boolean,
private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number },
private readonly cwdFormatter: TuiRuntime['formatCwd'],
private readonly currentModel: () => string | undefined,
private readonly contextPercent: () => number | undefined,
private readonly runningSeconds: () => number,
@@ -760,6 +774,9 @@ class FooterComponent implements Component {
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
const compactRight = `${context} ${modelState}`
const formattedCwd = displayText(
this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
)
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
const compact = truncateToWidth(compactRight, width, '')
return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
@@ -768,7 +785,7 @@ class FooterComponent implements Component {
const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight
const rightClipped = truncateToWidth(right, rightAvailable, '')
const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3)
const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '')
const cwd = truncateToWidth(formattedCwd, cwdAvailable, '')
const left = [cwd, counters].filter(Boolean).join(' ')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped)))
return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`]
@@ -1077,6 +1094,7 @@ export function createTuiChat(
() => toolsExpanded,
() => showReasoning,
() => tokens,
runtime.formatCwd,
() => target.current?.model,
() => contextWindow === undefined
? undefined
@@ -1504,6 +1522,30 @@ export function createTuiChat(
void shutdown(true)
}
/** Swap the palette and all derived themes for the given terminal color scheme. */
const applyColorScheme = (scheme: TerminalColorScheme): void => {
if (scheme === currentScheme) return
currentScheme = scheme
Object.assign(palette, createPalette(resolved.color, scheme))
Object.assign(mdTheme, markdownTheme(palette))
rebuildTranscript(false)
setStatus(agent.status)
requestRender()
}
let currentScheme: TerminalColorScheme = 'dark'
// Apply any color scheme the terminal reports. Registering before the query
// below means even a synchronous reply reaches `applyColorScheme`; in practice
// the startup query's reply is the only report, since dsh-tui leaves
// unsolicited color-scheme notifications disabled.
const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)
// Ask the terminal for its color scheme via device-status report; the reply,
// if any, arrives through the listener above. Most terminals do not respond,
// so we keep the dark-optimised palette. Swallow a query-write failure for the
// same reason.
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1714,6 +1756,7 @@ export function createTuiChat(
disposeStatus()
disposeError()
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
}
+8 -2
View File
@@ -12,7 +12,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config } from '../src/index.ts'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -28,6 +28,7 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
@@ -144,7 +145,12 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
}, options.config), {
terminal,
exit,
now: options.now ?? (() => 0),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
})
return { ctx, session, agent, terminal, exit, controller }
}
+81 -9
View File
@@ -1,5 +1,5 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
@@ -102,10 +102,10 @@ async function tick(): Promise<void> {
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,
})
// Let the harness default cwd ('/workspace') stand: a checkout-dependent
// process.cwd() longer than the 88-column fake terminal pushes the footer
// token counters off-screen and fails their assertions by location.
const result = await createTuiTestHarness(terminal, exit, options)
await tick()
return result
}
@@ -168,6 +168,10 @@ describe('TUI config', () => {
describe('pi-tui chat lifecycle and transcript', () => {
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
// A fixed short cwd keeps the footer's token counters inside the 88-column
// fake terminal regardless of where the checkout lives; cwd rendering has
// its own dedicated variants test below.
cwd: '/workspace',
beforeMount(session) {
session.append('session/title', {
title: 'Restored session title',
@@ -316,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ inputTokens: 500, outputTokens: 8 },
{ turn: 3, step: 1 },
)
await tick()
await vi.waitFor(() => {
expect(result.terminal.output).toContain('final live answer')
})
expect(result.terminal.output).toContain('◒ Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
@@ -324,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
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.progress).toContain(true)
result.session.append('assistant/chunk', {
@@ -413,6 +418,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('renders the ANSI palette and every markdown/content style', async () => {
const result = await setup({
cwd: '/workspace',
config: { color: true },
beforeMount(session) {
session.append('user/message', {
@@ -496,9 +502,21 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(unsetResult.terminal.output).toContain('cwd unset')
await dispose(unsetResult)
const homeParent = resolve(home, '..')
const parentResult = await setup({ cwd: homeParent })
expect(parentResult.terminal.output).toContain(homeParent)
await dispose(parentResult)
const outsideResult = await setup({ cwd: '/opt' })
expect(outsideResult.terminal.output).toContain('/opt')
await dispose(outsideResult)
const logicalResult = await setup({
cwd: '/w',
formatCwd: cwd => `logical:${cwd}\x1b`,
})
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
await dispose(logicalResult)
})
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
@@ -1174,8 +1192,9 @@ describe('TUI user-interaction dialogs', () => {
result.terminal.send('x')
result.terminal.send(' ')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select at least one option')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Select at least one option')
})
result.terminal.send('c')
await tick()
result.terminal.send('\x1b')
@@ -1399,4 +1418,57 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
await ctx.fiber.dispose()
})
it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
const result = await setup({ config: { color: true } })
// Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
// A report matching the current scheme is a no-op: no palette rebuild or
// re-render (ESC [?997;1n = dark, the startup default).
const beforeSameScheme = result.terminal.output.length
result.terminal.send('\x1b[?997;1n')
await tick()
expect(result.terminal.output.length).toBe(beforeSameScheme)
// Simulate the terminal responding with a light color scheme report
// (ESC [?997;2n = light, ESC [?997;1n = dark).
result.terminal.send('\x1b[?997;2n')
await tick()
await tick()
// After switching to light-optimised palette: palette.dim uses ANSI 90
// (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
// line. The cumulative output still contains the initial SGR 2 render,
// so we assert that a LATER write (appended after the scheme switch)
// uses ANSI 90 for the same header text.
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
// Switch back to dark scheme.
result.terminal.send('\x1b[?997;1n')
await tick()
await tick()
// After switching back, a new write uses SGR 2 for the header detail.
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await dispose(result)
})
it('keeps the dark palette when the terminal rejects the color-scheme query', async () => {
class QueryFailTerminal extends FakeTerminal {
override write(data: string): void {
// The device-status query is the only write that fails; the promise
// rejects and the swallowed `.catch` leaves the dark palette in place.
if (data === '\x1b[?996n') throw new Error('query write failed')
super.write(data)
}
}
const terminal = new QueryFailTerminal()
const result = await createTuiTestHarness(terminal, vi.fn(), {
config: { color: true },
cwd: process.cwd(),
})
await tick()
expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await disposeTuiTestHarness(result)
})
})
+5
View File
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
+4 -20
View File
@@ -7,7 +7,6 @@
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
@@ -69,26 +68,11 @@ declare module '@deepseek-ai/dsh-session' {
}
}
/**
* Pairs one `approval/asked` audit event with its `approval/decided`.
* Service-issued (one fresh id per {@link ApprovalService.request} call).
*/
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
import { ApprovalRequestId } from './types.ts'
import type { ApprovalOutcome } from './types.ts'
/**
* Brand a string as an {@link ApprovalRequestId}.
* @param id - the raw id string to brand.
* @returns the same string carrying the brand.
*/
export function ApprovalRequestId(id: string): ApprovalRequestId {
return id as ApprovalRequestId
}
/**
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
* request, or unavailable answerer. Callers fail closed on `unavailable`.
*/
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
export { ApprovalRequestId } from './types.ts'
export type { ApprovalOutcome } from './types.ts'
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
+29
View File
@@ -0,0 +1,29 @@
/**
* Wire-safe approval identifiers and outcome vocabulary, free of
* cordis/service imports so browser type chains (apiproxy api → client) can
* consume them without loading this package's Context augmentation.
* @module @deepseek-ai/dsh-user-approval/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Pairs one `approval/asked` audit event with its `approval/decided`.
* Service-issued (one fresh id per {@link ApprovalService.request} call).
*/
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
/**
* Brand a string as an {@link ApprovalRequestId}.
* @param id - the raw id string to brand.
* @returns the same string carrying the brand.
*/
export function ApprovalRequestId(id: string): ApprovalRequestId {
return id as ApprovalRequestId
}
/**
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
* request, or unavailable answerer. Callers fail closed on `unavailable`.
*/
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
@@ -0,0 +1,30 @@
import { defineConfig } from 'tsdown'
/**
* Build index and the invariant companion as separate single-entry bundles.
* Both entries import src/types.ts (the browser-safe subpath), so a
* multi-entry build emits a shared chunk the package's exact `files`
* whitelist omits; separate builds inline it.
*/
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
+4 -36
View File
@@ -17,27 +17,11 @@ declare module 'cordis' {
}
}
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
/** One question in an ask_user_question request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
export type {
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
} from './types.ts'
/** Request for a human answer. */
export interface AskUserQuestionRequest {
@@ -49,22 +33,6 @@ export interface AskUserQuestionRequest {
signal?: AbortSignal
}
/** Answer to one question. */
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
/** UI-side provider for user questions. */
export interface UserInteractionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
+44
View File
@@ -0,0 +1,44 @@
/**
* Wire-safe question/answer shapes, free of cordis/service imports so browser
* type chains (apiproxy api → client) can consume them without loading this
* package's Context augmentation.
* @module @deepseek-ai/dsh-user-interaction/types
*/
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
/** One question in an ask_user_question request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
/** Answer to one question. */
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}