diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 2147dfb222..c55b3d355c 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -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[] { diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 26cee8aea2..f9b2ec1d43 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -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.') diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 865a20a469..cf4cc609ce 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -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 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b9b74e7c9a..612f2f3099 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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['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) }, }) diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt new file mode 100644 index 0000000000..1a64b277d5 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt @@ -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| +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| +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| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| +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| +28| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-75 fg=yellow +29| +30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-63 fg=red +31| +32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-63 fg=red +33| +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 diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 0967e7f64c..e0016184fd 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -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 = { })), } +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: { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 5fc11cbe40..94a2447d5f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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'))