diff --git a/packages/tui/src/observability/local-observability.ts b/packages/tui/src/observability/local-observability.ts index 28bbfa224..20eb56eac 100644 --- a/packages/tui/src/observability/local-observability.ts +++ b/packages/tui/src/observability/local-observability.ts @@ -32,6 +32,27 @@ export interface TuiEventStreamObservation { readonly errorKind?: string; } +/** + * Run-ownership transitions the TUI cannot reconstruct from Runtime history alone. + * Session and Turn ids are opaque identifiers; no message content is recorded. + */ +export interface TuiRunLifecycleObservation { + readonly kind: + | 'runtime-turn-adopted' + | 'queue-turn-started' + | 'terminal-not-owned' + | 'stale-run-reconciled' + | 'stale-in-process-run'; + readonly sessionId: string; + readonly turnId?: string; + readonly projectedTurnId?: string; + readonly inProcessTurnId?: string; + readonly reason?: string; + readonly runtimeState?: string; + readonly queuedCount?: number; + readonly stalledForMs?: number; +} + export interface TuiTerminalObservation { readonly terminalId: string; readonly platform: NodeJS.Platform; @@ -138,6 +159,7 @@ export interface TuiObservability { recordStartup(observation: TuiStartupObservation): void; recordAccess(observation: TuiRuntimeAccessObservation): void; recordEventStream(observation: TuiEventStreamObservation): void; + recordRunLifecycle?(observation: TuiRunLifecycleObservation): void; recordTerminal(observation: TuiTerminalObservation): void; recordProcessStop(observation: TuiProcessStopObservation): void; recordTheme?(observation: TuiThemeObservation): void; @@ -268,6 +290,10 @@ class LocalTuiObservability implements TuiObservability { this.record('event.stream', observation); } + recordRunLifecycle(observation: TuiRunLifecycleObservation): void { + this.record('run.lifecycle', observation); + } + recordTerminal(observation: TuiTerminalObservation): void { this.terminal = Object.freeze({ ...observation }); this.record('terminal.capability', observation); diff --git a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts index 72baa15ea..57d5aa206 100644 --- a/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts +++ b/packages/tui/src/tui/controller/runtime/runtime-event-flow.ts @@ -33,6 +33,7 @@ import { noopTuiObservability, type TuiIncidentSink, type TuiObservability, + type TuiRunLifecycleObservation, } from '../../../observability/index.js'; import { formatTuiRuntimeFailure, resolveTuiRuntimeFailure } from './runtime-error-presentation.js'; import { @@ -47,6 +48,8 @@ const EVENT_BUS_RECONNECT_MAX_DELAY_MS = 10_000; const INCIDENT_RECONNECT_WINDOW_MS = 30_000; const INCIDENT_RECONNECT_FAILURE_THRESHOLD = 3; const TERMINAL_RECONCILIATION_TIMEOUT_MS = 2_000; +// A projected run must read as settled on two consecutive checks before the TUI clears it. +const STALE_RUN_CHECK_INTERVAL_MS = 5_000; type AppendLocalCell = (content: string, kind?: 'final-summary' | 'warning' | 'error') => void; @@ -86,6 +89,8 @@ export interface TuiRuntimeEventFlowOptions { readonly observability?: TuiObservability; readonly incidentReporter?: TuiIncidentSink; readonly terminalReconciliationTimeoutMs?: number; + /** Interval for the stale-run safety net; `0` disables the timer. */ + readonly staleRunCheckIntervalMs?: number; readonly notify?: (kind: TuiTerminalNotificationKind, key: string) => void; } @@ -105,6 +110,13 @@ export class TuiRuntimeEventFlow { } | undefined; private readonly llmRetryCalls = new Map(); + private staleRunTimer: ReturnType | undefined; + private staleRunCheck: Promise | undefined; + private staleRunGeneration = 0; + private staleRunCandidate: + | { readonly sessionId: string; readonly turnId: string; readonly firstSeenAtMs: number } + | undefined; + private reportedStaleInProcessTurnId: string | undefined; constructor(private readonly options: TuiRuntimeEventFlowOptions) {} @@ -139,6 +151,7 @@ export class TuiRuntimeEventFlow { start(): void { if (this.task || this.options.isStopped()) return; + this.startStaleRunWatchdog(); this.abortController = new AbortController(); const busController = this.abortController; const task = this.consume(busController); @@ -161,6 +174,10 @@ export class TuiRuntimeEventFlow { stop(): void { this.clearLlmRetry(); + if (this.staleRunTimer) clearInterval(this.staleRunTimer); + this.staleRunTimer = undefined; + this.staleRunGeneration += 1; + this.staleRunCandidate = undefined; this.abortController?.abort(); this.liveTurn?.controller.abort(); void this.task?.catch(() => undefined); @@ -171,6 +188,8 @@ export class TuiRuntimeEventFlow { restart(): void { if (this.options.isStopped()) return; + this.staleRunGeneration += 1; + this.staleRunCandidate = undefined; const previousTask = this.task; this.abortController?.abort(); if (this.task === previousTask) this.task = undefined; @@ -187,6 +206,172 @@ export class TuiRuntimeEventFlow { this.clearLlmRetry(); } + /** + * Safety net for a run the TUI still projects after Runtime has settled it. + * + * Runtime is authoritative for Turn ownership. When the TUI keeps a Runtime-owned Turn + * (adopted, recovered, or queue-started) while two consecutive reads report no running + * Turn and no Queue handoff, the missed terminal is reconciled locally so the activity + * line, Enter routing, and Queue admission stop treating the Session as busy. A + * foreground in-process submission owns its own completion and is only reported. + */ + reconcileStaleRun(): Promise { + if (!this.staleRunCheck) { + const check = this.checkStaleRun() + .catch((error: unknown) => { + this.staleRunCandidate = undefined; + throw error; + }) + .finally(() => { + if (this.staleRunCheck === check) this.staleRunCheck = undefined; + }); + this.staleRunCheck = check; + } + return this.staleRunCheck; + } + + private startStaleRunWatchdog(): void { + const intervalMs = this.options.staleRunCheckIntervalMs ?? STALE_RUN_CHECK_INTERVAL_MS; + if (this.staleRunTimer || intervalMs <= 0) return; + this.staleRunTimer = setInterval(() => { + void this.reconcileStaleRun().catch(() => undefined); + }, intervalMs); + this.staleRunTimer.unref?.(); + } + + private projectedRunTurnId(): string | undefined { + return ( + this.options.controller.snapshot().activeTurnId ?? + this.options.runProjection.snapshot().latestRuntimeTurnId ?? + this.liveTurn?.turnId + ); + } + + private staleRunOwnershipBlocksReconciliation( + sessionId: string, + projectedTurnId: string, + ): boolean { + const snapshot = this.options.controller.snapshot(); + const runProjection = this.options.runProjection.snapshot(); + return ( + this.options.isStopped() || + snapshot.session?.sessionId !== sessionId || + this.projectedRunTurnId() !== projectedTurnId || + Boolean(snapshot.retiringTurnId) || + runProjection.queueHandoffPending || + Boolean(runProjection.stoppingRuntimeTurnId) || + this.options.interactionFlow.continuesTurn(sessionId, projectedTurnId) + ); + } + + private async checkStaleRun(): Promise { + const snapshot = this.options.controller.snapshot(); + const sessionId = snapshot.session?.sessionId; + const projectedTurnId = this.projectedRunTurnId(); + if ( + !sessionId || + !projectedTurnId || + this.staleRunOwnershipBlocksReconciliation(sessionId, projectedTurnId) + ) { + this.staleRunCandidate = undefined; + return false; + } + const generation = this.staleRunGeneration; + const [activeRun, queued] = await Promise.all([ + this.options.runtime.getActiveRun(sessionId), + this.options.runtime.listQueuedMessages(sessionId), + ]); + if (generation !== this.staleRunGeneration) return false; + if (this.staleRunOwnershipBlocksReconciliation(sessionId, projectedTurnId)) { + this.staleRunCandidate = undefined; + return false; + } + const runtimeBusy = + activeRun.state === 'running' || + activeRun.state === 'decision-blocked' || + queued.some((item) => item.status === 'accepted' || item.status === 'running'); + if (runtimeBusy) { + this.staleRunCandidate = undefined; + return false; + } + const nowMs = Date.now(); + const candidate = this.staleRunCandidate; + if (candidate?.sessionId !== sessionId || candidate.turnId !== projectedTurnId) { + this.staleRunCandidate = { sessionId, turnId: projectedTurnId, firstSeenAtMs: nowMs }; + return false; + } + const inProcessTurnId = this.options.controller.snapshot().activeTurnId; + const observation = { + sessionId, + projectedTurnId, + runtimeState: activeRun.state, + queuedCount: queued.filter((item) => item.status === 'queued').length, + stalledForMs: Math.max(0, nowMs - candidate.firstSeenAtMs), + ...(activeRun.turnId ? { turnId: activeRun.turnId } : {}), + ...(inProcessTurnId ? { inProcessTurnId } : {}), + }; + if (inProcessTurnId) { + if (this.reportedStaleInProcessTurnId !== inProcessTurnId) { + this.reportedStaleInProcessTurnId = inProcessTurnId; + this.recordRunLifecycle({ kind: 'stale-in-process-run', ...observation }); + this.breadcrumb('cli.run.stale_in_process', { + runtimeState: activeRun.state, + stalledForMs: observation.stalledForMs, + }); + } + return false; + } + + this.staleRunCandidate = undefined; + const liveTurn = this.liveTurn; + if (liveTurn?.sessionId === sessionId && liveTurn.turnId === projectedTurnId) { + liveTurn.controller.abort(); + void liveTurn.task.catch(() => undefined); + this.liveTurn = undefined; + this.options.controller.runtimeTurnSettlement.settleProjection(projectedTurnId, 'succeeded'); + } + this.options.runProjection.reconcileRuntimeTurn(undefined); + this.recordRunLifecycle({ kind: 'stale-run-reconciled', ...observation }); + this.breadcrumb('cli.run.stale_reconciled', { + runtimeState: activeRun.state, + stalledForMs: observation.stalledForMs, + }); + this.options.onChanged(); + await this.refreshRuntimeSessionProjection(sessionId, { includeDurableHistory: true }).catch( + () => false, + ); + await this.options.activeRunFlow.refresh(true).catch(() => undefined); + this.options.onChanged(); + return true; + } + + private recordRunLifecycle(observation: TuiRunLifecycleObservation): void { + try { + this.options.observability?.recordRunLifecycle?.(observation); + } catch { + // Diagnostics must not affect Runtime event handling. + } + } + + private recordTerminalNotOwned( + event: TuiSessionLifecycleEvent, + sessionId: string, + reason: 'in-process-turn' | 'projection-mismatch', + context: { readonly inProcessTurnId?: string; readonly projectedTurnId?: string }, + ): void { + // Only a terminal for a Turn other than the one the TUI holds can leave it stale. + const heldTurnId = context.inProcessTurnId ?? context.projectedTurnId; + if (!event.turnId || event.turnId === heldTurnId) return; + this.recordRunLifecycle({ + kind: 'terminal-not-owned', + sessionId, + turnId: event.turnId, + reason, + ...(context.inProcessTurnId ? { inProcessTurnId: context.inProcessTurnId } : {}), + ...(context.projectedTurnId ? { projectedTurnId: context.projectedTurnId } : {}), + }); + } + applyActiveSessionProjection(): void { this.clearLlmRetry(); const view = selectActiveSessionView(this.options.stateStore.snapshot()); @@ -523,6 +708,14 @@ export class TuiRuntimeEventFlow { ) { return; } + this.recordRunLifecycle({ + kind: 'runtime-turn-adopted', + sessionId, + turnId, + ...(this.options.runProjection.snapshot().latestRuntimeTurnId + ? { projectedTurnId: this.options.runProjection.snapshot().latestRuntimeTurnId } + : {}), + }); this.startLiveTurn( sessionId, turnId, @@ -751,6 +944,14 @@ export class TuiRuntimeEventFlow { private handleQueuedDrainStarted(event: TuiSessionLifecycleEvent): void { this.options.runProjection.markQueueTurnStarted(event.turnId); + if (event.sessionId) { + this.recordRunLifecycle({ + kind: 'queue-turn-started', + sessionId: event.sessionId, + ...(event.turnId ? { turnId: event.turnId } : {}), + queuedCount: event.queueItemIds.length, + }); + } for (const itemId of event.queueItemIds) { const cached = this.options.runProjection.findQueueItem(itemId); if (!cached) continue; @@ -767,7 +968,11 @@ export class TuiRuntimeEventFlow { liveTurnDurationMs?: number, interactionHandled = false, ): Promise { - if (this.options.controller.snapshot().activeTurnId) return false; + const inProcessTurnId = this.options.controller.snapshot().activeTurnId; + if (inProcessTurnId) { + this.recordTerminalNotOwned(event, sessionId, 'in-process-turn', { inProcessTurnId }); + return false; + } const activeRun = this.options.activeRunFlow.currentSnapshot(); const activeRuntimeTurnId = @@ -787,7 +992,10 @@ export class TuiRuntimeEventFlow { !event.turnId || projectedTurnId === event.turnId || projectedTurnId.startsWith('session:'); - if (!eventMatchesProjection) return false; + if (!eventMatchesProjection) { + this.recordTerminalNotOwned(event, sessionId, 'projection-mismatch', { projectedTurnId }); + return false; + } // Late or background terminal events cannot settle the foreground turn. const ownsTerminal = Boolean( diff --git a/packages/tui/src/tui/features/transcript/panel.ts b/packages/tui/src/tui/features/transcript/panel.ts index 785f164ef..4e583b83b 100644 --- a/packages/tui/src/tui/features/transcript/panel.ts +++ b/packages/tui/src/tui/features/transcript/panel.ts @@ -20,6 +20,7 @@ import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; import { renderTuiStructuredPreview } from '../../transcript/presentation/structured-preview.js'; import { renderDeliveredAssets } from '../../transcript/delivered-assets.js'; +import { wrapLiteralUserText } from '../../transcript/presentation/literal-text.js'; import { renderTuiActionHint, tuiChalk as chalk, @@ -506,11 +507,15 @@ export class TuiTranscriptPanel implements TuiFeatureScreen, Component, Focusabl if (this.rawIds.has(cell.id)) { return this.renderTextSection('raw', presentation.rawText || 'No raw content', contentWidth); } - if (cell.kind === 'assistant' || cell.kind === 'assistant-preamble' || cell.kind === 'user') { - const content = - cell.kind === 'user' - ? { text: cell.content, assets: [] } - : projectAssistantContentForTerminal(cell.content); + if (cell.kind === 'user') { + // User prompts render literally here too, matching the main rail; see + // `wrapLiteralUserText`. + return wrapLiteralUserText(cell.content, contentWidth).map((line) => + this.detailLine(line, width), + ); + } + if (cell.kind === 'assistant' || cell.kind === 'assistant-preamble') { + const content = projectAssistantContentForTerminal(cell.content); const body = new Markdown(content.text, 0, 0, markdownTheme, { color: (value) => chalk.hex(colors.text)(value), }).render(contentWidth); diff --git a/packages/tui/src/tui/transcript/presentation/literal-text.ts b/packages/tui/src/tui/transcript/presentation/literal-text.ts new file mode 100644 index 000000000..2b0c8b021 --- /dev/null +++ b/packages/tui/src/tui/transcript/presentation/literal-text.ts @@ -0,0 +1,39 @@ +import { sanitizeTerminalText } from '../../rendering/terminal-text.js'; +import { wrapTextWithAnsi } from '../../rendering/text.js'; +import { tuiChalk as chalk, tuiColors as colors } from '../../theme/runtime.js'; + +/** + * Wrap user-authored text for literal display in the transcript. + * + * A user prompt is quoted source, not assistant prose. Running it through the + * Markdown renderer rewrites `__pycache__` into bold text with the underscores + * dropped, turns `*` into emphasis, and folds a leading `>` into a quote, so + * the echoed prompt stops matching what was typed. Codex keeps its user history + * cells plain for the same reason and only renders assistant content as + * Markdown. Every surface that shows a user prompt must use this helper so the + * main rail, pending steers, and the transcript panel cannot drift apart. + * + * - Terminal control strings are stripped, matching the assistant path. + * - Tabs expand to the 3-column form `visibleWidth` already assumes, so the + * rendered row and the width math agree. A literal tab would jump to the + * terminal's next tab stop and break a filled background. + * - Trailing CR/LF are dropped, like Codex's user cell: a pasted prompt usually + * ends with a newline, which would otherwise add empty rows. Trailing spaces + * inside the text are left alone. + * - Each row takes the theme text color explicitly. Leaving it to the terminal + * default foreground loses contrast when the palette appearance differs from + * the terminal's own, e.g. a light user band inside a dark terminal. + * + * Returns no rows when nothing printable is left, e.g. a prompt made only of + * control sequences, so callers can keep their own empty-state rendering. + */ +export function wrapLiteralUserText(text: string, width: number): string[] { + const normalized = sanitizeTerminalText(text) + .replace(/\t/gu, ' ') + .replace(/[\r\n]+$/u, ''); + if (!normalized.trim()) return []; + const textColor = chalk.hex(colors.text); + return wrapTextWithAnsi(normalized, Math.max(1, width)).map((line) => + line ? textColor(line) : line, + ); +} diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 5c69d9a22..bc2ebda59 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -5,6 +5,7 @@ import { formatTuiDuration } from '../rendering/duration.js'; import type { Component } from '../rendering/component.js'; import { stripAnsi, truncateToWidth, visibleWidth } from '../rendering/text.js'; import { sanitizeTerminalText } from '../rendering/terminal-text.js'; +import { wrapLiteralUserText } from './presentation/literal-text.js'; import type { TranscriptAttachment, TranscriptCell } from './model.js'; import { resolveTranscriptCellDisplayMode } from './model.js'; import { @@ -826,14 +827,16 @@ function sanitizeDelegationLabel(value: string | undefined, fallback: string): s return sanitized || fallback; } +/** + * Render the user's own prompt as literal text inside the user band. + * See `wrapLiteralUserText` for why prompts never go through Markdown. + */ function renderUserIntent(content: string, width: number): string[] { const normalizedWidth = Math.max(0, Math.floor(width)); if (normalizedWidth === 0) return []; const verticalPadding = renderUserBandLine('', normalizedWidth); const bodyWidth = Math.max(1, normalizedWidth - 4); - const body = new Markdown(content, 0, 0, markdownTheme, { - color: (text) => chalk.hex(colors.text)(text), - }).render(bodyWidth); + const body = wrapLiteralUserText(content, bodyWidth); if (body.length === 0) { return [ verticalPadding, @@ -878,12 +881,9 @@ function renderPendingSteerMessage(cell: TranscriptCell, width: number): string[ const heading = `${railIndent}${marker} ${label}`; const headingWidth = visibleWidth(heading); const contentWidth = Math.max(1, normalizedWidth - headingWidth - 3); + // Steer text is user input, so it stays literal like the main prompt row. const body = cell.content.trim() - ? new Markdown(cell.content, 0, 0, markdownTheme, { - color: (text) => chalk.hex(colors.text)(text), - }) - .render(contentWidth) - .map(trimTerminalLineEnd) + ? wrapLiteralUserText(cell.content, contentWidth).map(trimTerminalLineEnd) : []; const attachments = (cell.attachments ?? []).map((attachment) => { const kind = transcriptAttachmentKind(attachment); diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index d2bbd2e64..0562f6491 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -153,6 +153,136 @@ describe('TranscriptView', () => { expect(lines.filter((line) => line.includes('\x1b]133;B\x07\x1b]133;C\x07'))).toHaveLength(2); }); + it('keeps user prompts literal instead of rendering them as Markdown', () => { + // Regression: the prompt echo used to run through the Markdown renderer, so + // `__pycache__` lost its underscores, `*` became emphasis, and a leading `>` + // turned the row into a quote. User input is quoted source and must survive + // verbatim, the way Codex renders its user history cells. + const originalTheme = getTuiThemeSnapshot(); + const palette = + originalTheme.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + // Force real styling so "no bold/italic on the prompt" is a meaningful + // assertion instead of a vacuous one in a colorless test terminal. + applyTuiRenderTheme(palette, 3); + try { + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'user-literal-prompt', + kind: 'user', + status: 'succeeded', + content: [ + 'delete __pycache__ then check > logs and 2 * 3', + 'keep **stars** and _underscores_', + ].join('\n'), + createdAtMs: 1, + }), + createTranscriptCell({ + id: 'assistant-still-markdown', + kind: 'assistant', + status: 'succeeded', + content: 'Removed `__pycache__`.', + createdAtMs: 2, + }), + ]); + + const rendered = view.render(80).join('\n'); + const plain = stripVTControlCharacters(rendered).replace(/\s+$/gmu, ''); + const promptLines = plain + .split('\n') + .filter((line) => line.includes('delete') || line.includes('keep')); + + // Source markers survive verbatim. + expect(promptLines[0]).toContain('delete __pycache__ then check > logs and 2 * 3'); + expect(promptLines[1]).toContain('keep **stars** and _underscores_'); + + // The prompt text is one contiguous run. Markdown emphasis would have + // injected style codes inside it and dropped the markers; only the `›` + // marker itself is bold, and it closes before the text starts. + const userRows = rendered + .split('\n') + .filter((line) => stripVTControlCharacters(line).includes('delete')); + expect(userRows).toHaveLength(1); + expect(userRows[0]).toContain('delete __pycache__ then check > logs and 2 * 3'); + expect(rendered).toContain('keep **stars** and _underscores_'); + expect(userRows[0]).not.toContain('\x1b[3m'); + + // Assistant content is still Markdown: its codespan is themed and loses + // its backticks, the opposite of the prompt row. + expect(plain).toContain('Removed __pycache__.'); + expect(plain).not.toContain('Removed `__pycache__`.'); + } finally { + applyTuiRenderTheme(palette, originalTheme.colorLevel); + } + }); + + it('preserves user prompt line breaks and pads every band row to the full width', () => { + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'user-multiline', + kind: 'user', + status: 'succeeded', + content: 'line one\nline two', + createdAtMs: 1, + }), + ]); + + const lines = view.render(40); + const contentLines = lines + .map((line) => stripVTControlCharacters(line)) + .filter((line) => line.includes('line one') || line.includes('line two')); + + expect(contentLines).toHaveLength(2); + expect(contentLines[0]).toContain('line one'); + expect(contentLines[1]).toContain('line two'); + for (const line of lines) { + expect(visibleWidth(stripVTControlCharacters(line))).toBe(40); + } + }); + + it('does not render trailing blank band rows for a prompt ending in newlines', () => { + // Codex trims trailing CR/LF before wrapping its user history cell + // (`message.trim_end_matches(['\r', '\n'])`). A pasted prompt often ends + // with a newline, which would otherwise pad the user band with empty rows. + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'user-trailing-newlines', + kind: 'user', + status: 'succeeded', + content: 'line one\nline two\n\n\n', + createdAtMs: 1, + }), + ]); + + const plain = view.render(40).map((line) => stripVTControlCharacters(line)); + const contentLines = plain.filter( + (line) => line.includes('line one') || line.includes('line two'), + ); + + expect(contentLines).toHaveLength(2); + // 1 top padding row + 2 content rows (the `›` marker rides on the first + // content row) + 1 bottom padding row. The three trailing newlines must not + // add empty band rows. + expect(plain).toHaveLength(4); + }); + + it('keeps a pending steer literal like the main prompt row', () => { + const view = new TranscriptView(() => [ + createTranscriptCell({ + id: 'steer-literal', + kind: 'user', + status: 'pending', + content: 'also clear __pycache__ and keep 2 * 3', + userPresentation: 'pending-steer', + createdAtMs: 1, + }), + ]); + + const rendered = stripVTControlCharacters(view.render(80).join('\n')); + + expect(rendered).toContain('Next'); + expect(rendered).toContain('also clear __pycache__ and keep 2 * 3'); + }); + it('attaches a pending steer to the active flow as the next instruction', () => { const width = 60; const view = new TranscriptView(() => [ @@ -1059,7 +1189,10 @@ describe('TranscriptView', () => { const lines = view.render(50); const rendered = lines.join('\n'); - expect(rendered).toContain('› Please inspect this.'); + // The prompt is quoted source: its `**` markers stay literal. Assistant + // content below still renders as Markdown, which is why `one` loses its + // backticks there but not here. + expect(rendered).toContain('› Please **inspect** this.'); expect(rendered).not.toContain('YOU'); expect(rendered).toContain('├ • Thinking…'); expect(rendered).toContain('│ Reading the repository'); diff --git a/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts b/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts new file mode 100644 index 000000000..7091e204a --- /dev/null +++ b/packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + TuiActiveRunSnapshot, + TuiQueuedMessage, + TuiRuntimeEvent, +} from '../../../../../src/runtime/port.js'; +import type { TuiObservability } from '../../../../../src/observability/index.js'; +import { TuiRuntimeEventFlow } from '../../../../../src/tui/controller/runtime/runtime-event-flow.js'; +import { + createTuiState, + TuiRunProjection, + TuiEffectRunner, + TuiStateStore, +} from '../../../../../src/tui/state/index.js'; +import { TranscriptStore } from '../../../../../src/tui/transcript/store.js'; + +function lifecycle( + type: 'session.start' | 'session.finish' | 'session.error' | 'session.abort', + turnId: string, +): TuiRuntimeEvent { + return { + type, + timestampMs: 100, + source: 'runtime', + sessionId: 'session-1', + turnId, + queueItemIds: [], + }; +} + +function activeRun( + state: TuiActiveRunSnapshot['state'], + turnId?: string, +): TuiActiveRunSnapshot { + return { + schemaVersion: 1, + sessionId: 'session-1', + state, + ...(turnId ? { turnId } : {}), + actions: { + steer: false, + }, + }; +} + +function queued(status: string): TuiQueuedMessage { + return { itemId: `item-${status}`, sessionId: 'session-1', status }; +} + +function createObservability(): { + observability: TuiObservability; + recordRunLifecycle: ReturnType; +} { + const recordRunLifecycle = vi.fn(); + return { + recordRunLifecycle, + observability: { + recordStartup: vi.fn(), + recordAccess: vi.fn(), + recordEventStream: vi.fn(), + recordTerminal: vi.fn(), + recordProcessStop: vi.fn(), + observeRender: vi.fn(), + recordRunLifecycle, + snapshot: vi.fn(), + flush: vi.fn(async () => undefined), + } as unknown as TuiObservability, + }; +} + +function createFixture(options?: { + currentControllerTurnId?: () => string | undefined; + runtimeActiveRun?: () => Promise; + queuedMessages?: () => Promise; + interactionContinuesTurn?: (sessionId: string, turnId: string) => boolean; + observability?: TuiObservability; +}) { + const stateStore = new TuiStateStore(createTuiState()); + stateStore.dispatch({ type: 'session/activate', sessionId: 'session-1' }); + const effectRunner = new TuiEffectRunner( + async (effect) => ({ + type: 'connection/sessionReconciled', + sessionId: effect.sessionId, + }), + (action) => stateStore.dispatch(action), + ); + const refreshQueue = vi.fn(async () => []); + const activeRunFlow = { + refresh: vi.fn(async () => undefined), + currentSnapshot: vi.fn(() => activeRun('idle')), + }; + const stateCoordinator = { + project: vi.fn(), + }; + const sessionFlow = { + reconcileRuntimeEvent: vi.fn(async () => undefined), + }; + const delegationFlow = { + handleRuntimeEvent: vi.fn(async () => undefined), + handleSettledRuntimeEvent: vi.fn(), + refresh: vi.fn(async () => undefined), + }; + const interactionFlow = { + handleRuntimeEvent: vi.fn(async () => false), + replaceFromActiveSession: vi.fn(), + hasPending: vi.fn(() => false), + continuesTurn: vi.fn(options?.interactionContinuesTurn ?? (() => false)), + }; + const runProjection = new TuiRunProjection(); + const runtime = { + getActiveRun: + options?.runtimeActiveRun ?? vi.fn(async () => activeRun('idle')), + listQueuedMessages: options?.queuedMessages ?? vi.fn(async () => []), + watchEvents: async function* watchEvents(signal: AbortSignal) { + const event = await new Promise( + (resolve) => { + signal.addEventListener('abort', () => resolve(undefined), { + once: true, + }); + }, + ); + if (event) yield event; + }, + watchSessionTurn: async function* watchSessionTurn() {}, + }; + const settleRuntimeTurnProjection = vi.fn(); + const reconcileOwnerHistory = vi.fn(async () => true); + const controller = { + snapshot: vi.fn(() => ({ + session: { sessionId: 'session-1' }, + activeTurnId: options?.currentControllerTurnId?.(), + })), + refreshCurrentSessionHistory: vi.fn(async () => undefined), + refreshSessionMetadata: vi.fn(async () => undefined), + beginRuntimeTurn: vi.fn(), + applyRuntimeTurnEvent: vi.fn(), + runtimeTurnSettlement: { + settle: vi.fn(async () => undefined), + settleProjection: settleRuntimeTurnProjection, + enabled: vi.fn(() => false), + prepare: vi.fn((sessionId: string, turnId: string, status: string) => ({ + sessionId, + turnId, + status, + })), + publish: vi.fn(async () => undefined), + }, + latestDurableMessageId: vi.fn(() => 'message-anchor'), + reconcileOwnerHistory, + refreshStatusMetricsNow: vi.fn(), + refreshSessionUsageNow: vi.fn(), + }; + const flow = new TuiRuntimeEventFlow({ + runtime: runtime as never, + controller: controller as never, + stateStore, + effectRunner, + stateCoordinator: stateCoordinator as never, + sessionFlow: sessionFlow as never, + delegationFlow: delegationFlow as never, + activeRunFlow: activeRunFlow as never, + interactionFlow: interactionFlow as never, + runProjection, + releaseQueueItem: vi.fn(async () => undefined), + transcript: new TranscriptStore(), + refreshQueue, + projectQueueItem: vi.fn(), + restoreFailedQueueItem: vi.fn(), + onLlmRetryChanged: vi.fn(), + onRetryAvailabilityChanged: vi.fn(), + goalFlow: { project: vi.fn(), refresh: vi.fn(async () => undefined) }, + updateFollowUpPanel: vi.fn(), + onChanged: vi.fn(), + append: vi.fn(), + isStopped: () => false, + queueEnabled: true, + ...(options?.observability ? { observability: options.observability } : {}), + staleRunCheckIntervalMs: 0, + notify: vi.fn(), + }); + + return { + flow, + runtime, + runProjection, + activeRunFlow, + settleRuntimeTurnProjection, + reconcileOwnerHistory, + }; +} + +async function handle( + flow: TuiRuntimeEventFlow, + event: TuiRuntimeEvent, +): Promise { + await ( + flow as unknown as { + handle(runtimeEvent: TuiRuntimeEvent): Promise; + } + ).handle(event); +} + +describe('TuiRuntimeEventFlow stale-run safety net', () => { + it('clears an adopted Runtime Turn after two consecutive settled Runtime reads', async () => { + const { observability, recordRunLifecycle } = createObservability(); + const fixture = createFixture({ + observability, + runtimeActiveRun: vi.fn(async () => activeRun('terminal')), + queuedMessages: vi.fn(async () => [queued('queued')]), + }); + fixture.flow.adoptRuntimeTurn('session-1', 'turn-stale', 100); + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe( + 'turn-stale', + ); + + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe( + 'turn-stale', + ); + + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(true); + expect( + fixture.runProjection.snapshot().latestRuntimeTurnId, + ).toBeUndefined(); + expect(fixture.settleRuntimeTurnProjection).toHaveBeenCalledWith( + 'turn-stale', + 'succeeded', + ); + expect(fixture.reconcileOwnerHistory).toHaveBeenCalledWith(false); + expect(fixture.activeRunFlow.refresh).toHaveBeenCalledWith(true); + expect(recordRunLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'stale-run-reconciled', + sessionId: 'session-1', + projectedTurnId: 'turn-stale', + runtimeState: 'terminal', + queuedCount: 1, + }), + ); + fixture.flow.stop(); + }); + + it('keeps the projected run while Runtime still reports running work or a Queue handoff', async () => { + const reads: Array<{ + run: TuiActiveRunSnapshot; + queue: TuiQueuedMessage[]; + }> = [ + { run: activeRun('idle'), queue: [] }, + { run: activeRun('running', 'turn-next'), queue: [] }, + { run: activeRun('idle'), queue: [] }, + { run: activeRun('idle'), queue: [queued('accepted')] }, + { run: activeRun('decision-blocked', 'turn-stale'), queue: [] }, + ]; + let read = 0; + const fixture = createFixture({ + runtimeActiveRun: vi.fn( + async () => reads[Math.min(read, reads.length - 1)]!.run, + ), + queuedMessages: vi.fn( + async () => reads[Math.min(read++, reads.length - 1)]!.queue, + ), + }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + + for (let index = 0; index < reads.length; index += 1) { + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + } + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe( + 'turn-stale', + ); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('keeps the projected run while an accepted Queue handoff is not yet Runtime-visible', async () => { + const fixture = createFixture({ + runtimeActiveRun: vi.fn(async () => activeRun('idle')), + queuedMessages: vi.fn(async () => []), + }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + fixture.runProjection.markQueueHandoffPending(); + + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + + expect(fixture.runProjection.snapshot()).toEqual( + expect.objectContaining({ + latestRuntimeTurnId: 'turn-stale', + queueHandoffPending: true, + }), + ); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('rechecks Queue handoff ownership after Runtime reads finish', async () => { + let resolveActiveRun!: (value: TuiActiveRunSnapshot) => void; + let resolveQueuedMessages!: (value: TuiQueuedMessage[]) => void; + const runtimeActiveRun = vi + .fn<() => Promise>() + .mockResolvedValueOnce(activeRun('idle')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActiveRun = resolve; + }), + ); + const queuedMessages = vi + .fn<() => Promise>() + .mockResolvedValueOnce([]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQueuedMessages = resolve; + }), + ); + const fixture = createFixture({ runtimeActiveRun, queuedMessages }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + + const pendingCheck = fixture.flow.reconcileStaleRun(); + await vi.waitFor(() => { + expect(runtimeActiveRun).toHaveBeenCalledTimes(2); + expect(queuedMessages).toHaveBeenCalledTimes(2); + }); + fixture.runProjection.markQueueHandoffPending(); + resolveActiveRun(activeRun('idle')); + resolveQueuedMessages([]); + + await expect(pendingCheck).resolves.toBe(false); + expect(fixture.runProjection.snapshot()).toEqual( + expect.objectContaining({ + latestRuntimeTurnId: 'turn-stale', + queueHandoffPending: true, + }), + ); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('restarts confirmation after a Runtime ownership read fails', async () => { + const runtimeActiveRun = vi + .fn<() => Promise>() + .mockResolvedValueOnce(activeRun('idle')) + .mockRejectedValueOnce(new Error('ownership probe failed')) + .mockResolvedValue(activeRun('idle')); + const fixture = createFixture({ runtimeActiveRun }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + await expect(fixture.flow.reconcileStaleRun()).rejects.toThrow('ownership probe failed'); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe('turn-stale'); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('restarts confirmation when the Runtime event stream restarts', async () => { + const fixture = createFixture({ + runtimeActiveRun: vi.fn(async () => activeRun('idle')), + }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + vi.spyOn(fixture.flow, 'start').mockImplementation(() => undefined); + fixture.flow.restart(); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe('turn-stale'); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('discards an in-flight ownership read when the Runtime event stream restarts', async () => { + let resolveActiveRun!: (value: TuiActiveRunSnapshot) => void; + let resolveQueuedMessages!: (value: TuiQueuedMessage[]) => void; + const runtimeActiveRun = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActiveRun = resolve; + }), + ) + .mockResolvedValue(activeRun('idle')); + const queuedMessages = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQueuedMessages = resolve; + }), + ) + .mockResolvedValue([]); + const fixture = createFixture({ runtimeActiveRun, queuedMessages }); + fixture.runProjection.markRecoveredTurn('turn-stale'); + + const pendingCheck = fixture.flow.reconcileStaleRun(); + await vi.waitFor(() => { + expect(runtimeActiveRun).toHaveBeenCalledTimes(1); + expect(queuedMessages).toHaveBeenCalledTimes(1); + }); + vi.spyOn(fixture.flow, 'start').mockImplementation(() => undefined); + fixture.flow.restart(); + resolveActiveRun(activeRun('idle')); + resolveQueuedMessages([]); + + await expect(pendingCheck).resolves.toBe(false); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe('turn-stale'); + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('restarts confirmation when the projected Turn changes between reads', async () => { + const fixture = createFixture({ + runtimeActiveRun: vi.fn(async () => activeRun('idle')), + }); + fixture.runProjection.markRecoveredTurn('turn-a'); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + fixture.runProjection.markRecoveredTurn('turn-b'); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe('turn-b'); + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(true); + expect( + fixture.runProjection.snapshot().latestRuntimeTurnId, + ).toBeUndefined(); + fixture.flow.stop(); + }); + + it('does not clear a Turn that a pending interaction keeps open', async () => { + const fixture = createFixture({ + runtimeActiveRun: vi.fn(async () => activeRun('terminal')), + interactionContinuesTurn: () => true, + }); + fixture.runProjection.markRecoveredTurn('turn-question'); + await fixture.flow.reconcileStaleRun(); + await fixture.flow.reconcileStaleRun(); + expect(fixture.runProjection.snapshot().latestRuntimeTurnId).toBe( + 'turn-question', + ); + expect(fixture.runtime.getActiveRun).not.toHaveBeenCalled(); + fixture.flow.stop(); + }); + + it('reports but never clears a foreground in-process Turn', async () => { + const { observability, recordRunLifecycle } = createObservability(); + const fixture = createFixture({ + observability, + currentControllerTurnId: () => 'turn-local', + runtimeActiveRun: vi.fn(async () => activeRun('idle')), + }); + for (let index = 0; index < 4; index += 1) { + await expect(fixture.flow.reconcileStaleRun()).resolves.toBe(false); + } + expect(fixture.settleRuntimeTurnProjection).not.toHaveBeenCalled(); + expect(recordRunLifecycle).toHaveBeenCalledTimes(1); + expect(recordRunLifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'stale-in-process-run', + inProcessTurnId: 'turn-local', + runtimeState: 'idle', + }), + ); + fixture.flow.stop(); + }); + + it('records terminals for other Turns that the local projection does not own', async () => { + const { observability, recordRunLifecycle } = createObservability(); + let controllerTurnId: string | undefined = 'turn-local'; + const fixture = createFixture({ + observability, + currentControllerTurnId: () => controllerTurnId, + }); + + await handle(fixture.flow, lifecycle('session.finish', 'turn-delivery')); + expect(recordRunLifecycle).toHaveBeenCalledWith({ + kind: 'terminal-not-owned', + sessionId: 'session-1', + turnId: 'turn-delivery', + reason: 'in-process-turn', + inProcessTurnId: 'turn-local', + }); + + controllerTurnId = undefined; + fixture.runProjection.markRecoveredTurn('turn-held'); + await handle(fixture.flow, lifecycle('session.error', 'turn-other')); + expect(recordRunLifecycle).toHaveBeenLastCalledWith({ + kind: 'terminal-not-owned', + sessionId: 'session-1', + turnId: 'turn-other', + reason: 'projection-mismatch', + projectedTurnId: 'turn-held', + }); + fixture.flow.stop(); + }); + + it('records adopted and queue-started Runtime Turns', async () => { + const { observability, recordRunLifecycle } = createObservability(); + const fixture = createFixture({ observability }); + + fixture.flow.adoptRuntimeTurn('session-1', 'turn-activated', 100); + await handle(fixture.flow, { + ...lifecycle('session.start', 'turn-queued'), + runSource: 'queued-drain', + queueItemIds: ['item-1'], + } as TuiRuntimeEvent); + + expect(recordRunLifecycle).toHaveBeenCalledWith({ + kind: 'runtime-turn-adopted', + sessionId: 'session-1', + turnId: 'turn-activated', + }); + expect(recordRunLifecycle).toHaveBeenCalledWith({ + kind: 'queue-turn-started', + sessionId: 'session-1', + turnId: 'turn-queued', + queuedCount: 1, + }); + fixture.flow.stop(); + }); +}); diff --git a/packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts b/packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts new file mode 100644 index 000000000..c9a2edf7d --- /dev/null +++ b/packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts @@ -0,0 +1,37 @@ +import { stripVTControlCharacters } from 'node:util'; +import { describe, expect, it, vi } from 'vitest'; + +import { TuiTranscriptPanel } from '../../../../src/tui/features/transcript/panel.js'; +import { createTranscriptCell } from '../../../../src/tui/transcript/model.js'; +import { TranscriptStore } from '../../../../src/tui/transcript/store.js'; + +function text(panel: TuiTranscriptPanel): string { + return stripVTControlCharacters(panel.render(80).join('\n')); +} + +describe('TuiTranscriptPanel expanded user text', () => { + it('shows an expanded user prompt literally instead of as Markdown', () => { + const store = new TranscriptStore([ + createTranscriptCell({ + id: 'user-literal', + turnId: 'turn-1', + kind: 'user', + status: 'succeeded', + content: 'delete __pycache__\n> keep this marker\nand 2 * 3', + createdAtMs: 1, + }), + ]); + const panel = new TuiTranscriptPanel({ + source: store, + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + + panel.handleInput('\r'); + + const rendered = text(panel); + expect(rendered).toContain('│ delete __pycache__'); + expect(rendered).toContain('│ > keep this marker'); + expect(rendered).toContain('│ and 2 * 3'); + }); +}); diff --git a/packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts b/packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts new file mode 100644 index 000000000..4a2b21d5d --- /dev/null +++ b/packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts @@ -0,0 +1,114 @@ +import { stripVTControlCharacters } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { visibleWidth } from '../../../../../src/tui/rendering/text.js'; +import { + applyTuiRenderTheme, + getTuiThemeSnapshot, + tuiChalk, + tuiColors, +} from '../../../../../src/tui/theme/runtime.js'; +import { + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../../src/tui/theme/palettes.js'; +import { wrapLiteralUserText } from '../../../../../src/tui/transcript/presentation/literal-text.js'; + +const plain = (lines: string[]): string[] => lines.map((line) => stripVTControlCharacters(line)); + +describe('wrapLiteralUserText', () => { + const originalTheme = getTuiThemeSnapshot(); + const palette = + originalTheme.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + + beforeEach(() => { + // Real color output, so styling assertions cannot pass vacuously. + applyTuiRenderTheme(palette, 3); + }); + + afterEach(() => { + applyTuiRenderTheme(palette, originalTheme.colorLevel); + }); + + it('keeps Markdown markers as typed', () => { + const lines = wrapLiteralUserText( + [ + 'rm -rf __pycache__ && echo 2 * 3', + '> not a quote', + '# not a heading', + '- [x] not a task', + ].join('\n'), + 80, + ); + + expect(plain(lines)).toEqual([ + 'rm -rf __pycache__ && echo 2 * 3', + '> not a quote', + '# not a heading', + '- [x] not a task', + ]); + }); + + it('colors every non-empty row with the theme text color and adds no emphasis', () => { + const textColor = tuiChalk.hex(tuiColors.text)('x'); + const foreground = textColor.slice(0, textColor.indexOf('x')); + const [first = '', blank, last = ''] = wrapLiteralUserText('**bold?** _italic?_\n\nsecond', 80); + + // Guard against a vacuous `startsWith('')` if color output were disabled. + expect(foreground).not.toBe(''); + expect(first.startsWith(foreground)).toBe(true); + expect(first).toContain('**bold?** _italic?_'); + expect(first).not.toContain('\x1b[1m'); + expect(first).not.toContain('\x1b[3m'); + // A blank row stays empty rather than carrying a dangling color pair. + expect(blank).toBe(''); + expect(last.startsWith(foreground)).toBe(true); + expect(stripVTControlCharacters(last)).toBe('second'); + }); + + it('drops only trailing line breaks and keeps interior blank lines', () => { + expect(plain(wrapLiteralUserText('one\n\n\ntwo\r\n\n\n', 80))).toEqual(['one', '', '', 'two']); + }); + + it('keeps leading indentation of pasted code', () => { + expect(plain(wrapLiteralUserText('def f():\n return 1', 80))).toEqual([ + 'def f():', + ' return 1', + ]); + }); + + it('expands tabs so every row fits the requested width', () => { + const lines = wrapLiteralUserText('\tindented\twith\ttabs and more words to wrap', 16); + + expect(lines.some((line) => line.includes('\t'))).toBe(false); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(16); + }); + + it('wraps CJK text and unbroken paths within the width', () => { + const cjk = wrapLiteralUserText('删掉所有的缓存目录然后重新运行一遍测试看看结果是否正确', 12); + const path = wrapLiteralUserText( + '/very/long/unbroken/path/to/__pycache__/module.cpython-312.pyc', + 20, + ); + + for (const line of [...cjk, ...path]) expect(visibleWidth(line)).toBeLessThanOrEqual(20); + expect(plain(cjk).join('')).toBe('删掉所有的缓存目录然后重新运行一遍测试看看结果是否正确'); + expect(plain(path).join('')).toBe( + '/very/long/unbroken/path/to/__pycache__/module.cpython-312.pyc', + ); + }); + + it('strips terminal control strings from the prompt', () => { + const lines = wrapLiteralUserText('safe\x1b]52;c;U0VDUkVU\x07 \x1b[8mhidden?\x1b[0m', 80); + + expect(plain(lines)).toEqual(['safe hidden?']); + expect(lines.join('')).not.toContain('U0VDUkVU'); + expect(lines.join('')).not.toContain('\x1b[8m'); + }); + + it('returns no rows when nothing printable remains', () => { + expect(wrapLiteralUserText('', 80)).toEqual([]); + expect(wrapLiteralUserText('\n\n', 80)).toEqual([]); + expect(wrapLiteralUserText('\x1b[31m\x1b[0m', 80)).toEqual([]); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index dc8eb8fcd..348b4af0d 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3248,6 +3248,7 @@ "packages/tui/src/tui/transcript/local-appender.ts", "packages/tui/src/tui/transcript/model.ts", "packages/tui/src/tui/transcript/presentation/content.ts", + "packages/tui/src/tui/transcript/presentation/literal-text.ts", "packages/tui/src/tui/transcript/presentation/state.ts", "packages/tui/src/tui/transcript/presentation/structured-preview.ts", "packages/tui/src/tui/transcript/presentation/syntax-highlight.ts", @@ -3366,6 +3367,7 @@ "packages/tui/test/unit/tui/controller/projection/turn-output-rate.test.ts", "packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts", "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", + "packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts", "packages/tui/test/unit/tui/controller/side-session-flow.test.ts", "packages/tui/test/unit/tui/features/composer/attachments.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", @@ -3374,6 +3376,8 @@ "packages/tui/test/unit/tui/theme/custom-themes.test.ts", "packages/tui/test/unit/tui/theme/palettes.test.ts", "packages/tui/test/unit/tui/theme/runtime.test.ts", + "packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts", + "packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts", "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts", "packages/tui/test/unit/update-application.test.ts", "packages/tui/test/unit/update-service.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 3911ace38..6239c36cf 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -100,6 +100,8 @@ "packages/tui/test/unit/tui-thinking-preview.test.ts", "packages/tui/test/unit/tui-transcript-presentation.test.ts", "packages/tui/test/unit/tui-transcript-view.test.ts", + "packages/tui/test/unit/tui/transcript/panel-literal-user-text.test.ts", + "packages/tui/test/unit/tui/transcript/presentation/literal-text.test.ts", "packages/tui/test/unit/tui-workspace-status-line.test.ts", "packages/tui/test/unit/tui/controller/product/model-state.test.ts", "packages/tui/test/unit/tui/features/composer/attachments.test.ts", @@ -154,6 +156,7 @@ "packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts", "packages/tui/test/unit/tui/controller/product/command-flow.test.ts", "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", + "packages/tui/test/unit/tui/controller/runtime/runtime-event-flow-stale-run.test.ts", "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/tui/test/unit/incident-reporter-privacy.test.ts",