Merge commit 'refs/codex/unblock/master-current' into HEAD
# Conflicts: # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/core/agent/src/index.ts # packages/support/invariants/tests/invariants.spec.ts # packages/ui/acp/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
631 files changed
+21514
-3341
No files matched your search
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -30,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
|
||||
@@ -44,10 +44,15 @@ import {
|
||||
type Stream,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
installAgentLlmTarget,
|
||||
type Agent,
|
||||
type AgentLlmTarget as LlmTarget,
|
||||
type AgentLlmTargetRef as LlmTargetRef,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
@@ -258,19 +263,6 @@ export const Config: Schema<AcpConfig> = Schema.object({
|
||||
model: Schema.string(),
|
||||
})
|
||||
|
||||
/** Provider/model pair selected for one ACP session. */
|
||||
interface LlmTarget {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Mutable target shared by one agent's scoped assembly and request listeners. */
|
||||
interface LlmTargetRef {
|
||||
current: LlmTarget | undefined
|
||||
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
|
||||
assembled: LlmTarget | undefined
|
||||
}
|
||||
|
||||
/** One resolved ACP model selector plus its opaque value lookup. */
|
||||
interface ModelDirectory {
|
||||
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
|
||||
@@ -338,32 +330,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
|
||||
|
||||
// Capture once at assembly entry and apply the same pair after downstream
|
||||
// prompt listeners. A selector change during async assembly therefore takes
|
||||
// effect on the following step instead of splitting {{model}} from routing.
|
||||
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const selected = target.current
|
||||
const assembled = await next()
|
||||
target.assembled = selected
|
||||
if (selected === undefined) return assembled
|
||||
return {
|
||||
...assembled,
|
||||
variables: {
|
||||
...assembled.variables,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
},
|
||||
}
|
||||
})
|
||||
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, _signal, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
return selected === undefined ? resolved : {
|
||||
...resolved,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
}
|
||||
})
|
||||
installAgentLlmTarget(agentCtx, target)
|
||||
}
|
||||
|
||||
/** Opaque ACP value preserving both routing dimensions. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-acp`.
|
||||
* @module @deepseek-ai/dsh-acp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'acp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -8,7 +8,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
@@ -25,6 +28,13 @@ class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInvariants(ctx: BridgeHarness['ctx']): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'permission',
|
||||
@@ -76,7 +86,7 @@ describe('acp bridge — session config options', () => {
|
||||
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// Make an out-of-turn switch fail in this suite.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await mountInvariants(harness.ctx)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
await harness.ctx.plugin(PermissionService)
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,11 +29,13 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`.
|
||||
* @module @deepseek-ai/dsh-app-boot/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'app-boot-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,11 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
|
||||
* @module @deepseek-ai/dsh-commands/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'commands-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: registry notifications intentionally hide mutation details and contain
|
||||
* observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -26,6 +31,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
@@ -37,6 +43,7 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-jsonrpc`.
|
||||
* @module @deepseek-ai/dsh-jsonrpc/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'jsonrpc-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -34,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-permission'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'permission-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the package-owned event shape and ignore unrelated events. */
|
||||
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
|
||||
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation that loaded and newly appended preset events remain resolvable. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(ctx, event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
validateEvent(ctx, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['permission', 'sessions'] })
|
||||
|
||||
/**
|
||||
* Register the permission invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
class PermissionProbe extends Service {
|
||||
readonly names = ['safe', 'trusted']
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'permission')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(PermissionInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function presetEvent(preset: string): SessionEvent {
|
||||
return { type: 'permission/preset', seq: 0, time: 0, data: { preset } }
|
||||
}
|
||||
|
||||
describe('permission invariants', () => {
|
||||
it('accepts configured preset events and ignores other session data', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('safe')) }).not.toThrow()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, {
|
||||
type: 'turn/end', seq: 0, time: 0, data: {},
|
||||
} as SessionEvent) }).not.toThrow()
|
||||
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a durable preset that the active table cannot resolve', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) })
|
||||
.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
|
||||
it('rejects an unknown preset already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
ctx.sessions.create().append('permission/preset', { preset: 'missing' })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,12 @@ async function mounted(options: {
|
||||
approvalDefault?: ApprovalPolicy | undefined
|
||||
} = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write' })
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
run() { throw new Error('permission tests do not execute bash') },
|
||||
start() { throw new Error('permission tests do not execute bash') },
|
||||
})
|
||||
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
|
||||
await ctx.plugin(PermissionService, options.config ?? {})
|
||||
return ctx
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,12 +28,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-ask-user`.
|
||||
* @module @deepseek-ai/dsh-tool-ask-user/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-ask-user-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,13 +6,15 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
|
||||
|
||||
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `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.
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `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. 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. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
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. 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.
|
||||
|
||||
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`, `/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. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
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.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -21,10 +23,13 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
| `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 |
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
| `maxQuestionOptions` | `8` | Visible options in a question panel |
|
||||
| `maxModelOptions` | `8` | Visible models in the model selector |
|
||||
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
|
||||
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
|
||||
| `modelDialogWidth` | `72` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `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 |
|
||||
@@ -36,14 +41,14 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
|
||||
welcome: 'Coding agent ready.'
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 12
|
||||
maxToolOutputLines: 6
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
|
||||
## 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.
|
||||
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; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -61,6 +66,20 @@ Submitted text is retained under the agent loop's normal session-history and com
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Session model selection
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed.
|
||||
|
||||
### Interactive user-question answers
|
||||
|
||||
#### What the model sees
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -25,9 +30,12 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -41,10 +49,12 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
+405
-60
@@ -13,12 +13,12 @@ import {
|
||||
Editor,
|
||||
Input,
|
||||
Key,
|
||||
Loader,
|
||||
Markdown,
|
||||
Spacer,
|
||||
Text,
|
||||
TUI,
|
||||
ProcessTerminal,
|
||||
SelectList,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
@@ -33,11 +33,23 @@ import {
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
installAgentLlmTarget,
|
||||
type Agent,
|
||||
type AgentLlmTarget,
|
||||
type AgentLlmTargetRef,
|
||||
type AgentStatus,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmModelInfo,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
@@ -56,20 +68,26 @@ import {
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
/** Render model reasoning blocks. */
|
||||
showReasoning?: boolean
|
||||
/** Maximum tool-output lines shown before the card is collapsed. */
|
||||
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
|
||||
maxToolOutputLines?: number
|
||||
/** Maximum options visible at once in a user-question dialog. */
|
||||
/** Maximum options visible at once in a user-question panel. */
|
||||
maxQuestionOptions?: number
|
||||
/** User-question dialog width in terminal columns. */
|
||||
/** Maximum models visible at once in the model selector. */
|
||||
maxModelOptions?: number
|
||||
/** User-question panel width in terminal columns, clamped to the terminal. */
|
||||
questionDialogWidth?: number
|
||||
/** User-question dialog maximum height in terminal rows. */
|
||||
/** User-question panel maximum height in terminal rows. */
|
||||
questionDialogMaxHeight?: number
|
||||
/** Model-selector width in terminal columns. */
|
||||
modelDialogWidth?: number
|
||||
/** Model-selector maximum height in terminal rows. */
|
||||
modelDialogMaxHeight?: number
|
||||
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
|
||||
showHardwareCursor?: boolean
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
@@ -79,10 +97,13 @@ export interface TuiConfig {
|
||||
}
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12)
|
||||
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
|
||||
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const questionDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const showHardwareCursorSchema = z.boolean().default(false)
|
||||
const colorSchema = z.boolean().default(true)
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
@@ -92,8 +113,11 @@ export const TuiConfigSchema: z<TuiConfig> = z.object({
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
title: titleSchema,
|
||||
@@ -113,8 +137,11 @@ export const Config: z<Config> = z.object({
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
color: colorSchema,
|
||||
title: titleSchema,
|
||||
@@ -125,8 +152,11 @@ export interface ResolvedTuiConfig {
|
||||
showReasoning: boolean
|
||||
maxToolOutputLines: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
questionDialogWidth: number
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
showHardwareCursor: boolean
|
||||
color: boolean
|
||||
title: string
|
||||
@@ -138,6 +168,8 @@ export interface TuiRuntime {
|
||||
terminal: Terminal
|
||||
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
|
||||
exit(code: number): void
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,10 +181,13 @@ export interface TuiRuntime {
|
||||
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
|
||||
return {
|
||||
showReasoning: config?.showReasoning ?? true,
|
||||
maxToolOutputLines: config?.maxToolOutputLines ?? 12,
|
||||
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
|
||||
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 72,
|
||||
maxModelOptions: config?.maxModelOptions ?? 8,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 72,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
showHardwareCursor: config?.showHardwareCursor ?? false,
|
||||
color: config?.color ?? true,
|
||||
title: config?.title ?? 'DeepSeek Harness',
|
||||
@@ -253,6 +288,13 @@ function selectTheme(palette: Palette): SelectListTheme {
|
||||
}
|
||||
}
|
||||
|
||||
function dialogSelectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
...selectTheme(palette),
|
||||
selectedText: text => palette.selected(palette.accent(text)),
|
||||
}
|
||||
}
|
||||
|
||||
function contentText(content: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
@@ -284,11 +326,52 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
interface ModelChoice extends AgentLlmTarget {
|
||||
modelName: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function targetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.provider}/${target.model}`
|
||||
}
|
||||
|
||||
function initialTarget(agent: Agent): AgentLlmTarget | undefined {
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) return { provider: logged.provider, model: logged.model }
|
||||
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
|
||||
async function readModelChoices(
|
||||
ctx: Context,
|
||||
current: AgentLlmTarget | undefined,
|
||||
): Promise<ModelChoice[]> {
|
||||
const providers = ctx.llm.listProviders()
|
||||
const groups = await Promise.all(providers.map(async (provider) => {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models: LlmModelInfo[] = [...advertised]
|
||||
if (
|
||||
current?.provider === provider.id
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({ provider: provider.id, id: current.model, name: current.model })
|
||||
}
|
||||
return models.map((model): ModelChoice => ({
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
modelName: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
}))
|
||||
}))
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
class HeaderComponent implements Component {
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly welcome: string,
|
||||
private readonly palette: Palette,
|
||||
private readonly currentModel: () => string | undefined,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
@@ -296,7 +379,7 @@ class HeaderComponent implements Component {
|
||||
render(width: number): string[] {
|
||||
const usable = Math.max(1, width - 4)
|
||||
const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
|
||||
const model = displayText(this.agent.options.model ?? 'model unset')
|
||||
const model = displayText(this.currentModel() ?? 'model unset')
|
||||
const detail = `${model} • ${displayText(this.agent.session.id)}`
|
||||
const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
|
||||
const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
|
||||
@@ -508,9 +591,15 @@ class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
|
||||
const body = this.renderBody()
|
||||
const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
|
||||
const headLines = Math.ceil(this.maxOutputLines / 2)
|
||||
const tailLines = this.maxOutputLines - headLines
|
||||
const visibleBody = this.expanded || body.length <= this.maxOutputLines
|
||||
? body
|
||||
: [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)]
|
||||
: [
|
||||
...body.slice(0, headLines),
|
||||
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
|
||||
...body.slice(body.length - tailLines),
|
||||
]
|
||||
const barFn = this.result === undefined
|
||||
? this.palette.warning
|
||||
: isError ? this.palette.error : this.palette.success
|
||||
@@ -647,19 +736,41 @@ class FooterComponent implements Component {
|
||||
private readonly toolsExpanded: () => boolean,
|
||||
private readonly showReasoning: () => boolean,
|
||||
private readonly tokens: () => { input: number; output: number },
|
||||
private readonly currentModel: () => string | undefined,
|
||||
private readonly contextPercent: () => number | undefined,
|
||||
private readonly runningSeconds: () => number,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.agent.status === 'running') {
|
||||
const interrupt = this.palette.dim('esc interrupt')
|
||||
const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1)
|
||||
const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '')
|
||||
const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt)))
|
||||
return [`${activity}${gap}${interrupt}`]
|
||||
}
|
||||
const { input, output } = this.tokens()
|
||||
const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}`
|
||||
const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}`
|
||||
const leftStyled = this.palette.dim(left)
|
||||
const available = Math.max(0, width - visibleWidth(left) - 2)
|
||||
const rightClipped = truncateToWidth(right, available, '')
|
||||
const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped)))
|
||||
return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')]
|
||||
const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}`
|
||||
const model = displayText(this.currentModel() ?? 'model unset')
|
||||
const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
|
||||
const contextPercent = this.contextPercent()
|
||||
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
|
||||
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
|
||||
const compactRight = `${context} ${modelState}`
|
||||
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
|
||||
const compact = truncateToWidth(compactRight, width, '')
|
||||
return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
|
||||
}
|
||||
const rightAvailable = width - visibleWidth(counters) - 1
|
||||
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 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)}`]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +779,76 @@ interface QuestionSelection {
|
||||
custom?: string
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
title: string,
|
||||
body: readonly string[],
|
||||
width: number,
|
||||
palette: Palette,
|
||||
): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const topLabel = ` ${displayText(title)} `
|
||||
const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
|
||||
const lines: string[] = [palette.accent(top)]
|
||||
for (const line of body) {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
|
||||
}
|
||||
lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
|
||||
return lines
|
||||
}
|
||||
|
||||
class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (choice: ModelChoice) => void,
|
||||
cancel: () => void,
|
||||
) {
|
||||
this.list = new SelectList(choices.map(choice => ({
|
||||
value: targetLabel(choice),
|
||||
label: displayText(targetLabel(choice)),
|
||||
description: [
|
||||
displayText(choice.modelName),
|
||||
...choice.description === undefined ? [] : [displayText(choice.description)],
|
||||
...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
|
||||
].join(' — '),
|
||||
})), maxVisible, dialogSelectTheme(palette))
|
||||
const currentIndex = current === undefined
|
||||
? 0
|
||||
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
||||
this.list.setSelectedIndex(currentIndex)
|
||||
this.list.onSelect = (item) => {
|
||||
const selected = choices.find(choice => targetLabel(choice) === item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
done(selected)
|
||||
}
|
||||
this.list.onCancel = cancel
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.list.handleInput(data)
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
|
||||
class QuestionDialog implements Component, Focusable {
|
||||
private selectedIndex = 0
|
||||
private selected = new Set<number>()
|
||||
@@ -679,6 +860,9 @@ class QuestionDialog implements Component, Focusable {
|
||||
|
||||
constructor(
|
||||
private readonly question: AskUserQuestionItem,
|
||||
private readonly position: number,
|
||||
private readonly total: number,
|
||||
private readonly unanswered: number,
|
||||
private readonly maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (selection: QuestionSelection) => void,
|
||||
@@ -719,11 +903,11 @@ class QuestionDialog implements Component, Focusable {
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
|
||||
if (indices.length === 0) {
|
||||
this.error = 'Select at least one option, or press C for a custom answer.'
|
||||
this.error = 'Select at least one option, or press Tab for a custom answer.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
|
||||
} else if (data.toLowerCase() === 'c') {
|
||||
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
|
||||
this.mode = 'custom'
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
|
||||
@@ -743,16 +927,13 @@ class QuestionDialog implements Component, Focusable {
|
||||
render(width: number): string[] {
|
||||
this.input.focused = this.focused
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const title = displayText(this.question.header ?? 'Question')
|
||||
const topLabel = ` ${title} `
|
||||
const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
|
||||
const lines: string[] = [this.palette.accent(top)]
|
||||
const push = (line: string): void => {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`)
|
||||
}
|
||||
for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line)
|
||||
push('')
|
||||
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
|
||||
const lines = [
|
||||
this.palette.muted(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
'',
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
if (this.mode === 'custom') {
|
||||
for (const line of this.input.render(innerWidth)) push(line)
|
||||
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
|
||||
@@ -763,27 +944,45 @@ class QuestionDialog implements Component, Focusable {
|
||||
options.length - this.maxVisible,
|
||||
))
|
||||
const end = Math.min(options.length, start + this.maxVisible)
|
||||
const optionRows = options.slice(start, end).map((option, offset) => {
|
||||
const index = start + offset
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
})
|
||||
const descriptionColumn = Math.min(
|
||||
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
|
||||
Math.max(1, Math.floor(innerWidth * 0.55)),
|
||||
)
|
||||
for (let index = start; index < end; index += 1) {
|
||||
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
|
||||
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
|
||||
const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' '
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? this.palette.success('[x]') : '[ ]'
|
||||
: index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○')
|
||||
const description = option.description
|
||||
? this.palette.muted(` — ${displayText(option.description)}`)
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
const line = `${cursor} ${mark} ${displayText(option.label)}${description}`
|
||||
push(index === this.selectedIndex ? this.palette.selected(line) : line)
|
||||
const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
const leftStyled = index === this.selectedIndex
|
||||
? this.palette.bold(this.palette.accent(left))
|
||||
: left
|
||||
const description = option.description === undefined
|
||||
? ''
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
|
||||
push(`${leftStyled}${description}`)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
push(this.palette.dim(this.question.multiSelect
|
||||
? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel'
|
||||
: '↑↓ navigate • Enter select • C custom • Esc cancel'))
|
||||
const hint = this.palette.dim(this.question.multiSelect
|
||||
? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt'
|
||||
: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt')
|
||||
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
|
||||
}
|
||||
if (this.error) push(this.palette.error(this.error))
|
||||
lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
|
||||
return lines
|
||||
if (this.error) {
|
||||
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
|
||||
}
|
||||
return ['', ...lines, ''].map((line) => {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,7 +1038,6 @@ export function createTuiChat(
|
||||
const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
|
||||
const chat = new Container()
|
||||
const todoContainer = new Container()
|
||||
const statusContainer = new Container()
|
||||
const editor = new Editor(ui, {
|
||||
borderColor: palette.dim,
|
||||
selectList: selectTheme(palette),
|
||||
@@ -848,7 +1046,8 @@ export function createTuiChat(
|
||||
let showReasoning = resolved.showReasoning
|
||||
let toolsExpanded = false
|
||||
let streaming: StreamingAssistantComponent | undefined
|
||||
let statusLoader: Loader | undefined
|
||||
let runningStartedAt: number | undefined
|
||||
let statusTicker: ReturnType<typeof setInterval> | undefined
|
||||
let disposed = false
|
||||
let shuttingDown: Promise<void> | undefined
|
||||
const tokens = sessionTokens(agent.session)
|
||||
@@ -858,13 +1057,32 @@ export function createTuiChat(
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
const commandControllers = new Set<AbortController>()
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
let modelOverlay: OverlayHandle | undefined
|
||||
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
|
||||
let contextWindow: number | undefined
|
||||
let contextResolution: Promise<
|
||||
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
|
||||
| { readonly kind: 'error'; readonly error: unknown }
|
||||
> | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
const now = (): number => runtime.now?.() ?? Date.now()
|
||||
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const header = new HeaderComponent(agent, welcome, palette)
|
||||
const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens)
|
||||
const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model)
|
||||
const footer = new FooterComponent(
|
||||
agent,
|
||||
palette,
|
||||
() => toolsExpanded,
|
||||
() => showReasoning,
|
||||
() => tokens,
|
||||
() => target.current?.model,
|
||||
() => contextWindow === undefined
|
||||
? undefined
|
||||
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
|
||||
() => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
|
||||
)
|
||||
ui.addChild(header)
|
||||
ui.addChild(chat)
|
||||
ui.addChild(statusContainer)
|
||||
todoContainer.addChild(todo)
|
||||
ui.addChild(todoContainer)
|
||||
ui.addChild(editor)
|
||||
@@ -884,10 +1102,120 @@ export function createTuiChat(
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
const resolution = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
|
||||
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
contextWindow = result.contextWindow
|
||||
requestRender()
|
||||
})
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (selected: ModelChoice): void => {
|
||||
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
|
||||
appendNotice(`Model is already ${targetLabel(selected)}.`)
|
||||
return
|
||||
}
|
||||
target.current = { provider: selected.provider, model: selected.model }
|
||||
resolveContextWindow(target.current)
|
||||
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
|
||||
}
|
||||
|
||||
const showModelSelector = (choices: readonly ModelChoice[]): void => {
|
||||
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
|
||||
if (choices.length === 0) {
|
||||
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
|
||||
return
|
||||
}
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
const close = (): void => {
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
requestRender()
|
||||
}
|
||||
const dialog = new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selected) => {
|
||||
close()
|
||||
selectModel(selected)
|
||||
},
|
||||
close,
|
||||
)
|
||||
modelOverlay = ui.showOverlay(dialog, {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const handleModelCommand = async (raw: string): Promise<void> => {
|
||||
const choices = await readModelChoices(ctx, target.current)
|
||||
if (disposed) return
|
||||
const argument = raw.trim()
|
||||
if (argument === '') {
|
||||
showModelSelector(choices)
|
||||
return
|
||||
}
|
||||
const parts = argument.split(/\s+/u)
|
||||
if (parts.length > 2) {
|
||||
appendNotice('Usage: /model [provider/]model', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
let matches: ModelChoice[]
|
||||
if (parts.length === 2) {
|
||||
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
|
||||
} else {
|
||||
const value = argument
|
||||
const qualified = choices.filter(choice => targetLabel(choice) === value)
|
||||
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
|
||||
return
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
|
||||
return
|
||||
}
|
||||
const selected = matches[0]
|
||||
/* v8 ignore next -- a non-empty matches array always has index zero. */
|
||||
if (selected === undefined) return
|
||||
selectModel(selected)
|
||||
}
|
||||
|
||||
const queueModelCommand = (raw: string): void => {
|
||||
modelCommands = modelCommands.then(async () => {
|
||||
await handleModelCommand(raw)
|
||||
}).catch((error: unknown) => {
|
||||
if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
}
|
||||
|
||||
const clearStatus = (): void => {
|
||||
statusLoader?.stop()
|
||||
statusLoader = undefined
|
||||
statusContainer.clear()
|
||||
if (statusTicker !== undefined) clearInterval(statusTicker)
|
||||
statusTicker = undefined
|
||||
runningStartedAt = undefined
|
||||
runtime.terminal.setProgress(false)
|
||||
}
|
||||
|
||||
@@ -895,8 +1223,9 @@ export function createTuiChat(
|
||||
clearStatus()
|
||||
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
|
||||
if (status === 'running') {
|
||||
statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels')
|
||||
statusContainer.addChild(statusLoader)
|
||||
runningStartedAt = now()
|
||||
statusTicker = setInterval(requestRender, 1_000)
|
||||
statusTicker.unref()
|
||||
runtime.terminal.setProgress(true)
|
||||
}
|
||||
requestRender()
|
||||
@@ -1072,6 +1401,9 @@ export function createTuiChat(
|
||||
}
|
||||
const dialog = new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
@@ -1090,8 +1422,8 @@ export function createTuiChat(
|
||||
pending.overlay = ui.showOverlay(dialog, {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
})
|
||||
requestRender()
|
||||
}
|
||||
@@ -1130,7 +1462,10 @@ export function createTuiChat(
|
||||
const shutdown = (exitProcess: boolean): Promise<void> => {
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
contextResolution = undefined
|
||||
clearStatus()
|
||||
modelOverlay?.hide()
|
||||
modelOverlay = undefined
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
commandControllers.clear()
|
||||
if (activeQuestion !== undefined) {
|
||||
@@ -1184,7 +1519,7 @@ export function createTuiChat(
|
||||
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
|
||||
chat.addChild(new Text([
|
||||
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
|
||||
'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
|
||||
'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
|
||||
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
|
||||
'',
|
||||
...commandLines,
|
||||
@@ -1213,6 +1548,15 @@ export function createTuiChat(
|
||||
description: 'Show keyboard shortcuts and commands',
|
||||
handler: () => { showHelp(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'model',
|
||||
description: 'Show or switch this session\'s model',
|
||||
input: { hint: '[[provider/]model]' },
|
||||
handler: ({ rawInput }) => {
|
||||
queueModelCommand(rawInput)
|
||||
return { kind: 'success' }
|
||||
},
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'clear',
|
||||
description: 'Clear the transcript view (session history is unchanged)',
|
||||
@@ -1288,7 +1632,7 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const removeInputListener = ui.addInputListener((data) => {
|
||||
if (activeQuestion !== undefined) return undefined
|
||||
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
|
||||
if (matchesKey(data, Key.ctrl('o'))) {
|
||||
toggleTools()
|
||||
return { consume: true }
|
||||
@@ -1358,6 +1702,7 @@ export function createTuiChat(
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
disposeAgent()
|
||||
disposeTargetListeners()
|
||||
}
|
||||
|
||||
rebuildTranscript(true)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tui`.
|
||||
* @module @deepseek-ai/dsh-tui/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tui'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tui-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent, type AgentCancelCause, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type AgentCancelCause, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
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'
|
||||
@@ -22,6 +23,16 @@ export interface TuiHarnessOptions {
|
||||
configureContext?: (ctx: Context) => Promise<void>
|
||||
beforeMount?: (session: Session) => void
|
||||
cwd?: string | null
|
||||
agentOptions?: AgentOptions
|
||||
contextWindow?: number
|
||||
contextTokens?: number
|
||||
now?: () => number
|
||||
catalog?: {
|
||||
providers: LlmProviderInfo[]
|
||||
models: LlmModelInfo[]
|
||||
listModels?: (provider: string) => Promise<LlmModelInfo[]>
|
||||
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
|
||||
@@ -50,6 +61,31 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
|
||||
],
|
||||
}
|
||||
ctx.provide('llm', {
|
||||
listProviders() {
|
||||
return catalog.providers.map(provider => ({ ...provider }))
|
||||
},
|
||||
listModels(provider: string) {
|
||||
return catalog.listModels?.(provider)
|
||||
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
|
||||
},
|
||||
resolveModelContext(provider: string, model: string) {
|
||||
return catalog.resolveModelContext?.(provider, model)
|
||||
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('tokenMeter', {
|
||||
measure() {
|
||||
return { totalTokens: options.contextTokens ?? 0 }
|
||||
},
|
||||
} as never)
|
||||
if (options.configureContext === undefined) {
|
||||
const tools = options.tools ?? {}
|
||||
ctx.provide('tools', {
|
||||
@@ -60,18 +96,24 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
} else {
|
||||
await options.configureContext(ctx)
|
||||
}
|
||||
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
|
||||
const sessionId = SessionId('main-session')
|
||||
const session = ctx.sessions.create(
|
||||
sessionId,
|
||||
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
|
||||
)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: { model: 'deepseek-v4-flash' },
|
||||
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
@@ -97,7 +139,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
welcome: 'Coding agent ready.',
|
||||
sessionId,
|
||||
color: false,
|
||||
}, options.config), { terminal, exit })
|
||||
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
|
||||
@@ -122,11 +164,10 @@ export function appendAssistant(
|
||||
session: Session,
|
||||
content: ContentBlock[],
|
||||
usage?: { inputTokens: number; outputTokens: number },
|
||||
position: { turn: number; step: number } = { turn: 1, step: 0 },
|
||||
position: { turn: number; step: number } = { turn: 1, step: 1 },
|
||||
): void {
|
||||
session.append('assistant/message', {
|
||||
turn: position.turn,
|
||||
step: position.step,
|
||||
...position,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content,
|
||||
...usage === undefined ? {} : { usage },
|
||||
|
||||
@@ -12,7 +12,15 @@ describe('dsh-tui plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tui)
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
|
||||
expect(unwrapped.inject).toEqual([
|
||||
'agents',
|
||||
'commands',
|
||||
'userInteraction',
|
||||
'tools',
|
||||
'llm',
|
||||
'systemPrompt',
|
||||
'tokenMeter',
|
||||
])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
@@ -33,11 +33,12 @@ buffer
|
||||
9| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
10| "▌ packages/ui/tui 100% "
|
||||
10| "▌ … +4 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
11| "▌ … 4 more lines (Ctrl+O to expand) "
|
||||
style 2-30 dim
|
||||
11| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
style 2-9 dim
|
||||
12| "▌ "
|
||||
style 0-0 fg=green
|
||||
13| <blank>
|
||||
@@ -53,12 +54,12 @@ buffer
|
||||
17| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
18| "▌ - keep "
|
||||
18| "▌ … +5 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
19| "▌ … 5 more lines (Ctrl+O to expand) "
|
||||
style 2-30 dim
|
||||
19| "▌ + expect(screen).toMatchSnapshot() "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
style 2-35 fg=green
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| <blank>
|
||||
@@ -102,6 +103,6 @@ buffer
|
||||
style 1-1 inverse
|
||||
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
style 42-99 dim
|
||||
@@ -122,6 +122,6 @@ buffer
|
||||
style 1-1 inverse
|
||||
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded"
|
||||
49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 66-99 dim
|
||||
style 41-99 dim
|
||||
@@ -46,7 +46,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
18-35| <blank>
|
||||
@@ -1,5 +1,5 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
lifecycle started=1 stopped=0 progress=active
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
viewport
|
||||
@@ -41,12 +41,12 @@ viewport
|
||||
15| " Streaming visible state… "
|
||||
style 11-23 bold
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
style 0-95 fg=bright-blue
|
||||
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
|
||||
style 0-95 fg=bright-blue
|
||||
19| "◒ Working · 0s esc interrupt"
|
||||
style 0-13 fg=bright-blue
|
||||
style 83-95 dim
|
||||
20-35| <blank>
|
||||
@@ -53,7 +53,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
21-35| <blank>
|
||||
@@ -1,7 +1,7 @@
|
||||
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=29 bufferRow=29
|
||||
cursor visible column=0 viewportRow=30 bufferRow=30
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
@@ -25,7 +25,7 @@ buffer
|
||||
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 "
|
||||
8| " Esc cancel active turn • Ctrl+O toggle 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
|
||||
@@ -38,28 +38,30 @@ buffer
|
||||
style 1-47 fg=bright-black
|
||||
14| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
15| " /reasoning — Toggle reasoning blocks "
|
||||
15| " /model [[provider/]model] — Show or switch this session's model "
|
||||
style 1-63 fg=bright-black
|
||||
16| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
16| " /redraw — Invalidate components and redraw the terminal "
|
||||
17| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
17| " /tools — Expand or collapse all tool cards "
|
||||
18| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
18| <blank>
|
||||
19| " provider stream failed after partial output "
|
||||
19| <blank>
|
||||
20| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
20| <blank>
|
||||
21| " The previous process ended during this turn. "
|
||||
21| <blank>
|
||||
22| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
22| <blank>
|
||||
23| " Unknown command: /unknown-advanced-command "
|
||||
23| <blank>
|
||||
24| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
24| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
25| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
25| " "
|
||||
26| " "
|
||||
style 1-1 inverse
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
27| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
28-31| <blank>
|
||||
style 34-91 dim
|
||||
29-31| <blank>
|
||||
@@ -33,8 +33,9 @@ buffer
|
||||
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' }), "
|
||||
11| "▌ … +1 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=yellow
|
||||
style 2-30 dim
|
||||
12| "▌ ]) "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ phase('Verify') "
|
||||
@@ -49,7 +50,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
20-35| <blank>
|
||||
@@ -1,7 +1,7 @@
|
||||
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=25 bufferRow=25
|
||||
cursor hidden column=1 viewportRow=26 bufferRow=26
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
@@ -25,7 +25,7 @@ buffer
|
||||
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 "
|
||||
8| " Esc cancel active turn • Ctrl+O toggle 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
|
||||
@@ -38,28 +38,30 @@ buffer
|
||||
style 1-47 fg=bright-black
|
||||
14| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
15| " /reasoning — Toggle reasoning blocks "
|
||||
15| " /model [[provider/]model] — Show or switch this session's model "
|
||||
style 1-63 fg=bright-black
|
||||
16| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
16| " /redraw — Invalidate components and redraw the terminal "
|
||||
17| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
17| " /tools — Expand or collapse all tool cards "
|
||||
18| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
18| <blank>
|
||||
19| " provider stream failed after partial output "
|
||||
19| <blank>
|
||||
20| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
20| <blank>
|
||||
21| " The previous process ended during this turn. "
|
||||
21| <blank>
|
||||
22| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
22| <blank>
|
||||
23| " Unknown command: /unknown-advanced-command "
|
||||
23| <blank>
|
||||
24| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
24| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
25| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
25| " "
|
||||
26| " "
|
||||
style 1-1 inverse
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
27| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
28-31| <blank>
|
||||
style 34-91 dim
|
||||
29-31| <blank>
|
||||
@@ -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=0 viewportRow=31 bufferRow=31
|
||||
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| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
6| " "
|
||||
style 1-1 inverse
|
||||
7| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 34-91 dim
|
||||
9-12| <blank>
|
||||
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
|
||||
style 10-81 fg=bright-blue
|
||||
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
|
||||
style 10-10 fg=bright-blue
|
||||
style 12-72 fg=bright-blue inverse
|
||||
style 81-81 fg=bright-blue
|
||||
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
|
||||
style 10-10 fg=bright-blue
|
||||
style 38-60 fg=bright-black
|
||||
style 81-81 fg=bright-blue
|
||||
16| " │ │ "
|
||||
style 10-10 fg=bright-blue
|
||||
style 81-81 fg=bright-blue
|
||||
17| " │ ↑/↓ navigate • Enter select • Esc cancel │ "
|
||||
style 10-10 fg=bright-blue
|
||||
style 12-51 dim
|
||||
style 81-81 fg=bright-blue
|
||||
18| " ╰──────────────────────────────────────────────────────────────────────╯ "
|
||||
style 10-81 fg=bright-blue
|
||||
19-31| <blank>
|
||||
@@ -0,0 +1,35 @@
|
||||
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=8 bufferRow=8
|
||||
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-pro • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-33 dim
|
||||
style 91-91 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-91 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
|
||||
style 1-64 fg=bright-black
|
||||
7| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
8| " "
|
||||
style 1-1 inverse
|
||||
9| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 36-91 dim
|
||||
11-31| <blank>
|
||||
@@ -1,7 +1,7 @@
|
||||
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
|
||||
cursor hidden column=56 viewportRow=17 bufferRow=17
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────╮"
|
||||
style 0-55 fg=bright-blue
|
||||
@@ -18,52 +18,30 @@ viewport
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 55-55 fg=bright-blue
|
||||
4| "╰───╭ Coverage ────────────────────────────────────╮───╯"
|
||||
4| "╰──────────────────────────────────────────────────────╯"
|
||||
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>
|
||||
5| " "
|
||||
6| " Question 1/3 (3 unanswered) · Coverage "
|
||||
style 2-39 fg=bright-black
|
||||
7| " Which advanced TUI states belong in the required "
|
||||
8| " matrix? "
|
||||
9| " "
|
||||
10| " › 1. [ ] Code Mode run_code programs and capture "
|
||||
style 2-19 fg=bright-blue bold
|
||||
style 25-53 fg=bright-black
|
||||
11| " 2. [ ] Workflows phases and parallel agents "
|
||||
style 25-50 fg=bright-black
|
||||
12| " 3. [ ] Cordis tools inspect, mount, and unmount "
|
||||
style 25-51 fg=bright-black
|
||||
13| " 1/4 "
|
||||
style 2-4 dim
|
||||
14| " Tab custom answer • ↑/↓ navigate • Space toggle • "
|
||||
style 2-55 dim
|
||||
15| " Enter submit • Esc interrupt "
|
||||
style 2-29 dim
|
||||
16| " Select at least one option, or press Tab for a "
|
||||
style 2-55 fg=red
|
||||
17| " custom answer. "
|
||||
style 2-15 fg=red
|
||||
18| " "
|
||||
19| <blank>
|
||||
@@ -20,48 +20,28 @@ viewport
|
||||
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 │ "
|
||||
5| "────────────────────────────────────────────────────────"
|
||||
style 0-55 dim
|
||||
6| " "
|
||||
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>
|
||||
7| " "
|
||||
8| " Question 1/3 (3 unanswered) · Coverage "
|
||||
style 2-39 fg=bright-black
|
||||
9| " Which advanced TUI states belong in the required "
|
||||
10| " matrix? "
|
||||
11| " "
|
||||
12| " › 1. [ ] Code Mode run_code programs and capture "
|
||||
style 2-19 fg=bright-blue bold
|
||||
style 25-53 fg=bright-black
|
||||
13| " 2. [ ] Workflows phases and parallel agents "
|
||||
style 25-50 fg=bright-black
|
||||
14| " 3. [ ] Cordis tools inspect, mount, and unmount "
|
||||
style 25-51 fg=bright-black
|
||||
15| " 1/4 "
|
||||
style 2-4 dim
|
||||
16| " Tab custom answer • ↑/↓ navigate • Space toggle • "
|
||||
style 2-55 dim
|
||||
17| " Enter submit • Esc interrupt "
|
||||
style 2-29 dim
|
||||
18| " "
|
||||
19| <blank>
|
||||
@@ -42,7 +42,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
18-35| <blank>
|
||||
@@ -39,7 +39,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
16-35| <blank>
|
||||
@@ -43,7 +43,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
19-35| <blank>
|
||||
@@ -39,7 +39,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
style 38-95 dim
|
||||
16-35| <blank>
|
||||
@@ -35,7 +35,6 @@ buffer
|
||||
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
|
||||
13| " 0% context deepseek-v4-flash(reasoning:on)"
|
||||
style 1-43 dim
|
||||
14-17| <blank>
|
||||
@@ -31,7 +31,7 @@ buffer
|
||||
style 1-1 inverse
|
||||
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-103 dim
|
||||
11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 71-103 dim
|
||||
style 46-103 dim
|
||||
12-29| <blank>
|
||||
@@ -45,8 +45,9 @@ buffer
|
||||
style 2-19 dim
|
||||
15| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
16| "▌ 4016 tests passed "
|
||||
16| "▌ … +1 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-30 dim
|
||||
17| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
18| "▌ coverage complete "
|
||||
@@ -62,6 +63,6 @@ buffer
|
||||
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
|
||||
24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-13 dim
|
||||
style 22-79 dim
|
||||
@@ -49,36 +49,19 @@ buffer
|
||||
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 ─────────╮ "
|
||||
20| "▌ /unsafe/\\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 2-54 dim
|
||||
21| "▌ Unsafe output \\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\\│ │ "
|
||||
22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
|
||||
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 2-58 fg=red
|
||||
23| "▌ "
|
||||
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
|
||||
24| <blank>
|
||||
25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-62 dim
|
||||
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-60 fg=bright-black
|
||||
27| <blank>
|
||||
@@ -88,19 +71,17 @@ buffer
|
||||
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"
|
||||
32| " "
|
||||
33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 2-90 fg=bright-black
|
||||
34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
35| " "
|
||||
36| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
|
||||
style 2-65 fg=bright-blue bold
|
||||
style 67-97 fg=bright-black
|
||||
37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
|
||||
style 2-64 dim
|
||||
38| " "
|
||||
39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
style 42-99 dim
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -40,6 +41,8 @@ const CHECKPOINTS = [
|
||||
'surface-before-compaction',
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'model-selector',
|
||||
'model-switching',
|
||||
'errors-and-help',
|
||||
'disposed-terminal',
|
||||
] as const
|
||||
@@ -98,7 +101,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
|
||||
async function configureAdvancedTools(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
ctx.provide('workflows', {} as never)
|
||||
ctx.provide('workflows', { start() {} } as never)
|
||||
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
|
||||
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
|
||||
}
|
||||
@@ -119,7 +122,7 @@ function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): v
|
||||
for (const call of calls) {
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
callId: CallId(call.id),
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
@@ -135,7 +138,7 @@ function appendToolResult(
|
||||
): void {
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
callId: CallId(id),
|
||||
content,
|
||||
isError: options.isError ?? false,
|
||||
@@ -201,25 +204,27 @@ describe('TUI terminal-state snapshots', () => {
|
||||
it('pins an in-flight reasoning and Markdown stream', async () => {
|
||||
const harness = await setupSnapshot()
|
||||
await renderAfter(harness, () => {
|
||||
harness.agent.status = 'running'
|
||||
harness.ctx.emit('agent/status', harness.agent, 'running')
|
||||
appendUser(harness.session, 'Show the live update.')
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
|
||||
})
|
||||
})
|
||||
@@ -426,9 +431,10 @@ describe('TUI terminal-state snapshots', () => {
|
||||
source: { kind: 'user' },
|
||||
reason: `Unsafe policy ${CONTROL_PROBE}`,
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', {
|
||||
turn: 7,
|
||||
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` },
|
||||
})
|
||||
},
|
||||
}, { columns: 100, rows: 34 })
|
||||
@@ -450,7 +456,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
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}`))
|
||||
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
|
||||
})
|
||||
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
|
||||
|
||||
@@ -463,25 +469,29 @@ describe('TUI terminal-state snapshots', () => {
|
||||
const harness = await setupSnapshot({
|
||||
config: {
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 48,
|
||||
questionDialogWidth: 200,
|
||||
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' },
|
||||
],
|
||||
}],
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{ id: 'priority', question: 'Which state should be implemented first?' },
|
||||
{ id: 'notes', question: 'Any additional constraints?' },
|
||||
],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
@@ -508,14 +518,14 @@ describe('TUI terminal-state snapshots', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
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: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
|
||||
isError: false,
|
||||
@@ -551,13 +561,15 @@ describe('TUI terminal-state snapshots', () => {
|
||||
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'))
|
||||
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
|
||||
harness.session.append('step/end', { turn: 1, step: 1 })
|
||||
harness.session.append('turn/end', {
|
||||
turn: 3,
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
|
||||
})
|
||||
harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
harness.session.append('turn/end', {
|
||||
turn: 4,
|
||||
turn: 2,
|
||||
reason: { kind: 'interrupted' },
|
||||
})
|
||||
})
|
||||
@@ -569,6 +581,21 @@ describe('TUI terminal-state snapshots', () => {
|
||||
await harness.ctx.fiber.dispose()
|
||||
await harness.terminal.dispose()
|
||||
})
|
||||
|
||||
it('pins the model selector and selection notice', async () => {
|
||||
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/model')
|
||||
harness.terminal.send('\r')
|
||||
})
|
||||
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('\x1b[B')
|
||||
harness.terminal.send('\r')
|
||||
})
|
||||
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -112,14 +113,25 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<
|
||||
await disposeTuiTestHarness(setupResult)
|
||||
}
|
||||
|
||||
function provideTokenMeter(ctx: Context): void {
|
||||
ctx.provide('tokenMeter', {
|
||||
measure() {
|
||||
return { totalTokens: 0 }
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe('TUI config', () => {
|
||||
it('defaults every direct-call TUI option', () => {
|
||||
expect(resolveTuiConfig(undefined)).toEqual({
|
||||
showReasoning: true,
|
||||
maxToolOutputLines: 12,
|
||||
maxToolOutputLines: 6,
|
||||
maxQuestionOptions: 8,
|
||||
questionDialogWidth: 72,
|
||||
maxModelOptions: 8,
|
||||
questionDialogWidth: 200,
|
||||
questionDialogMaxHeight: 20,
|
||||
modelDialogWidth: 72,
|
||||
modelDialogMaxHeight: 20,
|
||||
showHardwareCursor: false,
|
||||
color: true,
|
||||
title: 'DeepSeek Harness',
|
||||
@@ -128,8 +140,11 @@ describe('TUI config', () => {
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
maxModelOptions: 4,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
@@ -137,8 +152,11 @@ describe('TUI config', () => {
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
maxModelOptions: 4,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
modelDialogWidth: 64,
|
||||
modelDialogMaxHeight: 16,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
@@ -148,7 +166,11 @@ describe('TUI config', () => {
|
||||
|
||||
describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||||
let now = 0
|
||||
const result = await setup({
|
||||
contextWindow: 100,
|
||||
contextTokens: 42,
|
||||
now: () => now,
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'restored prompt')
|
||||
appendAssistant(session, [
|
||||
@@ -174,9 +196,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('restored answer')
|
||||
expect(result.terminal.output).toContain('write tests')
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
||||
expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)')
|
||||
result.terminal.resize(52)
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
|
||||
result.terminal.resize(65)
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
|
||||
result.terminal.resize(88)
|
||||
await tick()
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.ctx.emit('agent/status', result.agent, 'running')
|
||||
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
|
||||
now = 8_000
|
||||
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' })
|
||||
@@ -184,62 +216,65 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
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('step/end', { turn: 1, step: 1 })
|
||||
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
result.session.append('step/start', { turn: 3, step: 1 })
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
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,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
|
||||
})
|
||||
await tick()
|
||||
@@ -250,33 +285,35 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.session,
|
||||
[{ type: 'text', text: 'final live answer' }],
|
||||
{ inputTokens: 500, outputTokens: 8 },
|
||||
{ turn: 2, step: 0 },
|
||||
{ turn: 3, step: 1 },
|
||||
)
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Working')
|
||||
expect(result.terminal.output).toContain('◒ Working · 8s')
|
||||
expect(result.terminal.output).toContain('esc interrupt')
|
||||
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,
|
||||
step: 1,
|
||||
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' }])
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('answer after clear')
|
||||
|
||||
result.agent.status = 'idle'
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
||||
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
expect(result.terminal.stopped).toBe(1)
|
||||
@@ -393,8 +430,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
appendUser(session, 'first prompt')
|
||||
appendUser(session, 'second prompt')
|
||||
session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
|
||||
})
|
||||
},
|
||||
@@ -446,6 +483,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
result.terminal.send('\r')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.ctx.emit('agent/status', result.agent, 'running')
|
||||
result.terminal.send('steer it')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
|
||||
@@ -500,6 +538,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
|
||||
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'alpha', model: 'a1' },
|
||||
contextTokens: 50,
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||
models: [
|
||||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||||
{ provider: 'alpha', id: 'shared', name: 'Alpha Shared' },
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
|
||||
],
|
||||
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
|
||||
? initialContext.promise
|
||||
: Promise.resolve({ contextWindow: 200 }),
|
||||
},
|
||||
})
|
||||
|
||||
for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
}
|
||||
expect(result.terminal.output).toContain('Usage: /model')
|
||||
expect(result.terminal.output).toContain('Unknown model: missing')
|
||||
expect(result.terminal.output).toContain('advertised by multiple providers')
|
||||
expect(result.terminal.output).toContain('already alpha/a1')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Select model')
|
||||
expect(result.terminal.output).toContain('alpha/a1')
|
||||
expect(result.terminal.output).toContain('Alpha One — Fast — current')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Model selected: beta/b1')
|
||||
expect(result.agent.sent).toEqual([])
|
||||
expect(result.agent.steered).toEqual([])
|
||||
initialContext.resolve({ contextWindow: 100 })
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
|
||||
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
expect(result.agent.cancelled).not.toContain('cancelled from terminal')
|
||||
result.agent.status = 'idle'
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)')
|
||||
|
||||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
|
||||
const request = await agentEvents(result.ctx, result.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)
|
||||
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => {
|
||||
const resumed = await setup({
|
||||
agentOptions: { provider: 'alpha', model: 'configured' },
|
||||
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
|
||||
beforeMount(session) {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'beta', model: 'private' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
},
|
||||
})
|
||||
resumed.terminal.send('/model')
|
||||
resumed.terminal.send('\r')
|
||||
await tick()
|
||||
expect(resumed.terminal.output).toContain('Select model')
|
||||
expect(resumed.terminal.output).toContain('beta/private')
|
||||
expect(resumed.terminal.output).toContain('private — current')
|
||||
await dispose(resumed)
|
||||
|
||||
const unset = await setup({
|
||||
agentOptions: {},
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelContext: () => Promise.resolve(undefined),
|
||||
},
|
||||
})
|
||||
unset.terminal.send('/model')
|
||||
unset.terminal.send('\r')
|
||||
await tick()
|
||||
unset.terminal.send('\r')
|
||||
await tick()
|
||||
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
|
||||
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
|
||||
await dispose(unset)
|
||||
|
||||
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
||||
empty.terminal.send('/model')
|
||||
empty.terminal.send('\r')
|
||||
await tick()
|
||||
expect(empty.terminal.output).toContain('Current model: unset')
|
||||
expect(empty.terminal.output).toContain('No models are advertised')
|
||||
const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent))
|
||||
expect(assembly.variables).toEqual({})
|
||||
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
|
||||
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
|
||||
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
await dispose(empty)
|
||||
|
||||
const failed = await setup({
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
listModels: () => Promise.reject(new Error('catalog offline')),
|
||||
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
|
||||
},
|
||||
})
|
||||
failed.terminal.send('/model')
|
||||
failed.terminal.send('\r')
|
||||
await tick()
|
||||
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
|
||||
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
|
||||
await dispose(failed)
|
||||
})
|
||||
|
||||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||||
const deferred = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
listModels: () => deferred.promise,
|
||||
},
|
||||
})
|
||||
result.terminal.send('/model')
|
||||
result.terminal.send('\r')
|
||||
await result.controller.dispose()
|
||||
deferred.resolve([])
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Available models')
|
||||
await result.ctx.fiber.dispose()
|
||||
|
||||
const rejected = Promise.withResolvers<never[]>()
|
||||
const rejectedResult = await setup({
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
listModels: () => rejected.promise,
|
||||
},
|
||||
})
|
||||
rejectedResult.terminal.send('/model')
|
||||
rejectedResult.terminal.send('\r')
|
||||
await rejectedResult.controller.dispose()
|
||||
rejected.reject(new Error('late catalog failure'))
|
||||
await tick()
|
||||
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
|
||||
await rejectedResult.ctx.fiber.dispose()
|
||||
|
||||
const context = Promise.withResolvers<{ contextWindow: number }>()
|
||||
const contextResult = await setup({
|
||||
contextTokens: 99,
|
||||
catalog: {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [],
|
||||
resolveModelContext: () => context.promise,
|
||||
},
|
||||
})
|
||||
await contextResult.controller.dispose()
|
||||
context.resolve({ contextWindow: 100 })
|
||||
await tick()
|
||||
expect(contextResult.terminal.output).not.toContain('99% context')
|
||||
await contextResult.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
|
||||
const result = await setup()
|
||||
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
|
||||
@@ -613,22 +834,30 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
const events = await setup()
|
||||
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
|
||||
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
|
||||
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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' } })
|
||||
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' } })
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
|
||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
|
||||
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
|
||||
events.session.append('step/end', { turn: 1, step: 1 })
|
||||
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
|
||||
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
||||
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } })
|
||||
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
|
||||
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
|
||||
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
events.session.append('turn/end', {
|
||||
turn: 9,
|
||||
turn: 7,
|
||||
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
|
||||
})
|
||||
events.ctx.emit('agent/disposed', events.agent)
|
||||
agentEvents(events.ctx, events.agent).emit('agent/disposed')
|
||||
await tick()
|
||||
expect(events.terminal.output).toContain('live failure')
|
||||
expect(events.terminal.output).toContain('durable failure')
|
||||
@@ -701,7 +930,7 @@ describe('tool cards and surface replay', () => {
|
||||
}
|
||||
|
||||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 4 } })
|
||||
const calls = [
|
||||
['c1', 'bash', '{"command":"printf hello"}'],
|
||||
['c2', 'signal', '{}'],
|
||||
@@ -722,7 +951,7 @@ describe('tool cards and surface replay', () => {
|
||||
})),
|
||||
])
|
||||
for (const [id, name, args] of calls) {
|
||||
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
|
||||
result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args })
|
||||
}
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('$ raw command')
|
||||
@@ -732,23 +961,23 @@ describe('tool cards and surface replay', () => {
|
||||
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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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' }] },
|
||||
@@ -757,20 +986,25 @@ describe('tool cards and surface replay', () => {
|
||||
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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'orphan' as never,
|
||||
content: [{ type: 'text', text: 'orphan result' }],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
}, { 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('lines (Ctrl+O to expand)')
|
||||
expect(output).toContain('SIGTERM')
|
||||
expect(output).toContain('Edit files')
|
||||
expect(output).toContain('Inspected')
|
||||
@@ -787,6 +1021,11 @@ describe('tool cards and surface replay', () => {
|
||||
result.terminal.send('/redraw')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(collapsed).toContain('Run command')
|
||||
expect(collapsed).toContain('[exit 0]')
|
||||
expect(collapsed).not.toContain('▌ hello')
|
||||
expect(collapsed).not.toContain('world')
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('world')
|
||||
@@ -799,15 +1038,15 @@ describe('tool cards and surface replay', () => {
|
||||
appendUser(result.session, 'old prompt')
|
||||
const assistant = result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
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: '{}',
|
||||
turn: 1, step: 1, 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,
|
||||
turn: 1, step: 1, 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', {
|
||||
@@ -840,6 +1079,7 @@ describe('TUI user-interaction dialogs', () => {
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Choose a mode')
|
||||
expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode')
|
||||
expect(result.terminal.output).toContain('1/2')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\r')
|
||||
@@ -859,7 +1099,7 @@ describe('TUI user-interaction dialogs', () => {
|
||||
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('c')
|
||||
result.terminal.send('\t')
|
||||
result.terminal.send('my choice')
|
||||
result.terminal.send('\r')
|
||||
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
|
||||
@@ -933,9 +1173,11 @@ describe('TUI user-interaction dialogs', () => {
|
||||
],
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Second?')
|
||||
expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)')
|
||||
result.terminal.send('done')
|
||||
result.terminal.send('\r')
|
||||
await expect(batch).resolves.toEqual({ answers: [
|
||||
@@ -982,6 +1224,7 @@ describe('TUI user-interaction dialogs', () => {
|
||||
describe('terminal mounting', () => {
|
||||
it('starts immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
@@ -1001,6 +1244,7 @@ describe('terminal mounting', () => {
|
||||
|
||||
it('waits for its configured agent before starting the TUI', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
@@ -1030,6 +1274,7 @@ describe('terminal mounting', () => {
|
||||
|
||||
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
@@ -1058,6 +1303,7 @@ describe('terminal mounting', () => {
|
||||
|
||||
it('renders an uncoercible startup failure without escaping the display boundary', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
@@ -1079,12 +1325,15 @@ describe('terminal mounting', () => {
|
||||
|
||||
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
@@ -1102,7 +1351,7 @@ describe('terminal mounting', () => {
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
|
||||
})
|
||||
await tick()
|
||||
@@ -1112,6 +1361,7 @@ describe('terminal mounting', () => {
|
||||
|
||||
it('throws when createTuiChat is called without the configured agent', async () => {
|
||||
const ctx = new Context()
|
||||
provideTokenMeter(ctx)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
@@ -37,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -36,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ApprovalRequestId } from './index.ts'
|
||||
import { APPROVAL_POLICIES } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval'
|
||||
const APPROVAL_OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] as const
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'user-approval-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
type ApprovalTransition =
|
||||
| { kind: 'asked'; id: ApprovalRequestId }
|
||||
| { kind: 'decided'; id: ApprovalRequestId }
|
||||
|
||||
/** Validate one approval event against committed unmatched questions. */
|
||||
function validateApprovalEvent(
|
||||
pending: ReadonlySet<ApprovalRequestId>,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): ApprovalTransition | undefined {
|
||||
if (event.type === 'approval/asked') {
|
||||
if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty')
|
||||
if (pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
|
||||
return { kind: 'asked', id: event.data.id }
|
||||
}
|
||||
if (event.type === 'approval/decided') {
|
||||
if (!pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`)
|
||||
if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) {
|
||||
fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`)
|
||||
}
|
||||
return { kind: 'decided', id: event.data.id }
|
||||
}
|
||||
if (event.type === 'approval/policy' && !APPROVAL_POLICIES.includes(event.data.policy)) {
|
||||
fail(`approval/policy carries unknown policy ${JSON.stringify(event.data.policy)}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Apply one accepted approval-pair transition. */
|
||||
function applyApprovalTransition(pending: Set<ApprovalRequestId>, transition: ApprovalTransition): void {
|
||||
if (transition.kind === 'asked') pending.add(transition.id)
|
||||
else pending.delete(transition.id)
|
||||
}
|
||||
|
||||
/** Install audit pairing and closed-vocabulary checks. */
|
||||
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
|
||||
/* jscpd:ignore-start */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, Set<ApprovalRequestId>>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; transition: ApprovalTransition }>()
|
||||
const seed = (session: Session): Set<ApprovalRequestId> => {
|
||||
const pending = new Set<ApprovalRequestId>()
|
||||
traces.set(session, pending)
|
||||
for (const event of session.events) {
|
||||
const transition = validateApprovalEvent(pending, event, fail)
|
||||
if (transition !== undefined) applyApprovalTransition(pending, transition)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
const traceFor = (session: Session): Set<ApprovalRequestId> => traces.get(session) ?? seed(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every package-owned pair event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation')
|
||||
staged.delete(event)
|
||||
applyApprovalTransition(traceFor(session), candidate.transition)
|
||||
}, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateApprovalEvent(traceFor(session), event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the approval invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
}
|
||||
|
||||
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
|
||||
ctx.serial('agent/pre-step', agent, 1, 1, new AbortController().signal)
|
||||
agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal)
|
||||
|
||||
/** Append a `request/header` snapshot whose system text is exactly `system`. */
|
||||
function appendHeader(session: Session, system: string): void {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApprovalInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('approval invariants', () => {
|
||||
it('accepts paired audit events and closed policy values', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
session.append('approval/decided', { id, outcome: 'allowed-once' })
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
})
|
||||
|
||||
it('rebuilds an unmatched question from an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const id = ApprovalRequestId('ask-resume')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApprovalInvariant)
|
||||
expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
const ctx = await setup()
|
||||
const session = new Session(SessionId('bare-approval-session'))
|
||||
const id = ApprovalRequestId('bare-ask')
|
||||
const asked = {
|
||||
type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' },
|
||||
} as const
|
||||
const decided = {
|
||||
type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const },
|
||||
} as const
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, asked)
|
||||
ctx.emit('session/event', session, decided)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects malformed and unpaired audit events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
expect(() => session.append('approval/asked', { id, toolName: '' }))
|
||||
.toThrow(/toolName must be non-empty/)
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
expect(() => session.append('approval/asked', { id, toolName: 'bash' }))
|
||||
.toThrow(/repeated open id/)
|
||||
expect(() => session.append('approval/decided', {
|
||||
id: ApprovalRequestId('missing'), outcome: 'rejected',
|
||||
})).toThrow(/no matching approval\/asked/)
|
||||
expect(() => session.append('approval/decided', { id, outcome: 'maybe' as never }))
|
||||
.toThrow(/unknown outcome/)
|
||||
expect(() => session.append('approval/policy', { policy: 'always' as never }))
|
||||
.toThrow(/unknown policy/)
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,11 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-user-interaction`.
|
||||
* @module @deepseek-ai/dsh-user-interaction/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'user-interaction-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the single provider slot is validated at registration and asks return
|
||||
* directly to their caller; the seam publishes no independent request/answer audit stream.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user