From ef950401d3be7fdd44c44ebbeb4f196fdcbda165 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:13:32 +0000 Subject: [PATCH] fix(native-chat): show prompts sent mid-turn and `!` commands where Claude read them (#31204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paweł Kotowski --- .../native-chat/transcript-file-order.test.ts | 126 ++++++++++++++++++ src/main/native-chat/transcript-file-order.ts | 17 +++ .../transcript-incremental-reader.ts | 11 +- ...line-decoders-claude-queued-prompt.test.ts | 88 ++++++++++++ .../transcript-line-decoders-claude.ts | 40 +++++- .../native-chat/transcript-stream-lines.ts | 6 +- .../native-chat/transcript-tail-reader.ts | 11 +- .../transcript-turn-lifecycle.test.ts | 36 +++++ .../native-chat/transcript-turn-lifecycle.ts | 16 ++- .../native-chat/transcript-watch-engine.ts | 2 + src/shared/claude-bash-input.test.ts | 19 +++ src/shared/claude-bash-input.ts | 18 +++ 12 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 src/main/native-chat/transcript-file-order.test.ts create mode 100644 src/main/native-chat/transcript-file-order.ts create mode 100644 src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts create mode 100644 src/shared/claude-bash-input.test.ts create mode 100644 src/shared/claude-bash-input.ts diff --git a/src/main/native-chat/transcript-file-order.test.ts b/src/main/native-chat/transcript-file-order.test.ts new file mode 100644 index 00000000000..d7a9767581d --- /dev/null +++ b/src/main/native-chat/transcript-file-order.test.ts @@ -0,0 +1,126 @@ +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { readNativeChatTranscript } from './transcript-reader' +import { readNativeChatTranscriptTail, subscribeNativeChatTranscript } from './transcript-watch' + +// A send while a tool runs: Claude stamps the queue entry at send time (10:00:10) +// but writes it after the tool result it read it with (10:00:30). +const PROMPT = { + type: 'user', + uuid: 'u-1', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'start the long task' } +} +const TOOL_CALL = { + type: 'assistant', + uuid: 'a-1', + timestamp: '2026-06-01T10:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'sleep 30' } }] + } +} +const TOOL_RESULT = { + type: 'user', + uuid: 'r-1', + timestamp: '2026-06-01T10:00:30.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }] + } +} +const QUEUED = { + type: 'attachment', + uuid: 'q-1', + parentUuid: 'r-1', + timestamp: '2026-06-01T10:00:10.000Z', + attachment: { + type: 'queued_command', + prompt: 'also check the config', + commandMode: 'prompt', + origin: { kind: 'human' }, + timestamp: '2026-06-01T10:00:10.000Z' + } +} +const REPLY = { + type: 'assistant', + uuid: 'a-2', + timestamp: '2026-06-01T10:00:35.000Z', + message: { + role: 'assistant', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'both done' }] + } +} + +const lines = (...records: object[]): string => + records.map((record) => `${JSON.stringify(record)}\n`).join('') + +let roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) + roots = [] +}) + +async function transcriptFile(content: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-transcript-file-order-')) + roots.push(root) + const filePath = join(root, 'session.jsonl') + await writeFile(filePath, content) + return filePath +} + +/** What the chat draws: the prompt after the tool run, with the call holding its result. */ +function expectPromptAfterToolRun(messages: NativeChatMessage[]): void { + const rows = projectNativeChatTranscriptMessages(messages) + expect(rows.map((row) => row.id)).toEqual(['u-1', 'a-1', 'q-1', 'a-2']) + expect(rows[1]?.blocks.map((block) => block.type)).toEqual(['tool-call', 'tool-result']) +} + +describe('transcript rows keep file order', () => { + it.each([ + ['full read', (filePath: string) => readNativeChatTranscript('claude', 's', { filePath })], + [ + 'tail read', + (filePath: string) => + readNativeChatTranscriptTail({ agent: 'claude', sessionId: 's', filePath, limit: 40 }) + ] + ])( + 'places a mid-turn prompt after the tool result it was read with (%s)', + async (_name, read) => { + const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT, QUEUED, REPLY)) + const result = await read(filePath) + if (!('messages' in result)) { + throw new Error(result.error) + } + expectPromptAfterToolRun(result.messages) + } + ) + + it('places a mid-turn prompt appended after the initial snapshot', async () => { + const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT)) + const received: NativeChatMessage[] = [] + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 's', + filePath, + initialLimit: 40, + onInitialSnapshot: (messages) => received.push(...messages), + onAppend: (messages) => received.push(...messages), + debounceMs: 5, + reconciliationIntervalMs: 20 + }) + try { + await expect.poll(() => received.length).toBe(3) + await appendFile(filePath, lines(QUEUED, REPLY)) + await expect.poll(() => received.length).toBe(5) + expectPromptAfterToolRun(received) + } finally { + subscription.unsubscribe() + } + }) +}) diff --git a/src/main/native-chat/transcript-file-order.ts b/src/main/native-chat/transcript-file-order.ts new file mode 100644 index 00000000000..0404cc00844 --- /dev/null +++ b/src/main/native-chat/transcript-file-order.ts @@ -0,0 +1,17 @@ +import type { NativeChatMessage } from '../../shared/native-chat-types' + +/** Desktop orders terminal transcript rows by time, but the file holds the true order: + * Claude stamps a prompt it took mid-turn with when it was queued, then writes it + * where it read it. So a row never sorts above the rows before it. `latest` is the + * newest time above this row; returns the row and the newest time including it. */ +export function inTranscriptFileOrder( + message: NativeChatMessage, + latest: number +): { message: NativeChatMessage; latest: number } { + if (message.timestamp === null || message.timestamp >= latest) { + return { message, latest: Math.max(latest, message.timestamp ?? latest) } + } + // Why: one past, not equal: equal times fall back to id order, which can still put it first. + const timestamp = latest + 1 + return { message: { ...message, timestamp }, latest: timestamp } +} diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 67bd3dce1e4..be93314d320 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -1,6 +1,7 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' import { extendTranscriptBoundary } from './transcript-file-version' +import { inTranscriptFileOrder } from './transcript-file-order' import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder @@ -16,6 +17,8 @@ export type IncrementalTranscriptState = { pendingStart: number pendingBytes: number droppingOversizedRecord: boolean + /** Newest row time read so far, so an append never sorts above earlier rows. */ + latestTimestamp: number } export function createIncrementalTranscriptState(): IncrementalTranscriptState { @@ -25,7 +28,8 @@ export function createIncrementalTranscriptState(): IncrementalTranscriptState { pendingChunks: [], pendingStart: 0, pendingBytes: 0, - droppingOversizedRecord: false + droppingOversizedRecord: false, + latestTimestamp: Number.NEGATIVE_INFINITY } } @@ -36,6 +40,7 @@ export function resetIncrementalTranscriptState(state: IncrementalTranscriptStat state.pendingStart = 0 state.pendingBytes = 0 state.droppingOversizedRecord = false + state.latestTimestamp = Number.NEGATIVE_INFINITY } export async function readIncrementalTranscriptMessages( @@ -127,7 +132,9 @@ export async function readIncrementalTranscriptMessages( if (!message) { return } - messages.push(message) + const ordered = inTranscriptFileOrder(message, state.latestTimestamp) + state.latestTimestamp = ordered.latest + messages.push(ordered.message) if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { onBatch(messages.splice(0)) } diff --git a/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts new file mode 100644 index 00000000000..7d2252ec6ae --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' + +// Shape captured from Claude Code 2.1.292 for a prompt absorbed mid-turn. +function queuedCommand(attachment: Record): string { + return JSON.stringify({ + parentUuid: 'tool-result-row', + isSidechain: false, + attachment: { + type: 'queued_command', + source_uuid: 'cmd-1', + delivery_id: 'delivery-1', + timestamp: '2026-10-06T21:49:54.910Z', + ...attachment + }, + type: 'attachment', + uuid: 'queued-row', + timestamp: '2026-10-06T21:49:54.910Z', + rendered: [{ content: '\nThe user sent a new message…' }] + }) +} + +describe('Claude queued_command attachment', () => { + it('decodes a prompt absorbed mid-turn as the user row Claude never writes', () => { + const line = queuedCommand({ + prompt: 'or you know what, use fable as orchestrator', + commandMode: 'prompt', + origin: { kind: 'human' }, + humanTurn: true + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')).toEqual({ + id: 'queued-row', + role: 'user', + blocks: [{ type: 'text', text: 'or you know what, use fable as orchestrator' }], + timestamp: Date.parse('2026-10-06T21:49:54.910Z'), + source: 'transcript', + parentId: 'tool-result-row' + }) + }) + + it('decodes legacy entries without an origin and content-block prompts', () => { + const decoded = decodeClaudeTranscriptLine( + queuedCommand({ + prompt: [{ type: 'text', text: 'is this true?' }], + commandMode: 'prompt' + }), + 'fallback' + ) + expect(decoded?.role).toBe('user') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'is this true?' }]) + }) + + it.each([ + { prompt: 'done', commandMode: 'task-notification' }, + { + prompt: 'done', + commandMode: 'task-notification', + origin: { kind: 'task-notification' } + }, + { prompt: 'from another agent', commandMode: 'prompt', origin: { kind: 'peer' } }, + { commandMode: 'prompt', origin: { kind: 'human' } } + ])('drops queue entries the user did not type (%o)', (attachment) => { + expect(decodeClaudeTranscriptLine(queuedCommand(attachment), 'fallback')).toBeNull() + }) + + it('still drops other attachment records', () => { + const line = JSON.stringify({ + type: 'attachment', + uuid: 'a', + attachment: { type: 'edited_text_file', filename: 'x.ts' } + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')).toBeNull() + }) +}) + +describe('Claude `!` shell command row', () => { + it('decodes as the line the user typed so its echo can match', () => { + const line = JSON.stringify({ + type: 'user', + uuid: 'bash-row', + timestamp: '2026-10-06T10:00:00.000Z', + message: { role: 'user', content: ' gcloud auth login' } + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')?.blocks).toEqual([ + { type: 'text', text: '! gcloud auth login' } + ]) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 8693b4296a9..5d8ec177ea9 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -16,6 +16,7 @@ import { import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers' import { claudeContentBlocks } from './transcript-record-blocks' import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content' +import { unwrapClaudeBashInputBlock } from '../../shared/claude-bash-input' import { claudeInterruptedMessageId } from './transcript-turn-markers' const MAX_EDIT_PATCH_HUNKS = 40 @@ -75,10 +76,11 @@ export function decodeClaudeTranscriptLine( line: string, fallbackId: string ): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { + const parsed = parseJsonObject(line) + if (!parsed) { return null } + const record = claudeQueuedPromptAsUserRecord(parsed) ?? parsed const role = record.type if (role !== 'user' && role !== 'assistant') { return null @@ -129,13 +131,45 @@ export function decodeClaudeTranscriptLine( return { id: messageId ?? fallbackId, role: claudeMessageRole(role, blocks), - blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, + // Why: a `!` command's echo reads `!cmd`; left as harness markup the row was + // hidden as noise and the echo stayed pinned at the tail (#25956). + blocks: + role === 'user' + ? blocks.map((block) => unwrapClaudeBashInputBlock(unwrapClaudePastedContentBlock(block))) + : blocks, timestamp, source: 'transcript', ...parent } } +/** A prompt sent mid-turn is recorded only as a `queued_command` attachment, never + * a `type:"user"` row; without one its echo had nothing to match and stayed + * pinned below every later turn (#14308, #20846). Task notifications and + * peer-agent messages share the attachment type but were not typed by the user. */ +function claudeQueuedPromptAsUserRecord( + record: Record +): Record | null { + if (record.type !== 'attachment') { + return null + } + const attachment = asRecord(record.attachment) + if (attachment?.type !== 'queued_command' || attachment.commandMode !== 'prompt') { + return null + } + // Why: older Claude versions omit `origin`; their prompt-mode queue entries + // were all typed by the user. + const originKind = asRecord(attachment.origin)?.kind + if (originKind !== undefined && originKind !== 'human') { + return null + } + const prompt = attachment.prompt + if (typeof prompt !== 'string' && !Array.isArray(prompt)) { + return null + } + return { ...record, type: 'user', message: { role: 'user', content: prompt } } +} + // Keep only genuine image companion records; a marker mixed with prose must // remain an injected turn (or be dropped), never become an image-source turn. function isImageSourceRecord(blocks: NativeChatBlock[]): boolean { diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 64de2adb625..4a6bb550fb2 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -2,6 +2,7 @@ import type { Readable } from 'node:stream' import { StringDecoder } from 'node:string_decoder' import type { NativeChatMessage } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' +import { inTranscriptFileOrder } from './transcript-file-order' type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -15,6 +16,7 @@ export async function decodeTranscriptStream( ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { const messages: NativeChatMessage[] = [] let consumedBytes = 0 + let latest = Number.NEGATIVE_INFINITY const framer = createTranscriptLineFramer((line, byteLength, terminated) => { if (terminated || includeTrailingLine) { decodeLine(line, consumedBytes) @@ -35,7 +37,9 @@ export async function decodeTranscriptStream( } const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) if (message) { - messages.push(message) + const ordered = inTranscriptFileOrder(message, latest) + latest = ordered.latest + messages.push(ordered.message) } } } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fce4b630e4e..38a81b0a57e 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -14,6 +14,7 @@ import { decodeOmpTranscriptLine } from './transcript-line-decoders' import { transcriptFallbackId } from './transcript-fallback-id' +import { inTranscriptFileOrder } from './transcript-file-order' import { extendTranscriptBoundary } from './transcript-file-version' import { nativeChatTurnLifecycleDecoderForAgent, @@ -68,6 +69,8 @@ export async function readNativeChatTranscriptTailFile( consumedTo: number hasMore: boolean beforeOffset: number + /** Newest row time up to `consumedTo`, where an incremental read continues. */ + latestTimestamp?: number malformedRecordCount?: number oversizedRecordCount?: number }> { @@ -153,7 +156,12 @@ export async function readNativeChatTranscriptTailFile( if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { decodeLine(0, newestFirst) } - const chronological = newestFirst.toReversed() + let latest = Number.NEGATIVE_INFINITY + const chronological = newestFirst.toReversed().map((entry) => { + const ordered = inTranscriptFileOrder(entry.message, latest) + latest = ordered.latest + return { message: ordered.message, offset: entry.offset } + }) // Why: slice(-0) returns the whole array, so a non-positive limit must // window to nothing explicitly rather than leak every buffered record. const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : [] @@ -163,6 +171,7 @@ export async function readNativeChatTranscriptTailFile( consumedTo, hasMore: limit > 0 && chronological.length > limit, beforeOffset: selected[0]?.offset ?? end, + ...(Number.isFinite(latest) ? { latestTimestamp: latest } : {}), ...(malformedRecordCount > 0 ? { malformedRecordCount } : {}), ...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {}) } diff --git a/src/main/native-chat/transcript-turn-lifecycle.test.ts b/src/main/native-chat/transcript-turn-lifecycle.test.ts index e46fcde0020..44bc59a33ed 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.test.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.test.ts @@ -243,6 +243,42 @@ describe('native chat transcript turn lifecycle', () => { ).toBeNull() }) + it.each([ + ' git status', + [{ type: 'text', text: 'ls' }] + ])('does not treat a `!` shell command as a new working generation', (content) => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'user', + uuid: 'bash-1', + timestamp: '2026-07-16T23:46:12.000Z', + message: { role: 'user', content } + }), + 'fallback' + ) + ).toBeNull() + }) + + it('does not treat a prompt absorbed mid-turn as a new generation', () => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'attachment', + uuid: 'queued-1', + timestamp: '2026-07-16T23:46:13.000Z', + attachment: { + type: 'queued_command', + prompt: 'also check the config', + commandMode: 'prompt', + origin: { kind: 'human' } + } + }), + 'fallback' + ) + ).toBeNull() + }) + it('excludes Claude tool-result rows that also carry text sidecars', () => { expect( decodeClaudeTurnLifecycle( diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts index b91e4fa7bee..0db70aea258 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.ts @@ -1,6 +1,7 @@ import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { isNoiseMessage } from '../../shared/native-chat-noise' +import { claudeBashInputAsTypedCommand } from '../../shared/claude-bash-input' import { asRecord, extractString, @@ -120,12 +121,25 @@ export function decodeClaudeTurnLifecycle( // Why: harness noise (task-notification, system-reminder, …) is user-role in // the JSONL but not a new generation. Treating it as working would overwrite // a real terminal marker and re-stick the chat spinner after done/interrupt. - if (isNoiseMessage(decoded)) { + if (isNoiseMessage(decoded) || isClaudeBashInputRecord(record)) { return null } return { state: 'working', turnId: decoded.id, timestamp } } +// Why: a `!` row is the shell command, not a prompt; any reply Claude writes after it +// carries its own completed marker, and with none a working mark would stick. +function isClaudeBashInputRecord(record: Record): boolean { + const content = asRecord(record.message)?.content + const texts = + typeof content === 'string' + ? [content] + : Array.isArray(content) + ? content.map((block) => extractString(asRecord(block)?.text)) + : [] + return texts.some((text) => text != null && claudeBashInputAsTypedCommand(text) !== null) +} + function lifecycleTimestamp(value: unknown): number | null { const parsed = timestampMs(value) return Number.isFinite(parsed) ? parsed : null diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index 894863bc970..9792323092a 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -164,6 +164,7 @@ export async function installTranscriptWatcher( if (replacementSnapshot && onReplace) { state.offset = replacementSnapshot.consumedTo state.pendingStart = state.offset + state.latestTimestamp = replacementSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY onReplace( replacementSnapshot.messages, replacementSnapshot.hasMore, @@ -186,6 +187,7 @@ export async function installTranscriptWatcher( if (initialSnapshot) { state.offset = initialSnapshot.consumedTo state.pendingStart = state.offset + state.latestTimestamp = initialSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY onInitialSnapshot( initialSnapshot.messages, initialSnapshot.hasMore, diff --git a/src/shared/claude-bash-input.test.ts b/src/shared/claude-bash-input.test.ts new file mode 100644 index 00000000000..88626ecf1c5 --- /dev/null +++ b/src/shared/claude-bash-input.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { claudeBashInputAsTypedCommand } from './claude-bash-input' + +describe('claudeBashInputAsTypedCommand', () => { + it('restores the line as typed, keeping the space after `!`', () => { + expect(claudeBashInputAsTypedCommand(' gcloud auth login')).toBe( + '! gcloud auth login' + ) + expect(claudeBashInputAsTypedCommand('ls -la')).toBe('!ls -la') + }) + it.each([ + 'run ls', + 'ls and more', + 'ok', + 'ab' + ])('leaves anything else alone (%s)', (text) => { + expect(claudeBashInputAsTypedCommand(text)).toBeNull() + }) +}) diff --git a/src/shared/claude-bash-input.ts b/src/shared/claude-bash-input.ts new file mode 100644 index 00000000000..4c36a05c9f9 --- /dev/null +++ b/src/shared/claude-bash-input.ts @@ -0,0 +1,18 @@ +import type { NativeChatBlock } from './native-chat-types' + +const BASH_INPUT = /^\s*([\s\S]*)<\/bash-input>\s*$/ + +/** Claude records a `!` shell command as `cmd`, with the `!` + * stripped. Returns the line as typed, or null when the text is anything else. */ +export function claudeBashInputAsTypedCommand(text: string): string | null { + const command = BASH_INPUT.exec(text)?.[1] + return command === undefined || command.includes('') ? null : `!${command}` +} + +export function unwrapClaudeBashInputBlock(block: NativeChatBlock): NativeChatBlock { + if (block.type !== 'text') { + return block + } + const typed = claudeBashInputAsTypedCommand(block.text) + return typed === null ? block : { ...block, text: typed } +}