Merge origin/master into worktree/explicit-turn-signal
This commit is contained in:
131
packages/ui/tui/tests/harness.ts
Normal file
131
packages/ui/tui/tests/harness.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent, type AgentCancelCause, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createTuiChat, type Config } from '../src/index.ts'
|
||||
|
||||
interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
steered: ContentBlock[][]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
status?: AgentStatus
|
||||
config?: Config
|
||||
tools?: Record<string, ToolDefinition>
|
||||
configureContext?: (ctx: Context) => Promise<void>
|
||||
beforeMount?: (session: Session) => void
|
||||
cwd?: string | null
|
||||
}
|
||||
|
||||
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
|
||||
ctx: Context
|
||||
session: Session
|
||||
agent: FakeAgent
|
||||
terminal: TerminalType
|
||||
exit: Exit
|
||||
controller: ReturnType<typeof createTuiChat>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the production TUI around an in-memory session and controllable agent.
|
||||
* @param terminal - Terminal boundary driven by the test.
|
||||
* @param exit - Process-exit observer.
|
||||
* @param options - Initial session, agent, tool, and TUI configuration.
|
||||
* @returns The mounted TUI and every boundary the test may drive or inspect.
|
||||
*/
|
||||
export async function createTuiTestHarness<TerminalType extends Terminal, Exit extends (code: number) => void>(
|
||||
terminal: TerminalType,
|
||||
exit: Exit,
|
||||
options: TuiHarnessOptions = {},
|
||||
): Promise<TuiHarness<TerminalType, Exit>> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.configureContext === undefined) {
|
||||
const tools = options.tools ?? {}
|
||||
ctx.provide('tools', {
|
||||
get(name: string) {
|
||||
return tools[name]
|
||||
},
|
||||
} as never)
|
||||
} else {
|
||||
await options.configureContext(ctx)
|
||||
}
|
||||
const sessionId = SessionId('main-session')
|
||||
const session = ctx.sessions.create(
|
||||
sessionId,
|
||||
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
|
||||
)
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: { model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
status: options.status ?? 'idle',
|
||||
ctx,
|
||||
sent,
|
||||
steered,
|
||||
cancelled,
|
||||
send(content) {
|
||||
sent.push(content)
|
||||
},
|
||||
steer(content) {
|
||||
steered.push(content)
|
||||
},
|
||||
inject() {},
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
whenIdle() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
const controller = createTuiChat(ctx, Object.assign({
|
||||
welcome: 'Coding agent ready.',
|
||||
sessionId,
|
||||
color: false,
|
||||
}, options.config), { terminal, exit })
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
|
||||
/** Dispose the mounted TUI before its owning Cordis context. */
|
||||
export async function disposeTuiTestHarness(
|
||||
setup: Pick<TuiHarness<Terminal, (code: number) => void>, 'controller' | 'ctx'>,
|
||||
): Promise<void> {
|
||||
await setup.controller.dispose()
|
||||
await setup.ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
/** Append a production-shaped user message to the active session surface. */
|
||||
export function appendUser(session: Session, text: string): void {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the active session surface. */
|
||||
export function appendAssistant(
|
||||
session: Session,
|
||||
content: ContentBlock[],
|
||||
usage?: { inputTokens: number; outputTokens: number },
|
||||
): void {
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content,
|
||||
...usage === undefined ? {} : { usage },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
318
packages/ui/tui/tests/headless-terminal.ts
Normal file
318
packages/ui/tui/tests/headless-terminal.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import { Terminal as XtermTerminal, type IBufferCell } from '@xterm/headless'
|
||||
|
||||
const FRAME_END = '\x1b[?2026l'
|
||||
const FRAME_TIMEOUT_MS = 2_000
|
||||
|
||||
const ANSI_COLORS = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white',
|
||||
] as const
|
||||
|
||||
interface FrameWaiter {
|
||||
target: number
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface RowSnapshot {
|
||||
text: string
|
||||
wrapped: boolean
|
||||
styles: string[]
|
||||
}
|
||||
|
||||
export interface TerminalSnapshotOptions {
|
||||
/** Include the whole active buffer instead of only the visible viewport. */
|
||||
includeScrollback?: boolean
|
||||
}
|
||||
|
||||
function occurrenceCount(value: string, needle: string): number {
|
||||
let count = 0
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const match = value.indexOf(needle, offset)
|
||||
if (match < 0) return count
|
||||
count += 1
|
||||
offset = match + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
function colorLabel(cell: IBufferCell, kind: 'fg' | 'bg'): string | undefined {
|
||||
const isDefault = kind === 'fg' ? cell.isFgDefault() : cell.isBgDefault()
|
||||
if (isDefault) return undefined
|
||||
const isRgb = kind === 'fg' ? cell.isFgRGB() : cell.isBgRGB()
|
||||
const value = kind === 'fg' ? cell.getFgColor() : cell.getBgColor()
|
||||
if (isRgb) return `${kind}=#${value.toString(16).padStart(6, '0')}`
|
||||
const name = ANSI_COLORS[value]
|
||||
return `${kind}=${name ?? `ansi-${value}`}`
|
||||
}
|
||||
|
||||
function styleLabel(cell: IBufferCell): string {
|
||||
const labels = [
|
||||
colorLabel(cell, 'fg'),
|
||||
colorLabel(cell, 'bg'),
|
||||
cell.isBold() !== 0 ? 'bold' : undefined,
|
||||
cell.isDim() !== 0 ? 'dim' : undefined,
|
||||
cell.isItalic() !== 0 ? 'italic' : undefined,
|
||||
cell.isUnderline() !== 0 ? 'underline' : undefined,
|
||||
cell.isBlink() !== 0 ? 'blink' : undefined,
|
||||
cell.isInverse() !== 0 ? 'inverse' : undefined,
|
||||
cell.isInvisible() !== 0 ? 'invisible' : undefined,
|
||||
cell.isStrikethrough() !== 0 ? 'strike' : undefined,
|
||||
cell.isOverline() !== 0 ? 'overline' : undefined,
|
||||
].filter((label): label is string => label !== undefined)
|
||||
return labels.join(' ')
|
||||
}
|
||||
|
||||
function snapshotRow(terminal: XtermTerminal, row: number): RowSnapshot {
|
||||
const line = terminal.buffer.active.getLine(row)
|
||||
if (line === undefined) return { text: '', wrapped: false, styles: [] }
|
||||
const styles: string[] = []
|
||||
let activeStyle = ''
|
||||
let activeStart = 0
|
||||
for (let column = 0; column <= terminal.cols; column++) {
|
||||
const cell = column < terminal.cols ? line.getCell(column) : undefined
|
||||
const style = cell === undefined ? '' : styleLabel(cell)
|
||||
if (style === activeStyle) continue
|
||||
if (activeStyle !== '') styles.push(`${activeStart}-${column - 1} ${activeStyle}`)
|
||||
activeStyle = style
|
||||
activeStart = column
|
||||
}
|
||||
return {
|
||||
text: line.translateToString(true),
|
||||
wrapped: line.isWrapped,
|
||||
styles,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows(rows: readonly RowSnapshot[], firstRow: number): string[] {
|
||||
const rendered: string[] = []
|
||||
let blankStart: number | undefined
|
||||
const flushBlanks = (end: number): void => {
|
||||
if (blankStart === undefined) return
|
||||
rendered.push(blankStart === end ? `${blankStart}| <blank>` : `${blankStart}-${end}| <blank>`)
|
||||
blankStart = undefined
|
||||
}
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const absoluteRow = firstRow + index
|
||||
const row = rows[index] as RowSnapshot
|
||||
if (row.text === '' && row.styles.length === 0 && !row.wrapped) {
|
||||
blankStart ??= absoluteRow
|
||||
continue
|
||||
}
|
||||
flushBlanks(absoluteRow - 1)
|
||||
rendered.push(`${absoluteRow}${row.wrapped ? '~' : ''}| ${JSON.stringify(row.text)}`)
|
||||
for (const style of row.styles) rendered.push(` style ${style}`)
|
||||
}
|
||||
flushBlanks(firstRow + rows.length - 1)
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal emulator used by TUI snapshots. It consumes the same ANSI stream as
|
||||
* a real terminal and exposes completed synchronized frames as an awaitable boundary.
|
||||
*/
|
||||
export class HeadlessTerminal implements Terminal {
|
||||
readonly kittyProtocolActive = false
|
||||
readonly drainInput = (): Promise<void> => Promise.resolve()
|
||||
started = 0
|
||||
stopped = 0
|
||||
title = ''
|
||||
progress = false
|
||||
cursorVisible = true
|
||||
frames = 0
|
||||
private readonly emulator: XtermTerminal
|
||||
private onInput: (data: string) => void = () => {}
|
||||
private onResize: () => void = () => {}
|
||||
private pendingWrite: Promise<void> = Promise.resolve()
|
||||
private readonly frameWaiters = new Set<FrameWaiter>()
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this.emulator = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows,
|
||||
scrollback: 1_000,
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors: false,
|
||||
logLevel: 'off',
|
||||
})
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.emulator.cols
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.emulator.rows
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.started += 1
|
||||
this.onInput = onInput
|
||||
this.onResize = onResize
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped += 1
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
const completedFrames = occurrenceCount(data, FRAME_END)
|
||||
this.pendingWrite = new Promise((resolve) => {
|
||||
this.emulator.write(data, () => {
|
||||
this.frames += completedFrames
|
||||
for (const waiter of this.frameWaiters) {
|
||||
if (this.frames < waiter.target) continue
|
||||
clearTimeout(waiter.timer)
|
||||
this.frameWaiters.delete(waiter)
|
||||
waiter.resolve()
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) this.write(`\x1b[${lines}B`)
|
||||
if (lines < 0) this.write(`\x1b[${-lines}A`)
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.cursorVisible = false
|
||||
this.write('\x1b[?25l')
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.cursorVisible = true
|
||||
this.write('\x1b[?25h')
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.write('\x1b[K')
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.write('\x1b[J')
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.write('\x1b[2J\x1b[H')
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title
|
||||
this.write(`\x1b]0;${title}\x07`)
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
this.progress = active
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.onInput(data)
|
||||
}
|
||||
|
||||
resize(columns: number, rows = this.rows): void {
|
||||
this.emulator.resize(columns, rows)
|
||||
this.onResize()
|
||||
}
|
||||
|
||||
/** Wait until pi-tui completes a synchronized frame newer than `after`. */
|
||||
async waitForFrame(after = this.frames): Promise<void> {
|
||||
if (this.frames <= after) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: FrameWaiter = {
|
||||
target: after + 1,
|
||||
resolve,
|
||||
reject,
|
||||
timer: setTimeout(() => {
|
||||
this.frameWaiters.delete(waiter)
|
||||
reject(new Error(`TUI did not complete frame ${after + 1} within ${FRAME_TIMEOUT_MS}ms`))
|
||||
}, FRAME_TIMEOUT_MS),
|
||||
}
|
||||
this.frameWaiters.add(waiter)
|
||||
})
|
||||
}
|
||||
await this.flush()
|
||||
}
|
||||
|
||||
/** Await every terminal write queued through the current task. */
|
||||
async flush(): Promise<void> {
|
||||
let pending: Promise<void>
|
||||
do {
|
||||
pending = this.pendingWrite
|
||||
await pending
|
||||
} while (pending !== this.pendingWrite)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject palette output that would become theme-specific in a user's terminal.
|
||||
* @returns One location per RGB, extended-palette, or explicit-background cell.
|
||||
*/
|
||||
themeViolations(): string[] {
|
||||
const violations: string[] = []
|
||||
const buffer = this.emulator.buffer.active
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
const line = buffer.getLine(row)
|
||||
if (line === undefined) continue
|
||||
for (let column = 0; column < this.columns; column++) {
|
||||
const cell = line.getCell(column)
|
||||
if (cell === undefined) continue
|
||||
const reasons = [
|
||||
cell.isFgRGB() ? 'rgb-fg' : undefined,
|
||||
cell.isBgRGB() ? 'rgb-bg' : undefined,
|
||||
cell.isFgPalette() && cell.getFgColor() > 15 ? `extended-fg-${cell.getFgColor()}` : undefined,
|
||||
cell.isBgPalette() && cell.getBgColor() > 15 ? `extended-bg-${cell.getBgColor()}` : undefined,
|
||||
!cell.isBgDefault() ? 'explicit-bg' : undefined,
|
||||
].filter((reason): reason is string => reason !== undefined)
|
||||
if (reasons.length > 0) violations.push(`${row}:${column} ${reasons.join(',')}`)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/** Serialize terminal cells and metadata into a stable, reviewable expected output. */
|
||||
async snapshot(options: TerminalSnapshotOptions = {}): Promise<string> {
|
||||
await this.flush()
|
||||
const buffer = this.emulator.buffer.active
|
||||
const firstRow = options.includeScrollback === true ? 0 : buffer.viewportY
|
||||
const rowCount = options.includeScrollback === true ? buffer.length : this.rows
|
||||
const rows = Array.from({ length: rowCount }, (_, index) => snapshotRow(this.emulator, firstRow + index))
|
||||
const cursorBufferRow = buffer.baseY + buffer.cursorY
|
||||
const cursorViewportRow = cursorBufferRow - buffer.viewportY
|
||||
return [
|
||||
`terminal ${this.columns}x${this.rows} buffer=${buffer.type} length=${buffer.length} base=${buffer.baseY} viewport=${buffer.viewportY}`,
|
||||
`lifecycle started=${this.started} stopped=${this.stopped} progress=${this.progress ? 'active' : 'inactive'}`,
|
||||
`title ${JSON.stringify(this.title)}`,
|
||||
`cursor ${this.cursorVisible ? 'visible' : 'hidden'} column=${buffer.cursorX} viewportRow=${cursorViewportRow} bufferRow=${cursorBufferRow}`,
|
||||
options.includeScrollback === true ? 'buffer' : 'viewport',
|
||||
...renderRows(rows, firstRow),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.flush()
|
||||
for (const waiter of this.frameWaiters) {
|
||||
clearTimeout(waiter.timer)
|
||||
waiter.reject(new Error('terminal disposed before the requested frame completed'))
|
||||
}
|
||||
this.frameWaiters.clear()
|
||||
this.emulator.dispose()
|
||||
}
|
||||
}
|
||||
19
packages/ui/tui/tests/plugin-shape.spec.ts
Normal file
19
packages/ui/tui/tests/plugin-shape.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as tui from '../src/index.ts'
|
||||
|
||||
/** Real Loader export-path guard for the namespace TUI plugin. */
|
||||
describe('dsh-tui plugin export shape', () => {
|
||||
it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
|
||||
expect('default' in tui).toBe(false)
|
||||
expect(typeof tui.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tui)
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
terminal 100x40 buffer=normal length=41 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=37 bufferRow=38
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=green
|
||||
7| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
8| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
9| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
10| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
11| "▌ … 4 more lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
12| "▌ "
|
||||
style 0-0 fg=green
|
||||
13| <blank>
|
||||
14| "▌ "
|
||||
style 0-0 fg=green
|
||||
15| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
16| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
17| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
18| "▌ - keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
19| "▌ … 5 more lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-34 dim
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| <blank>
|
||||
22| "▌ "
|
||||
style 0-0 fg=green
|
||||
23| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
24| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
25| "▌ "
|
||||
style 0-0 fg=green
|
||||
26| <blank>
|
||||
27| "▌ "
|
||||
style 0-0 fg=green
|
||||
28| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
29| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
30| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
31| "▌ "
|
||||
style 0-0 fg=green
|
||||
32| <blank>
|
||||
33| "▌ "
|
||||
style 0-0 fg=green
|
||||
34| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
35| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
36| "▌ "
|
||||
style 0-0 fg=green
|
||||
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
38| " "
|
||||
style 1-1 inverse
|
||||
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
@@ -0,0 +1,127 @@
|
||||
terminal 100x40 buffer=normal length=50 base=10 viewport=10
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=37 bufferRow=47
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=green
|
||||
7| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
8| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
9| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
10| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
11| "▌ 4016 tests passed "
|
||||
style 0-0 fg=green
|
||||
12| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
13| "▌ coverage complete "
|
||||
style 0-0 fg=green
|
||||
14| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
15| "▌ "
|
||||
style 0-0 fg=green
|
||||
16| <blank>
|
||||
17| "▌ "
|
||||
style 0-0 fg=green
|
||||
18| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
19| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
20| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
21| "▌ - keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
22| "▌ + new line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=green
|
||||
23| "▌ + keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=green
|
||||
24| "▌ "
|
||||
style 0-0 fg=green
|
||||
25| "▌ tests/view.spec.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-19 bold
|
||||
26| "▌ + expect(screen).toMatchSnapshot() "
|
||||
style 0-0 fg=green
|
||||
style 2-35 fg=green
|
||||
27| "▌ "
|
||||
style 0-0 fg=green
|
||||
28| <blank>
|
||||
29| "▌ "
|
||||
style 0-0 fg=green
|
||||
30| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
31| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
32| "▌ "
|
||||
style 0-0 fg=green
|
||||
33| <blank>
|
||||
34| "▌ "
|
||||
style 0-0 fg=green
|
||||
35| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
36| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
37| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
38| "▌ "
|
||||
style 0-0 fg=green
|
||||
39| <blank>
|
||||
40| "▌ "
|
||||
style 0-0 fg=green
|
||||
41| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
42| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
43| "▌ "
|
||||
style 0-0 fg=green
|
||||
44| <blank>
|
||||
45| " Tool cards expanded. "
|
||||
style 1-20 fg=bright-black
|
||||
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
47| " "
|
||||
style 1-1 inverse
|
||||
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded"
|
||||
style 0-24 dim
|
||||
style 66-99 dim
|
||||
@@ -0,0 +1,52 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=15 bufferRow=15
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-95 bold
|
||||
8| "▌ const second = await tools.bas "
|
||||
style 0-0 fg=yellow
|
||||
style 2-31 bold
|
||||
9| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ console.log(first, second) "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ return `${first}+${second}` "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| " "
|
||||
style 1-1 inverse
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
18-35| <blank>
|
||||
@@ -0,0 +1,52 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Show the live update. "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| " Reasoning "
|
||||
style 1-9 fg=bright-black italic
|
||||
12| " Inspecting width and styles. "
|
||||
style 1-28 fg=bright-black italic
|
||||
13| <blank>
|
||||
14| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
15| " Streaming visible state… "
|
||||
style 11-23 bold
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| " "
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
20-35| <blank>
|
||||
@@ -0,0 +1,59 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=18 bufferRow=18
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ ◌ Inspect cordis runtime: tools "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-32 bold
|
||||
7| <blank>
|
||||
8| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ ◌ Mount plugin into live cordis runtime "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-40 bold
|
||||
10| "▌ { "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ ready: true }) } }\" "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ } "
|
||||
style 0-0 fg=yellow
|
||||
14| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
15| <blank>
|
||||
16| "▌ ◌ Unmount dyn-1 "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-16 bold
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
21-35| <blank>
|
||||
@@ -0,0 +1,52 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=22 bufferRow=22
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 91-91 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 91-91 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 91-91 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-91 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
@@ -0,0 +1,55 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-95 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 95-95 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 95-95 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 95-95 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-95 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ ◌ workflow: tui-matrix "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-23 bold
|
||||
8| "▌ phase('Inspect') "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ const reports = await parallel([ "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ ]) "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ phase('Verify') "
|
||||
style 0-0 fg=yellow
|
||||
14| "▌ return { reports, verdict: 'covered' } "
|
||||
style 0-0 fg=yellow
|
||||
15| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| " "
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 63-95 dim
|
||||
20-35| <blank>
|
||||
52
packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
Normal file
52
packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=18 bufferRow=18
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 91-91 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 91-91 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 91-91 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-91 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
@@ -0,0 +1,69 @@
|
||||
terminal 56x20 buffer=normal length=20 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=56 viewportRow=13 bufferRow=13
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────╮"
|
||||
style 0-55 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 55-55 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 55-55 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 55-55 fg=bright-blue
|
||||
4| "╰───╭ Coverage ────────────────────────────────────╮───╯"
|
||||
style 0-55 fg=bright-blue
|
||||
5| "────│ Which advanced TUI states belong in the │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-50 bold
|
||||
style 51-51 fg=bright-blue bold
|
||||
style 52-55 dim
|
||||
6| " │ required matrix? │ "
|
||||
style 1-1 inverse
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-21 bold
|
||||
style 51-51 fg=bright-blue
|
||||
7| "────│ │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-6 fg=bright-blue inverse
|
||||
style 7-20 inverse
|
||||
style 21-49 fg=bright-black inverse
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
9| " │ [ ] Workflows — phases and parallel agents │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 21-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
10| " │ [ ] Cordis tools — inspect, mount, and unm │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 24-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
11| " │ 1/4 │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-8 dim
|
||||
style 51-51 fg=bright-blue
|
||||
12| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 dim
|
||||
style 51-51 fg=bright-blue
|
||||
13| " │ Select at least one option, or press C for a │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 fg=red
|
||||
style 51-51 fg=bright-blue
|
||||
14| " ╰──────────────────────────────────────────────╯ "
|
||||
style 4-51 fg=bright-blue
|
||||
15-19| <blank>
|
||||
67
packages/ui/tui/tests/snapshots/question-dialog.expected.txt
Normal file
67
packages/ui/tui/tests/snapshots/question-dialog.expected.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
terminal 56x20 buffer=normal length=20 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=0 viewportRow=19 bufferRow=19
|
||||
viewport
|
||||
0| "╭──────────────────────────────────────────────────────╮"
|
||||
style 0-55 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 55-55 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 55-55 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 55-55 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────╯"
|
||||
style 0-55 fg=bright-blue
|
||||
5| "────╭ Coverage ────────────────────────────────────╮────"
|
||||
style 0-3 dim
|
||||
style 4-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
6| " │ Which advanced TUI states belong in the │ "
|
||||
style 1-1 inverse
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-50 bold
|
||||
style 51-51 fg=bright-blue bold
|
||||
7| "────│ required matrix? │────"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-21 bold
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
8| "/wor│ │:com"
|
||||
style 0-3 dim
|
||||
style 4-4 fg=bright-blue
|
||||
style 51-51 fg=bright-blue
|
||||
style 52-55 dim
|
||||
9| " │ › [ ] Code Mode — run_code programs and capt │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-6 fg=bright-blue inverse
|
||||
style 7-20 inverse
|
||||
style 21-49 fg=bright-black inverse
|
||||
style 51-51 fg=bright-blue
|
||||
10| " │ [ ] Workflows — phases and parallel agents │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 21-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
11| " │ [ ] Cordis tools — inspect, mount, and unm │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 24-49 fg=bright-black
|
||||
style 51-51 fg=bright-blue
|
||||
12| " │ 1/4 │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-8 dim
|
||||
style 51-51 fg=bright-blue
|
||||
13| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
|
||||
style 4-4 fg=bright-blue
|
||||
style 6-49 dim
|
||||
style 51-51 fg=bright-blue
|
||||
14| " ╰──────────────────────────────────────────────╯ "
|
||||
style 4-51 fg=bright-blue
|
||||
15-19| <blank>
|
||||
@@ -0,0 +1,41 @@
|
||||
terminal 44x18 buffer=normal length=18 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=11 bufferRow=11
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────╮"
|
||||
style 0-43 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 43-43 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 43-43 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 43-43 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────╯"
|
||||
style 0-43 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Context · compact "
|
||||
style 1-17 dim
|
||||
7| " Compacted summary: the prior command "
|
||||
style 1-43 fg=bright-black
|
||||
8| " completed and its details were retired "
|
||||
style 1-43 fg=bright-black
|
||||
9| " from the active surface. "
|
||||
style 1-24 fg=bright-black
|
||||
10| "────────────────────────────────────────────"
|
||||
style 0-43 dim
|
||||
11| " "
|
||||
style 1-1 inverse
|
||||
12| "────────────────────────────────────────────"
|
||||
style 0-43 dim
|
||||
13| "/workspace/project ↑0 ↓0 idle reasoning:o"
|
||||
style 0-24 dim
|
||||
style 27-43 dim
|
||||
14-17| <blank>
|
||||
@@ -0,0 +1,37 @@
|
||||
terminal 104x30 buffer=normal length=30 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=9 bufferRow=9
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-103 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 103-103 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 103-103 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 103-103 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-103 fg=bright-blue
|
||||
5| <blank>
|
||||
6| " Context · compact "
|
||||
style 1-17 dim
|
||||
7| " Compacted summary: the prior command completed and its details were retired from the active surface. "
|
||||
style 1-100 fg=bright-black
|
||||
8| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-103 dim
|
||||
9| " "
|
||||
style 1-1 inverse
|
||||
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-103 dim
|
||||
11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 71-103 dim
|
||||
12-29| <blank>
|
||||
@@ -0,0 +1,67 @@
|
||||
terminal 80x24 buffer=normal length=25 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=21 bufferRow=22
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-79 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 79-79 fg=bright-blue
|
||||
2| "│ Snapshot agent ready. │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-22 fg=bright-black
|
||||
style 79-79 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 79-79 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-79 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Old prompt with a long line that exercises wrapping before compaction. "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| "▌ "
|
||||
style 0-0 fg=green
|
||||
12| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
13| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
14| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
15| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
16| "▌ 4016 tests passed "
|
||||
style 0-0 fg=green
|
||||
17| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
18| "▌ coverage complete "
|
||||
style 0-0 fg=green
|
||||
19| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| "────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-79 dim
|
||||
22| " "
|
||||
style 1-1 inverse
|
||||
23| "────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-79 dim
|
||||
24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 47-79 dim
|
||||
106
packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
Normal file
106
packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
Normal file
@@ -0,0 +1,106 @@
|
||||
terminal 100x34 buffer=normal length=40 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
cursor hidden column=100 viewportRow=33 bufferRow=39
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-61 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-35 dim
|
||||
style 99-99 fg=bright-blue
|
||||
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-99 fg=bright-blue
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
8| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=bright-blue
|
||||
9| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
10| <blank>
|
||||
11| " Reasoning "
|
||||
style 1-9 fg=bright-black italic
|
||||
12| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-62 fg=bright-black italic
|
||||
13| <blank>
|
||||
14| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
15| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
16| <blank>
|
||||
17| "▌ "
|
||||
style 0-0 fg=green
|
||||
18| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-61 bold
|
||||
19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 0-0 fg=green
|
||||
style 2-65 fg=bright-black
|
||||
20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ "
|
||||
style 0-0 fg=green
|
||||
style 2-13 dim
|
||||
style 14-85 fg=bright-blue
|
||||
21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ "
|
||||
style 0-0 fg=green
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-76 bold
|
||||
style 85-85 fg=bright-blue
|
||||
22| "▌ [signal SIG\\│ │ "
|
||||
style 0-0 fg=green
|
||||
style 2-13 fg=red
|
||||
style 14-14 fg=bright-blue
|
||||
style 85-85 fg=bright-blue
|
||||
23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ "
|
||||
style 0-0 fg=green
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-16 fg=bright-blue inverse
|
||||
style 17-17 inverse
|
||||
style 18-18 fg=bright-blue inverse
|
||||
style 19-78 inverse
|
||||
style 79-83 fg=bright-black inverse
|
||||
style 85-85 fg=bright-blue
|
||||
24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ "
|
||||
style 14-14 fg=bright-blue
|
||||
style 16-65 dim
|
||||
style 85-85 fg=bright-blue
|
||||
25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ "
|
||||
style 1-13 dim
|
||||
style 14-85 fg=bright-blue
|
||||
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-60 fg=bright-black
|
||||
27| <blank>
|
||||
28| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-75 fg=yellow
|
||||
29| <blank>
|
||||
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-63 fg=red
|
||||
31| <blank>
|
||||
32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
|
||||
style 1-63 fg=red
|
||||
33| <blank>
|
||||
34| "Plan"
|
||||
style 0-3 fg=bright-blue bold
|
||||
35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
style 2-2 fg=yellow
|
||||
36| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
37| " "
|
||||
style 1-1 inverse
|
||||
38| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 67-99 dim
|
||||
499
packages/ui/tui/tests/tui.snapshot.ts
Normal file
499
packages/ui/tui/tests/tui.snapshot.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
createTuiTestHarness,
|
||||
disposeTuiTestHarness,
|
||||
type TuiHarness,
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts'
|
||||
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
const CHECKPOINTS = [
|
||||
'conversation-streaming',
|
||||
'code-mode-pending',
|
||||
'dynamic-workflow-pending',
|
||||
'cordis-tools-pending',
|
||||
'advanced-cards-collapsed',
|
||||
'advanced-cards-expanded',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-validation',
|
||||
'surface-before-compaction',
|
||||
'surface-after-compaction-narrow',
|
||||
'surface-after-compaction-wide',
|
||||
'errors-and-help',
|
||||
'disposed-terminal',
|
||||
] as const
|
||||
|
||||
type Checkpoint = typeof CHECKPOINTS[number]
|
||||
type SnapshotHarness = TuiHarness<HeadlessTerminal, (code: number) => void>
|
||||
|
||||
const observedCheckpoints = new Set<Checkpoint>()
|
||||
|
||||
async function checkpoint(
|
||||
name: Checkpoint,
|
||||
terminal: HeadlessTerminal,
|
||||
options: TerminalSnapshotOptions = {},
|
||||
): Promise<void> {
|
||||
observedCheckpoints.add(name)
|
||||
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
|
||||
const snapshot = await terminal.snapshot(options)
|
||||
const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`)
|
||||
if (REFRESHING) {
|
||||
await mkdir(SNAPSHOTS_DIR, { recursive: true })
|
||||
await writeFile(path, snapshot)
|
||||
}
|
||||
await expect(snapshot).toMatchFileSnapshot(path)
|
||||
}
|
||||
|
||||
async function setupSnapshot(
|
||||
options: TuiHarnessOptions = {},
|
||||
size: { columns?: number; rows?: number } = {},
|
||||
): Promise<SnapshotHarness> {
|
||||
const terminal = new HeadlessTerminal(size.columns ?? 96, size.rows ?? 36)
|
||||
const before = terminal.frames
|
||||
const result = await createTuiTestHarness(terminal, () => {}, {
|
||||
...options,
|
||||
cwd: options.cwd === undefined ? '/workspace/project' : options.cwd,
|
||||
config: Object.assign({
|
||||
welcome: 'Snapshot agent ready.',
|
||||
color: true,
|
||||
title: 'DSH snapshot',
|
||||
}, options.config),
|
||||
})
|
||||
await terminal.waitForFrame(before)
|
||||
return result
|
||||
}
|
||||
|
||||
async function renderAfter(harness: SnapshotHarness, action: () => void): Promise<void> {
|
||||
const before = harness.terminal.frames
|
||||
action()
|
||||
await harness.terminal.waitForFrame(before)
|
||||
}
|
||||
|
||||
async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
|
||||
await disposeTuiTestHarness(harness)
|
||||
await harness.terminal.dispose()
|
||||
}
|
||||
|
||||
async function configureAdvancedTools(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
||||
ctx.provide('workflows', {} as never)
|
||||
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
|
||||
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
|
||||
}
|
||||
|
||||
interface ToolCallFixture {
|
||||
id: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
|
||||
function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): void {
|
||||
appendAssistant(session, calls.map(call => ({
|
||||
type: 'tool-call',
|
||||
id: CallId(call.id),
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
})))
|
||||
for (const call of calls) {
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId(call.id),
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function appendToolResult(
|
||||
session: Session,
|
||||
id: string,
|
||||
content: ContentBlock[],
|
||||
options: { isError?: boolean; meta?: unknown } = {},
|
||||
): void {
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId(id),
|
||||
content,
|
||||
isError: options.isError ?? false,
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function visualTool(
|
||||
name: string,
|
||||
call: NonNullable<ToolDefinition['presentCall']>,
|
||||
result?: NonNullable<ToolDefinition['presentResult']>,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `${name} snapshot fixture`,
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([]),
|
||||
presentCall: call,
|
||||
...result === undefined ? {} : { presentResult: result },
|
||||
}
|
||||
}
|
||||
|
||||
const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
|
||||
bash: visualTool(
|
||||
'bash',
|
||||
() => ({ card: 'terminal', title: 'pnpm run test:coverage', description: 'Run the coverage gate', cwd: '/workspace/project' }),
|
||||
() => ({ card: 'terminal', output: 'packages/ui/tui 100%\n4016 tests passed\n1 test skipped\ncoverage complete', exitCode: 0 }),
|
||||
),
|
||||
edit: visualTool(
|
||||
'edit',
|
||||
() => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }),
|
||||
(): ToolResultView => ({
|
||||
card: 'diff',
|
||||
diffs: [
|
||||
{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' },
|
||||
{ path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
subagent: visualTool('subagent', args => ({
|
||||
card: 'generic',
|
||||
title: 'Delegate renderer audit',
|
||||
rawInput: (args as { prompt: string }).prompt,
|
||||
})),
|
||||
task_output: visualTool('task_output', args => ({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: `Read output from background task ${(args as { task_id: string }).task_id}`,
|
||||
rawInput: (args as { task_id: string }).task_id,
|
||||
})),
|
||||
skill: visualTool('skill', args => ({
|
||||
card: 'generic',
|
||||
kind: 'read',
|
||||
title: `Load skill ${(args as { name: string }).name}`,
|
||||
rawInput: (args as { name: string }).name,
|
||||
})),
|
||||
}
|
||||
|
||||
const CONTROL_PROBE = '\u001b]2;snapshot-controlled\u0007\t\u007f\u009b31m'
|
||||
const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7f\x9b31m`
|
||||
|
||||
describe('TUI terminal-state snapshots', () => {
|
||||
it('pins an in-flight reasoning and Markdown stream', async () => {
|
||||
const harness = await setupSnapshot()
|
||||
await renderAfter(harness, () => {
|
||||
appendUser(harness.session, 'Show the live update.')
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
harness.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
|
||||
})
|
||||
})
|
||||
await checkpoint('conversation-streaming', harness.terminal)
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins Code Mode run_code with its production presenter', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
id: 'code-1',
|
||||
name: 'run_code',
|
||||
arguments: {
|
||||
code: "const first = await tools.bash({ command: 'echo CODE_ONE' })\nconst second = await tools.bash({ command: 'echo CODE_TWO' })\nconsole.log(first, second)\nreturn `${first}+${second}`",
|
||||
},
|
||||
}
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) })
|
||||
await checkpoint('code-mode-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins a dynamic workflow with phases, parallel agents, and structured output', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const call = {
|
||||
id: 'workflow-1',
|
||||
name: 'workflow',
|
||||
arguments: {
|
||||
meta: {
|
||||
name: 'tui-matrix',
|
||||
description: 'Audit terminal states from independent angles',
|
||||
phases: [
|
||||
{ title: 'Inspect', detail: 'Map renderer branches' },
|
||||
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
],
|
||||
},
|
||||
args: { packages: ['ui/tui', 'workflow/tool-workflow'] },
|
||||
script: "phase('Inspect')\nconst reports = await parallel([\n () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }),\n () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }),\n])\nphase('Verify')\nreturn { reports, verdict: 'covered' }",
|
||||
},
|
||||
}
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) })
|
||||
await checkpoint('dynamic-workflow-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => {
|
||||
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
|
||||
const calls = [
|
||||
{ id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } },
|
||||
{
|
||||
id: 'cordis-2',
|
||||
name: 'cordis_mount',
|
||||
arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" },
|
||||
},
|
||||
{ id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } },
|
||||
]
|
||||
await renderAfter(harness, () => { appendToolCalls(harness.session, calls) })
|
||||
await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
const calls = [
|
||||
{ id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } },
|
||||
{ id: 'advanced-2', name: 'edit', arguments: { file_path: 'src/view.ts' } },
|
||||
{ id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } },
|
||||
{ id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } },
|
||||
{ id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } },
|
||||
]
|
||||
await renderAfter(harness, () => {
|
||||
appendToolCalls(harness.session, calls)
|
||||
appendToolResult(harness.session, 'advanced-1', [{ type: 'text', text: 'raw process output' }])
|
||||
appendToolResult(harness.session, 'advanced-2', [{ type: 'text', text: 'edit complete' }])
|
||||
appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }])
|
||||
appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }])
|
||||
appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }])
|
||||
})
|
||||
await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.send('\x0f') })
|
||||
await checkpoint('advanced-cards-expanded', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => {
|
||||
const tools = {
|
||||
unsafe: visualTool(
|
||||
'unsafe',
|
||||
() => ({
|
||||
card: 'terminal',
|
||||
title: `Unsafe title ${CONTROL_PROBE}`,
|
||||
description: `Unsafe description ${CONTROL_PROBE}`,
|
||||
cwd: `/unsafe/${CONTROL_PROBE}`,
|
||||
}),
|
||||
() => ({
|
||||
card: 'terminal',
|
||||
output: `Unsafe output ${CONTROL_PROBE}`,
|
||||
signal: `SIG${CONTROL_PROBE}`,
|
||||
}),
|
||||
),
|
||||
}
|
||||
const harness = await setupSnapshot({
|
||||
tools,
|
||||
config: {
|
||||
welcome: `Unsafe welcome ${CONTROL_PROBE}`,
|
||||
title: `Unsafe terminal title ${CONTROL_PROBE}`,
|
||||
},
|
||||
beforeMount(session) {
|
||||
appendUser(session, `Unsafe user ${CONTROL_PROBE}`)
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: `Unsafe reasoning ${CONTROL_PROBE}` },
|
||||
{ type: 'text', text: `Unsafe assistant ${CONTROL_PROBE}` },
|
||||
])
|
||||
appendToolCalls(session, [{ id: 'unsafe-1', name: 'unsafe', arguments: { value: CONTROL_PROBE } }])
|
||||
appendToolResult(session, 'unsafe-1', [{ type: 'text', text: `Unsafe fallback ${CONTROL_PROBE}` }])
|
||||
session.append('todo/write', {
|
||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||
})
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }],
|
||||
source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('prompt/blocked', {
|
||||
content: [{ type: 'text', text: 'blocked' }],
|
||||
source: { kind: 'user' },
|
||||
reason: `Unsafe policy ${CONTROL_PROBE}`,
|
||||
})
|
||||
session.append('turn/end', {
|
||||
turn: 7,
|
||||
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
|
||||
})
|
||||
},
|
||||
}, { columns: 100, rows: 34 })
|
||||
expect(harness.terminal.title).toContain(DISPLAYED_CONTROL_PROBE)
|
||||
expect(harness.terminal.title).not.toContain('\u001b')
|
||||
expect(harness.terminal.title).not.toContain('\u009b')
|
||||
|
||||
const controller = new AbortController()
|
||||
const beforeQuestion = harness.terminal.frames
|
||||
const answer = harness.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'unsafe-question',
|
||||
header: `Unsafe header ${CONTROL_PROBE}`,
|
||||
question: `Unsafe question ${CONTROL_PROBE}`,
|
||||
options: [{ label: `Unsafe option ${CONTROL_PROBE}`, description: `Unsafe detail ${CONTROL_PROBE}` }],
|
||||
}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await harness.terminal.waitForFrame(beforeQuestion)
|
||||
await renderAfter(harness, () => {
|
||||
harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
|
||||
})
|
||||
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
|
||||
|
||||
controller.abort()
|
||||
await rejected
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins a constrained multi-select question and its validation state', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
config: {
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 48,
|
||||
questionDialogMaxHeight: 16,
|
||||
},
|
||||
}, { columns: 56, rows: 20 })
|
||||
const controller = new AbortController()
|
||||
const beforeQuestion = harness.terminal.frames
|
||||
const answer = harness.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'coverage',
|
||||
header: 'Coverage',
|
||||
question: 'Which advanced TUI states belong in the required matrix?',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: 'Code Mode', description: 'run_code programs and captured output' },
|
||||
{ label: 'Workflows', description: 'phases and parallel agents' },
|
||||
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
|
||||
{ label: 'Compaction', description: 'surface replacement and reflow' },
|
||||
],
|
||||
}],
|
||||
signal: controller.signal,
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await harness.terminal.waitForFrame(beforeQuestion)
|
||||
await checkpoint('question-dialog', harness.terminal)
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.send('\r') })
|
||||
await checkpoint('question-dialog-validation', harness.terminal)
|
||||
controller.abort()
|
||||
await rejected
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
|
||||
let replacementStart = 0
|
||||
let replacementEnd = 0
|
||||
let replacementSources: number[] = []
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
beforeMount(session) {
|
||||
const user = session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
callId: CallId('old-tool'),
|
||||
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
replacementStart = user.seq
|
||||
replacementEnd = result.seq
|
||||
replacementSources = [user.seq, assistant.seq, result.seq]
|
||||
},
|
||||
}, { columns: 80, rows: 24 })
|
||||
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => {
|
||||
harness.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
|
||||
sourceEventSeqs: replacementSources,
|
||||
})
|
||||
harness.terminal.resize(44, 18)
|
||||
})
|
||||
await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await renderAfter(harness, () => { harness.terminal.resize(104, 30) })
|
||||
await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true })
|
||||
await disposeSnapshot(harness)
|
||||
})
|
||||
|
||||
it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => {
|
||||
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
|
||||
await renderAfter(harness, () => {
|
||||
harness.terminal.send('/help')
|
||||
harness.terminal.send('\r')
|
||||
harness.terminal.send('/unknown-advanced-command')
|
||||
harness.terminal.send('\r')
|
||||
harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output'))
|
||||
harness.session.append('turn/end', {
|
||||
turn: 3,
|
||||
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
|
||||
})
|
||||
harness.session.append('turn/end', {
|
||||
turn: 4,
|
||||
reason: { kind: 'interrupted' },
|
||||
})
|
||||
})
|
||||
await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true })
|
||||
|
||||
await harness.controller.dispose()
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true })
|
||||
await harness.ctx.fiber.dispose()
|
||||
await harness.terminal.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
expect([...observedCheckpoints].sort()).toEqual([...CHECKPOINTS].sort())
|
||||
const files = (await readdir(SNAPSHOTS_DIR))
|
||||
.filter(file => file.endsWith('.expected.txt'))
|
||||
.sort()
|
||||
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.expected.txt`).sort())
|
||||
})
|
||||
939
packages/ui/tui/tests/tui.spec.ts
Normal file
939
packages/ui/tui/tests/tui.spec.ts
Normal file
@@ -0,0 +1,939 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
createTuiChat,
|
||||
mountTui,
|
||||
resolveTuiConfig,
|
||||
type TuiRuntime,
|
||||
} from '../src/index.ts'
|
||||
import {
|
||||
appendAssistant,
|
||||
appendUser,
|
||||
createTuiTestHarness,
|
||||
disposeTuiTestHarness,
|
||||
type TuiHarnessOptions,
|
||||
} from './harness.ts'
|
||||
|
||||
class FakeTerminal implements Terminal {
|
||||
columns = 88
|
||||
rows = 32
|
||||
kittyProtocolActive = false
|
||||
output = ''
|
||||
title = ''
|
||||
progress: boolean[] = []
|
||||
started = 0
|
||||
stopped = 0
|
||||
drainInput = vi.fn(() => Promise.resolve())
|
||||
private onInput: (data: string) => void = () => {}
|
||||
private onResize: () => void = () => {}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.started += 1
|
||||
this.onInput = onInput
|
||||
this.onResize = onResize
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped += 1
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.output += data
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
this.output += `[move:${lines}]`
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.output += '[hide]'
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.output += '[show]'
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.output += '[clear-line]'
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.output += '[clear-rest]'
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.output += '[clear-screen]'
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this.title = title
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
this.progress.push(active)
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.onInput(data)
|
||||
}
|
||||
|
||||
resize(columns: number, rows = this.rows): void {
|
||||
this.columns = columns
|
||||
this.rows = rows
|
||||
this.onResize()
|
||||
}
|
||||
}
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
async function setup(options: TuiHarnessOptions = {}) {
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
const result = await createTuiTestHarness(terminal, exit, {
|
||||
...options,
|
||||
cwd: options.cwd === undefined ? process.cwd() : options.cwd,
|
||||
})
|
||||
await tick()
|
||||
return result
|
||||
}
|
||||
|
||||
async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
|
||||
await disposeTuiTestHarness(setupResult)
|
||||
}
|
||||
|
||||
describe('TUI config', () => {
|
||||
it('defaults every direct-call TUI option', () => {
|
||||
expect(resolveTuiConfig(undefined)).toEqual({
|
||||
showReasoning: true,
|
||||
maxToolOutputLines: 12,
|
||||
maxQuestionOptions: 8,
|
||||
questionDialogWidth: 72,
|
||||
questionDialogMaxHeight: 20,
|
||||
showHardwareCursor: false,
|
||||
color: true,
|
||||
title: 'DeepSeek Harness',
|
||||
})
|
||||
expect(resolveTuiConfig({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
})).toEqual({
|
||||
showReasoning: false,
|
||||
maxToolOutputLines: 2,
|
||||
maxQuestionOptions: 3,
|
||||
questionDialogWidth: 60,
|
||||
questionDialogMaxHeight: 14,
|
||||
showHardwareCursor: true,
|
||||
color: false,
|
||||
title: 'DSH',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi-tui chat lifecycle and transcript', () => {
|
||||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'restored prompt')
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: 'restored thought' },
|
||||
{ type: 'text', text: '**restored answer**' },
|
||||
], { inputTokens: 1_250, outputTokens: 42 })
|
||||
session.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'read code', status: 'completed' },
|
||||
{ content: 'write tests', status: 'in_progress' },
|
||||
{ content: 'ship', status: 'pending' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.started).toBe(1)
|
||||
expect(result.terminal.title).toBe('DeepSeek Harness')
|
||||
expect(result.terminal.output).toContain('DEEPSEEK')
|
||||
expect(result.terminal.output).toContain('Coding agent ready.')
|
||||
expect(result.terminal.output).toContain('restored prompt')
|
||||
expect(result.terminal.output).toContain('restored thought')
|
||||
expect(result.terminal.output).toContain('restored answer')
|
||||
expect(result.terminal.output).toContain('write tests')
|
||||
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.ctx.emit('agent/status', result.agent, 'running')
|
||||
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
|
||||
appendAssistant(result.session, [])
|
||||
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
|
||||
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
|
||||
result.session.append('step/start', { turn: 11, step: 0 })
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
||||
})
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('live thought')
|
||||
result.terminal.send('\x12')
|
||||
await tick()
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 })
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Working')
|
||||
expect(result.terminal.output).toContain('Steering')
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Prompt blocked')
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
|
||||
result.session.append('assistant/chunk', {
|
||||
turn: 3,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
|
||||
})
|
||||
result.terminal.send('/clear')
|
||||
result.terminal.send('\r')
|
||||
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('answer after clear')
|
||||
|
||||
result.agent.status = 'idle'
|
||||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||||
await tick()
|
||||
expect(result.terminal.progress.at(-1)).toBe(false)
|
||||
await dispose(result)
|
||||
expect(result.terminal.stopped).toBe(1)
|
||||
expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
|
||||
})
|
||||
|
||||
it('renders the ANSI palette and every markdown/content style', async () => {
|
||||
const result = await setup({
|
||||
config: { color: true },
|
||||
beforeMount(session) {
|
||||
session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
|
||||
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
|
||||
{ type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
|
||||
{ type: 'future-block' } as never,
|
||||
{} as never,
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendAssistant(session, [
|
||||
{ type: 'reasoning', text: 'styled reasoning' },
|
||||
{ type: 'text', text: 'styled answer' },
|
||||
], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
|
||||
session.append('todo/write', { todos: [
|
||||
{ content: 'done', status: 'completed' },
|
||||
{ content: 'active', status: 'in_progress' },
|
||||
{ content: 'later', status: 'pending' },
|
||||
] })
|
||||
},
|
||||
})
|
||||
result.terminal.send('/')
|
||||
await tick()
|
||||
result.terminal.send('zz')
|
||||
await tick()
|
||||
result.terminal.send('\x0c')
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('\x1b[')
|
||||
expect(result.terminal.output).toContain('Heading')
|
||||
expect(result.terminal.output).toContain('nested_tool({})')
|
||||
expect(result.terminal.output).toContain('nested result')
|
||||
expect(result.terminal.output).toContain('[future-block]')
|
||||
expect(result.terminal.output).toContain('[content]')
|
||||
expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => {
|
||||
const result = await setup({
|
||||
beforeMount(session) {
|
||||
appendUser(session, 'first prompt')
|
||||
appendUser(session, 'second prompt')
|
||||
session.append('assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.terminal.output).not.toContain('stale partial response')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]])
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('formats large token totals and cwd variants', async () => {
|
||||
const home = homedir()
|
||||
const homeResult = await setup({
|
||||
cwd: home,
|
||||
beforeMount(session) {
|
||||
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
|
||||
},
|
||||
})
|
||||
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
|
||||
await dispose(homeResult)
|
||||
|
||||
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
|
||||
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
||||
await dispose(childResult)
|
||||
|
||||
const unsetResult = await setup({ cwd: null })
|
||||
expect(unsetResult.terminal.output).toContain('cwd unset')
|
||||
await dispose(unsetResult)
|
||||
|
||||
const outsideResult = await setup({ cwd: '/opt' })
|
||||
expect(outsideResult.terminal.output).toContain('/opt')
|
||||
await dispose(outsideResult)
|
||||
})
|
||||
|
||||
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
|
||||
const result = await setup()
|
||||
|
||||
result.terminal.send('do the work')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]])
|
||||
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
|
||||
result.agent.status = 'running'
|
||||
result.terminal.send('steer it')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
|
||||
|
||||
result.terminal.send('\x1b')
|
||||
result.terminal.send('\x04')
|
||||
result.terminal.send('\x03')
|
||||
result.terminal.send('\x12')
|
||||
result.terminal.send('\x0f')
|
||||
result.terminal.send('/cancel')
|
||||
result.terminal.send('\r')
|
||||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||||
|
||||
result.agent.status = 'idle'
|
||||
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
}
|
||||
for (const command of ['/clear', '/cancel', '/wat']) {
|
||||
result.terminal.send(command)
|
||||
result.terminal.send('\r')
|
||||
}
|
||||
await tick()
|
||||
result.terminal.send('draft')
|
||||
result.terminal.send('\x03')
|
||||
result.terminal.send('\x04')
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).toContain('Keyboard shortcuts')
|
||||
expect(result.terminal.output).toContain('Reasoning blocks')
|
||||
expect(result.terminal.output).toContain('Tool cards')
|
||||
expect(result.terminal.output).toContain('already idle')
|
||||
expect(result.terminal.output).toContain('Unknown command')
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
await result.controller.dispose()
|
||||
await result.ctx.fiber.dispose()
|
||||
|
||||
const ctrlCExit = await setup()
|
||||
ctrlCExit.terminal.send('\x03')
|
||||
await tick()
|
||||
expect(ctrlCExit.exit).toHaveBeenCalledWith(0)
|
||||
await ctrlCExit.controller.dispose()
|
||||
await ctrlCExit.ctx.fiber.dispose()
|
||||
|
||||
const disposedAgent = await setup()
|
||||
disposedAgent.agent.status = 'disposed'
|
||||
disposedAgent.terminal.send('late input')
|
||||
disposedAgent.terminal.send('\r')
|
||||
await tick()
|
||||
expect(disposedAgent.terminal.output).toContain('is disposed')
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('cancels before /exit while running and handles agent errors/disposal', async () => {
|
||||
const result = await setup({ status: 'running' })
|
||||
result.terminal.send('/exit')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||||
expect(result.exit).toHaveBeenCalledWith(0)
|
||||
|
||||
const events = await setup()
|
||||
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
|
||||
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
|
||||
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
||||
events.ctx.emit('agent/status', unrelatedAgent, 'running')
|
||||
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
|
||||
events.ctx.emit('agent/disposed', unrelatedAgent)
|
||||
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
|
||||
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
|
||||
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
||||
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted' } })
|
||||
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
|
||||
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
|
||||
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
|
||||
events.ctx.emit('agent/disposed', events.agent)
|
||||
await tick()
|
||||
expect(events.terminal.output).toContain('live failure')
|
||||
expect(events.terminal.output).toContain('durable failure')
|
||||
expect(events.terminal.output).toContain('Turn cancelled')
|
||||
expect(events.terminal.output).toContain('output-token limit')
|
||||
expect(events.terminal.output).toContain('Turn rejected')
|
||||
expect(events.terminal.output).toContain('previous process ended')
|
||||
expect(events.terminal.output).toContain('was disposed')
|
||||
await dispose(events)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool cards and surface replay', () => {
|
||||
const tools: Record<string, ToolDefinition> = {
|
||||
bash: {
|
||||
name: 'bash', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
|
||||
},
|
||||
signal: {
|
||||
name: 'signal', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
|
||||
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
|
||||
},
|
||||
edit: {
|
||||
name: 'edit', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({
|
||||
card: 'diff',
|
||||
title: 'Edit files',
|
||||
diffs: [
|
||||
{ path: 'a.txt', oldText: 'old', newText: 'new' },
|
||||
{ path: 'b.txt', oldText: 'before', newText: 'after' },
|
||||
],
|
||||
}),
|
||||
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
|
||||
},
|
||||
generic: {
|
||||
name: 'generic', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
||||
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
|
||||
},
|
||||
throwing: {
|
||||
name: 'throwing', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => { throw new Error('call presenter boom') },
|
||||
presentResult: () => { throw new Error('result presenter boom') },
|
||||
},
|
||||
rawTerminal: {
|
||||
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
|
||||
},
|
||||
undefinedViews: {
|
||||
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => undefined,
|
||||
presentResult: () => undefined,
|
||||
},
|
||||
empty: {
|
||||
name: 'empty', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
|
||||
},
|
||||
terminalResult: {
|
||||
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||||
},
|
||||
symbolic: {
|
||||
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||||
},
|
||||
}
|
||||
|
||||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
|
||||
const calls = [
|
||||
['c1', 'bash', '{"command":"printf hello"}'],
|
||||
['c2', 'signal', '{}'],
|
||||
['c3', 'edit', '{}'],
|
||||
['c4', 'generic', '{}'],
|
||||
['c5', 'throwing', '{}'],
|
||||
['c6', 'unknown', 'not-json'],
|
||||
['c7', 'rawTerminal', '{"value":"raw"}'],
|
||||
['c8', 'undefinedViews', '{"value":8}'],
|
||||
['c10', 'empty', '{}'],
|
||||
['c11', 'terminalResult', '{}'],
|
||||
['c12', 'symbolic', '{}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
...calls.map(([id, name, args]) => ({
|
||||
type: 'tool-call' as const, id: id as never, name, arguments: args,
|
||||
})),
|
||||
])
|
||||
for (const [id, name, args] of calls) {
|
||||
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
|
||||
}
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('$ raw command')
|
||||
result.terminal.send('/reasoning')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('call presenter boom')
|
||||
expect(result.terminal.output).toContain('Symbol(input)')
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
|
||||
meta: { value: 1 },
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c7' as never,
|
||||
content: [
|
||||
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
|
||||
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
|
||||
{ type: 'future-result' } as never,
|
||||
],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
await tick()
|
||||
|
||||
const output = result.terminal.output
|
||||
expect(output).toContain('Run command')
|
||||
expect(output).toContain('printf hello')
|
||||
expect(output).toContain('more lines')
|
||||
expect(output).toContain('SIGTERM')
|
||||
expect(output).toContain('Edit files')
|
||||
expect(output).toContain('Inspected')
|
||||
expect(output).toContain('result text')
|
||||
expect(output).toContain('Presenter failed')
|
||||
expect(output).toContain('not-json')
|
||||
expect(output).toContain('nested output')
|
||||
expect(output).toContain('[future-result]')
|
||||
expect(output).toContain('undefined presenter output')
|
||||
expect(output).toContain('Empty card')
|
||||
expect(output).toContain('converted terminal')
|
||||
expect(output).toContain('orphan result')
|
||||
|
||||
result.terminal.send('/redraw')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
result.terminal.send('\x0f')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('world')
|
||||
expect(result.terminal.output).toContain('+ created')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
||||
const result = await setup({ tools })
|
||||
appendUser(result.session, 'old prompt')
|
||||
const assistant = result.session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/call', {
|
||||
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
|
||||
})
|
||||
const toolResult = result.session.append('tool/result', {
|
||||
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const start = result.session.surface.nodes[0] as number
|
||||
result.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'summary replacement' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end: toolResult.seq },
|
||||
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
|
||||
})
|
||||
await tick()
|
||||
|
||||
result.terminal.resize(89)
|
||||
await tick()
|
||||
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||||
expect(lastFullRender).toContain('summary replacement')
|
||||
expect(lastFullRender).not.toContain('old output')
|
||||
await dispose(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI user-interaction dialogs', () => {
|
||||
it('answers single-select, multi-select, custom, and optionless questions', async () => {
|
||||
const result = await setup({ config: { maxQuestionOptions: 1 } })
|
||||
|
||||
const single = result.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode', header: 'Mode', question: 'Choose a mode',
|
||||
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
|
||||
}],
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Choose a mode')
|
||||
expect(result.terminal.output).toContain('1/2')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\r')
|
||||
await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] })
|
||||
|
||||
const multi = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })
|
||||
|
||||
const custom = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('c')
|
||||
result.terminal.send('my choice')
|
||||
result.terminal.send('\r')
|
||||
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
|
||||
|
||||
const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] })
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Enter an answer before submitting')
|
||||
result.terminal.send('ship it')
|
||||
result.terminal.send('\r')
|
||||
await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('handles option wrapping, deselection errors, and returning from custom input', async () => {
|
||||
const result = await setup({ config: { color: true } })
|
||||
const single = result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }],
|
||||
})
|
||||
const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Two')
|
||||
result.terminal.send('\x03')
|
||||
await singleRejected
|
||||
|
||||
const answer = result.ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'options',
|
||||
question: 'Exercise options',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
|
||||
}],
|
||||
})
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[B')
|
||||
result.terminal.send('\x1b[A')
|
||||
result.terminal.send(' ')
|
||||
await tick()
|
||||
result.terminal.send('x')
|
||||
result.terminal.send(' ')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Select at least one option')
|
||||
result.terminal.send('c')
|
||||
await tick()
|
||||
result.terminal.send('\x1b')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Space toggle')
|
||||
result.terminal.send('\x03')
|
||||
await rejected
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('asks batches in order and rejects cancelled or aborted work', async () => {
|
||||
const result = await setup()
|
||||
const preAborted = new AbortController()
|
||||
preAborted.abort()
|
||||
await expect(result.ctx.userInteraction.ask({
|
||||
questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }],
|
||||
signal: preAborted.signal,
|
||||
})).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
const batch = result.ctx.userInteraction.ask({
|
||||
questions: [
|
||||
{ id: 'first', question: 'First?', options: [{ label: 'Yes' }] },
|
||||
{ id: 'second', question: 'Second?' },
|
||||
],
|
||||
})
|
||||
await tick()
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Second?')
|
||||
result.terminal.send('done')
|
||||
result.terminal.send('\r')
|
||||
await expect(batch).resolves.toEqual({ answers: [
|
||||
{ id: 'first', selected: ['Yes'] },
|
||||
{ id: 'second', selected: [], custom: 'done' },
|
||||
] })
|
||||
|
||||
const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] })
|
||||
const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
result.terminal.send('\x1b')
|
||||
await cancelledExpectation
|
||||
|
||||
const controller = new AbortController()
|
||||
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal })
|
||||
const queuedController = new AbortController()
|
||||
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal })
|
||||
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
queuedController.abort()
|
||||
controller.abort()
|
||||
await activeExpectation
|
||||
await queuedExpectation
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rejects active and queued dialogs on disposal', async () => {
|
||||
const result = await setup()
|
||||
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
|
||||
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
|
||||
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await tick()
|
||||
await result.controller.dispose()
|
||||
await activeExpectation
|
||||
await queuedExpectation
|
||||
await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal mounting', () => {
|
||||
it('starts immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
|
||||
await tick()
|
||||
expect(terminal.started).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for its configured agent before starting the TUI', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
mountTui(ctx, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() })
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||
ctx.agents.register({
|
||||
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
expect(terminal.started).toBe(0)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('late-session'))
|
||||
const agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
await tick()
|
||||
expect(terminal.started).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit })
|
||||
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed'))
|
||||
expect(terminal.output).toBe('')
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
||||
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('main-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.started).toBe(0)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('renders an uncoercible startup failure without escaping the display boundary', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
|
||||
mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit })
|
||||
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), {
|
||||
toString(): string { throw new Error('coercion failed') },
|
||||
})
|
||||
|
||||
expect(terminal.started).toBe(0)
|
||||
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable value>\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||||
ctx.agents.register({
|
||||
id: session.id, options: {}, session, status: 'running', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
})
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||||
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
|
||||
.toThrow('terminal startup failed')
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
|
||||
})
|
||||
await tick()
|
||||
expect(terminal.output).not.toContain('must not render')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('throws when createTuiChat is called without the configured agent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user