mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(tui): neutralize terminal controls at display boundary
Model responses, replayed session data, tool presenter output, question metadata, configuration, and diagnostics all cross into ANSI-aware pi-tui renderers. Passing their C0 or C1 controls through unchanged lets an otherwise ordinary transcript emit OSC, CSI, cursor, or title operations in the user terminal. Introduce one displayText boundary that preserves line-feed layout but renders every other C0/C1 control as visible \\xNN text before application styling is applied. Route transcript blocks, streaming output, tool cards, diffs, plans, dialogs, headers, cwd/title data, notices, errors, and pre-mount startup failures through that boundary while leaving pi-tui and the theme responsible for legitimate terminal control sequences. Pin the contract at three levels: a settled headless-terminal golden spans the main untrusted display sources, unit coverage checks the pre-fullscreen failure path, and the real Loader/PTY conversation streams hostile OSC, cursor, and C1 probes and proves only their inert textual forms reach the terminal stream.
This commit is contained in:
@@ -2,7 +2,8 @@ import type { Context } from 'cordis'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const INITIAL_TEXT = 'I need one decision before I continue.'
|
||||
const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1'
|
||||
const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}`
|
||||
const FINAL_TEXT = 'Decision received. Scripted TUI run complete.'
|
||||
|
||||
function textChunks(text: string): StreamChunk[] {
|
||||
|
||||
@@ -153,6 +153,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => {
|
||||
const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' })
|
||||
expect(output).toContain('I need one decision before I continue.')
|
||||
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
|
||||
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
|
||||
expect(output).toContain(String.raw`\x9b31mMODEL_C1`)
|
||||
expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007')
|
||||
expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
|
||||
expect(output).not.toContain('\u009B31mMODEL_C1')
|
||||
expect(output).toContain('How should the scripted run proceed?')
|
||||
expect(output).toContain('Safe')
|
||||
expect(output).toContain('Decision received. Scripted TUI run complete.')
|
||||
|
||||
@@ -6,6 +6,8 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -179,6 +179,17 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) =>
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
}
|
||||
|
||||
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
|
||||
|
||||
/**
|
||||
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
|
||||
* Line feeds remain structural so transcript and tool output retain their layout.
|
||||
*/
|
||||
function displayText(text: string): string {
|
||||
return text.replace(TERMINAL_CONTROL_PATTERN, control =>
|
||||
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
@@ -281,11 +292,11 @@ class HeaderComponent implements Component {
|
||||
render(width: number): string[] {
|
||||
const usable = Math.max(1, width - 4)
|
||||
const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
|
||||
const model = this.agent.options.model ?? 'model unset'
|
||||
const detail = `${this.agent.id} • ${model} • ${this.agent.session.id}`
|
||||
const model = displayText(this.agent.options.model ?? 'model unset')
|
||||
const detail = `${displayText(this.agent.id)} • ${model} • ${displayText(this.agent.session.id)}`
|
||||
const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
|
||||
const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
|
||||
const lines = [title, this.palette.muted(this.welcome), this.palette.dim(detail)]
|
||||
const lines = [title, this.palette.muted(displayText(this.welcome)), this.palette.dim(detail)]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
.map((line) => {
|
||||
const clipped = truncateToWidth(line, usable, '')
|
||||
@@ -330,8 +341,8 @@ class GutterBox implements Component {
|
||||
class UserMessageComponent extends GutterBox {
|
||||
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
|
||||
super(value => palette.accent(value))
|
||||
this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0))
|
||||
this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, {
|
||||
this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0))
|
||||
this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
|
||||
preserveOrderedListMarkers: true,
|
||||
preserveBackslashEscapes: true,
|
||||
}))
|
||||
@@ -341,8 +352,8 @@ class UserMessageComponent extends GutterBox {
|
||||
class AssistantMessageComponent extends Container {
|
||||
constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
|
||||
super()
|
||||
const reasoning = textBlocks(content, 'reasoning').trim()
|
||||
const text = textBlocks(content, 'text').trim()
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
if (reasoning && showReasoning) {
|
||||
this.addChild(new Spacer(1))
|
||||
this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
|
||||
@@ -422,19 +433,19 @@ function parseArguments(raw: string): ParsedArguments {
|
||||
}
|
||||
|
||||
function pretty(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'string') return displayText(value)
|
||||
// The lib declaration narrows `unknown` to a string-returning overload, but
|
||||
// JSON.stringify returns undefined for runtime values such as symbols.
|
||||
const serialized = JSON.stringify(value, null, 2) as string | undefined
|
||||
return serialized ?? String(value)
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
const lines = [palette.bold(diff.path)]
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of diff.oldText.split('\n')) lines.push(palette.removed(`- ${line}`))
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
|
||||
}
|
||||
for (const line of diff.newText.split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -460,10 +471,10 @@ class ToolCardComponent implements Component {
|
||||
const view = this.definition.presentCall(this.parsed.value)
|
||||
if (view !== undefined) return view
|
||||
} catch (error: unknown) {
|
||||
return { card: 'generic', title: this.name, rawInput: `Presenter failed: ${String(error)}` }
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
|
||||
}
|
||||
}
|
||||
return { card: 'generic', title: this.name, rawInput: this.parsed.value }
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
|
||||
}
|
||||
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
@@ -492,7 +503,7 @@ class ToolCardComponent implements Component {
|
||||
const isError = this.result?.isError ?? false
|
||||
const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
|
||||
const body = this.renderBody()
|
||||
const title = truncateToWidth(`${glyph} ${this.title()}`, Math.max(1, width - 4), '')
|
||||
const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
|
||||
const visibleBody = this.expanded || body.length <= this.maxOutputLines
|
||||
? body
|
||||
: [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)]
|
||||
@@ -514,17 +525,19 @@ class ToolCardComponent implements Component {
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.callView.card === 'terminal' ? this.callView : undefined
|
||||
const lines: string[] = []
|
||||
if (pending?.description) lines.push(this.palette.muted(pending.description))
|
||||
if (pending?.cwd) lines.push(this.palette.dim(pending.cwd))
|
||||
if (pending?.description) lines.push(this.palette.muted(displayText(pending.description)))
|
||||
if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd)))
|
||||
if (this.resultView?.card === 'terminal') {
|
||||
if (this.resultView.output) lines.push(...this.resultView.output.split('\n'))
|
||||
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
|
||||
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
|
||||
if (this.resultView.signal !== undefined) lines.push(this.palette.error(`[signal ${this.resultView.signal}]`))
|
||||
if (this.resultView.signal !== undefined) {
|
||||
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
|
||||
}
|
||||
} else if (this.result === undefined) {
|
||||
// A pending terminal view is the call view itself; TerminalCallView requires a title.
|
||||
lines.push(this.palette.code(`$ ${(pending as TerminalCallView).title}`))
|
||||
lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`))
|
||||
} else {
|
||||
lines.push(...contentText(this.result.content).split('\n'))
|
||||
lines.push(...displayText(contentText(this.result.content)).split('\n'))
|
||||
}
|
||||
return lines.filter(Boolean)
|
||||
}
|
||||
@@ -536,7 +549,7 @@ class ToolCardComponent implements Component {
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
const lines: string[] = []
|
||||
if (content !== undefined) lines.push(...contentText(content).split('\n'))
|
||||
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
|
||||
const rawInput = this.result === undefined && this.callView.card === 'generic'
|
||||
? this.callView.rawInput
|
||||
: undefined
|
||||
@@ -565,7 +578,8 @@ class TodoComponent implements Component {
|
||||
: todo.status === 'in_progress'
|
||||
? this.palette.warning('●')
|
||||
: this.palette.dim('○')
|
||||
const text = todo.status === 'completed' ? this.palette.muted(todo.content) : todo.content
|
||||
const content = displayText(todo.content)
|
||||
const text = todo.status === 'completed' ? this.palette.muted(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
@@ -584,8 +598,8 @@ function formatCwd(cwd: string | undefined): string {
|
||||
const home = homedir()
|
||||
const rel = relative(resolve(home), resolve(cwd))
|
||||
if (rel === '') return '~'
|
||||
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
|
||||
return cwd
|
||||
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`)
|
||||
return displayText(cwd)
|
||||
}
|
||||
|
||||
function sessionTokens(session: Session): { input: number; output: number } {
|
||||
@@ -702,7 +716,7 @@ class QuestionDialog implements Component, Focusable {
|
||||
render(width: number): string[] {
|
||||
this.input.focused = this.focused
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const title = this.question.header ?? 'Question'
|
||||
const title = displayText(this.question.header ?? 'Question')
|
||||
const topLabel = ` ${title} `
|
||||
const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
|
||||
const lines: string[] = [this.palette.accent(top)]
|
||||
@@ -710,7 +724,7 @@ class QuestionDialog implements Component, Focusable {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`)
|
||||
}
|
||||
for (const line of wrapTextWithAnsi(this.palette.bold(this.question.question), innerWidth)) push(line)
|
||||
for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line)
|
||||
push('')
|
||||
if (this.mode === 'custom') {
|
||||
for (const line of this.input.render(innerWidth)) push(line)
|
||||
@@ -729,8 +743,10 @@ class QuestionDialog implements Component, Focusable {
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? this.palette.success('[x]') : '[ ]'
|
||||
: index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○')
|
||||
const description = option.description ? this.palette.muted(` — ${option.description}`) : ''
|
||||
const line = `${cursor} ${mark} ${option.label}${description}`
|
||||
const description = option.description
|
||||
? this.palette.muted(` — ${displayText(option.description)}`)
|
||||
: ''
|
||||
const line = `${cursor} ${mark} ${displayText(option.label)}${description}`
|
||||
push(index === this.selectedIndex ? this.palette.selected(line) : line)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
@@ -826,7 +842,7 @@ export function createTuiChat(
|
||||
ui.addChild(editor)
|
||||
ui.addChild(footer)
|
||||
ui.setFocus(editor)
|
||||
runtime.terminal.setTitle(resolved.title)
|
||||
runtime.terminal.setTitle(displayText(resolved.title))
|
||||
|
||||
const requestRender = (): void => {
|
||||
footer.invalidate()
|
||||
@@ -836,7 +852,7 @@ export function createTuiChat(
|
||||
const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
|
||||
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(color(message), 1, 0))
|
||||
chat.addChild(new Text(color(displayText(message)), 1, 0))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
@@ -876,7 +892,7 @@ export function createTuiChat(
|
||||
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = contentText(event.data.content).trim()
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
|
||||
@@ -885,7 +901,7 @@ export function createTuiChat(
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = contentText(event.data.content).trim()
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
|
||||
@@ -893,11 +909,11 @@ export function createTuiChat(
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const text = contentText(event.data.content).trim()
|
||||
const text = displayText(contentText(event.data.content).trim())
|
||||
if (text) {
|
||||
const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${source}`), 1, 0))
|
||||
chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0))
|
||||
chat.addChild(new Text(palette.muted(text), 1, 0))
|
||||
}
|
||||
break
|
||||
@@ -1291,7 +1307,7 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
|
||||
}, 'ui-tui')
|
||||
},
|
||||
onFailed: (error) => {
|
||||
runtime.terminal.write(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`)
|
||||
runtime.terminal.write(displayText(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`))
|
||||
runtime.exit(1)
|
||||
},
|
||||
})
|
||||
|
||||
106
packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt
Normal file
106
packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt
Normal file
@@ -0,0 +1,106 @@
|
||||
terminal 100x34 buffer=normal length=40 base=6 viewport=6
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
|
||||
cursor hidden column=100 viewportRow=33 bufferRow=39
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-99 fg=bright-blue
|
||||
1| "│ DEEPSEEK HARNESS │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-9 fg=bright-blue bold
|
||||
style 11-17 bold
|
||||
style 99-99 fg=bright-blue
|
||||
2| "│ Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-61 fg=bright-black
|
||||
style 99-99 fg=bright-blue
|
||||
3| "│ main • deepseek-v4-flash • main-session │"
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-44 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
|
||||
@@ -29,6 +29,7 @@ const CHECKPOINTS = [
|
||||
'cordis-tools-pending',
|
||||
'advanced-cards-collapsed',
|
||||
'advanced-cards-expanded',
|
||||
'untrusted-controls',
|
||||
'question-dialog',
|
||||
'question-dialog-validation',
|
||||
'surface-before-compaction',
|
||||
@@ -188,6 +189,9 @@ const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
|
||||
})),
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -298,6 +302,82 @@ describe('TUI terminal-state snapshots', () => {
|
||||
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: {
|
||||
|
||||
@@ -856,8 +856,8 @@ describe('terminal mounting', () => {
|
||||
ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed'))
|
||||
expect(terminal.output).toBe('')
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed'))
|
||||
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume failed\n')
|
||||
ctx.agents.reportStartFailure(AgentId('main'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
||||
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('must-not-start'))
|
||||
|
||||
Reference in New Issue
Block a user