diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx index 2786d45e975..45118f8c270 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tool-result-availability.test.tsx @@ -63,6 +63,9 @@ vi.mock('lucide-react-native', () => ({ Wrench: 'Wrench' })) vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('../../../components/MobileSourceCopyButton', () => ({ + MobileSourceCopyButton: 'CopyButton' +})) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' })) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.work-run.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.work-run.test.tsx index 96ef93d60b3..a623e950974 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.work-run.test.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.work-run.test.tsx @@ -54,6 +54,9 @@ vi.mock('lucide-react-native', () => ({ Wrench: 'Wrench' })) vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('../../../components/MobileSourceCopyButton', () => ({ + MobileSourceCopyButton: 'CopyButton' +})) vi.mock('../../../components/inline-text-selection', () => ({ INLINE_TEXT_SELECTION: false })) vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatToolLine.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatToolLine.tsx index 91277e5390e..d52d0752faf 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatToolLine.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatToolLine.tsx @@ -20,6 +20,7 @@ import type { NativeChatToolCallBlock, NativeChatToolResultBlock } from '../../../../../src/shared/native-chat-types' +import { MobileSourceCopyButton } from '../../../components/MobileSourceCopyButton' import { MobileSyntaxSegments } from '../../../components/MobileSyntaxSegments' import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection' import { openExternalLink } from '../../../platform/external-link' @@ -163,7 +164,8 @@ export function MobileNativeChatToolLine({ () => nativeChatToolLineView(block, result), [block, result] ) - const fullCommand = label?.commandDetail ?? null + const commandDetail = label?.commandDetail ?? null + const fullCommand = label?.fullCommand ?? null const verb = label?.verb ? NATIVE_CHAT_TOOL_ROW_VERB_COPY[label.verb] : null const commandAlone = label?.command === true && verb !== null && label.target.length > 0 const mcp = call ? mcpToolIdentity(call.name, call.mcpIdentity) : null @@ -181,60 +183,65 @@ export function MobileNativeChatToolLine({ return ( - hasDetail && setExpanded(!expanded)} - disabled={!hasDetail} - accessibilityRole="button" - accessibilityState={hasDetail ? { expanded } : undefined} - > - - {call ? ( - - ) : null} - - {commandAlone ? null : ( - - {verb ?? name} - - )} - {target ? ( - onOpenFile(filePath) : undefined} - suppressHighlighting={!(filePath && onOpenFile)} - accessibilityRole={filePath && onOpenFile ? 'link' : undefined} - > - {target} - - ) : null} - {/* Held at the row's right edge, so the carets of a run line up in one column. */} - - {exitCode !== undefined ? ( - - {`exit ${exitCode}`} + + hasDetail && setExpanded(!expanded)} + disabled={!hasDetail} + accessibilityRole="button" + accessibilityState={hasDetail ? { expanded } : undefined} + > + + {call ? ( + + ) : null} + + {commandAlone ? null : ( + + {verb ?? name} + + )} + {target ? ( + onOpenFile(filePath) : undefined} + suppressHighlighting={!(filePath && onOpenFile)} + accessibilityRole={filePath && onOpenFile ? 'link' : undefined} + > + {target} ) : null} - {duration ? {duration} : null} - {hasDetail ? ( - - - - ) : null} - - + {/* Held at the row's right edge, so the carets of a run line up in one column. */} + + {exitCode !== undefined ? ( + + {`exit ${exitCode}`} + + ) : null} + {duration ? {duration} : null} + {hasDetail ? ( + + + + ) : null} + + + {fullCommand ? ( + + ) : null} + {hasDetail && expanded ? ( { expect(onOpenFile).toHaveBeenCalledTimes(1) }) + it('copies exactly the command a shell row ran, and offers no copy on other rows', () => { + const tree = render([ + { type: 'tool-call', name: 'Read', input: { file_path: 'src/a.ts' } }, + { type: 'tool-result', output: 'a' }, + { + type: 'tool-call', + name: 'exec_command', + input: { cmd: "/bin/zsh -lc 'cd mobile &&\n pnpm test'", workdir: '/repo' } + }, + { type: 'tool-result', output: 'ok' } + ]) + const copies = tree.root.findAll((node) => String(node.type) === 'CopyButton') + expect(copies.map((node) => node.props.text)).toEqual(['cd mobile &&\n pnpm test']) + }) + it('offers no caret on a row with nothing to show', () => { const tree = render([{ type: 'tool-call', name: 'Agent', input: { description: 'Explore' } }]) expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Subagent', 'Explore'])) diff --git a/src/shared/native-chat/tools/native-chat-tool-line-label.ts b/src/shared/native-chat/tools/native-chat-tool-line-label.ts index 6ba358e4d6e..fe5bf845c1b 100644 --- a/src/shared/native-chat/tools/native-chat-tool-line-label.ts +++ b/src/shared/native-chat/tools/native-chat-tool-line-label.ts @@ -45,6 +45,8 @@ export type NativeChatToolLineLabel = { title: string command: boolean filePath: string | null + /** The whole command a command row ran; null when its input names none. */ + fullCommand: string | null /** The whole command, when the row had to shorten it. */ commandDetail: string | null /** Grammar of `commandDetail`; null when the row holds no command text. */ @@ -79,6 +81,7 @@ export function nativeChatToolLineLabel( (call.state === 'completed' || result !== undefined) const failed = call.state === 'failed' || result?.isError === true const phase = { running, completed, failed } + let fullCommand: string | null = null let commandDetail: string | null = null let commandLanguage: string | null = null let verb: NativeChatToolRowVerb | null = null @@ -93,7 +96,7 @@ export function nativeChatToolLineLabel( : null if (command) { - const fullCommand = nativeChatFullCommand(call.input) + fullCommand = nativeChatFullCommand(call.input) target = fullCommand === null ? target : summarizeToolInput(fullCommand) title = fullCommand ?? target if (fullCommand !== null) { @@ -140,5 +143,5 @@ export function nativeChatToolLineLabel( }) } } - return { verb, target, title, command, filePath, commandDetail, commandLanguage } + return { verb, target, title, command, filePath, fullCommand, commandDetail, commandLanguage } } diff --git a/src/shared/native-chat/tools/native-chat-tool-preview-prefix.test.ts b/src/shared/native-chat/tools/native-chat-tool-preview-prefix.test.ts index 1efdf464a08..c9d9a001f0b 100644 --- a/src/shared/native-chat/tools/native-chat-tool-preview-prefix.test.ts +++ b/src/shared/native-chat/tools/native-chat-tool-preview-prefix.test.ts @@ -12,6 +12,9 @@ describe('unwrapLoginShellCommand', () => { it('keeps a command that only happens to mention a shell', () => { expect(unwrapLoginShellCommand('git status')).toBe('git status') expect(unwrapLoginShellCommand('echo /bin/zsh -lc')).toBe('echo /bin/zsh -lc') + expect(unwrapLoginShellCommand(`/bin/bash -lc 'echo /bin/bash -lc "ls"'`)).toBe( + 'echo /bin/bash -lc "ls"' + ) }) it('leaves the wrapper alone when the remainder is not one quoted string', () => { @@ -19,12 +22,22 @@ describe('unwrapLoginShellCommand', () => { expect(unwrapLoginShellCommand(`/bin/zsh -lc 'ls' | head`)).toBe(`/bin/zsh -lc 'ls' | head`) expect(unwrapLoginShellCommand(`/bin/zsh -lc "unterminated`)).toBe(`/bin/zsh -lc "unterminated`) expect(unwrapLoginShellCommand(`/bin/zsh -lc ls`)).toBe(`/bin/zsh -lc ls`) + expect(unwrapLoginShellCommand(`/bin/zsh -lc 'a' | 'b'`)).toBe(`/bin/zsh -lc 'a' | 'b'`) }) it('keeps quotes that belong to the command itself', () => { expect(unwrapLoginShellCommand(`/bin/zsh -lc "rg -n 'needle' src"`)).toBe(`rg -n 'needle' src`) }) + it('undoes the quoting Codex used to join the script, as the shell would', () => { + expect(unwrapLoginShellCommand(String.raw`/bin/zsh -lc "rg -n \"a|b\" src"`)).toBe( + `rg -n "a|b" src` + ) + expect(unwrapLoginShellCommand(String.raw`/bin/zsh -lc "rg 'x\\.js\"'$'\"'; ls"`)).toBe( + String.raw`rg 'x\.js"'$'"'; ls` + ) + }) + it('unwraps a multi-line command without collapsing it', () => { expect(unwrapLoginShellCommand(`/bin/zsh -lc 'set -e\ngit push'`)).toBe('set -e\ngit push') }) diff --git a/src/shared/native-chat/tools/native-chat-tool-preview-prefix.ts b/src/shared/native-chat/tools/native-chat-tool-preview-prefix.ts index 89d315cb593..0161632e83b 100644 --- a/src/shared/native-chat/tools/native-chat-tool-preview-prefix.ts +++ b/src/shared/native-chat/tools/native-chat-tool-preview-prefix.ts @@ -28,11 +28,68 @@ export function collapsedToolInputPrefix(input: string): string { return collapsed } -/** Strip login-shell wrappers only when the entire remainder is one quoted string. Pipelines/unpaired quotes stay literal. +/** Strip login-shell wrappers only when the entire remainder is one quoted shell word. Pipelines/unpaired quotes stay literal. * Run before clipping: the closing quote proves the wrapper and would be lost from a bounded prefix. */ -const LOGIN_SHELL_COMMAND = - /^\s*(?:.*[\\/])?(?:ba|z|k|da|fi)?sh(?:\.exe)?\s+-[a-zA-Z]*c\s+(['"])([\s\S]*)\1\s*$/ +const LOGIN_SHELL_PREFIX = + /^\s*(?:[^'"]*[\\/])?(?:ba|z|k|da|fi)?sh(?:\.exe)?\s+-[a-zA-Z]*c\s+(?=['"])/ +const DOUBLE_QUOTED_RUN = /[^"\\]+/y +const DOUBLE_QUOTE_ESCAPABLE = '$`"\\\n' +const PLAIN_WORD_CHAR = /[\w@%+=:,./-]/ export function unwrapLoginShellCommand(command: string): string { - return LOGIN_SHELL_COMMAND.exec(command)?.[2] ?? command + const prefix = LOGIN_SHELL_PREFIX.exec(command) + return prefix + ? (decodeQuotedShellWord(command.slice(prefix[0].length).trimEnd()) ?? command) + : command +} + +// Agents join argv by switching quote styles mid-word (`"a'b"'$'`), so undo POSIX quoting, not just the outer pair. +function decodeQuotedShellWord(word: string): string | null { + let decoded = '' + let index = 0 + while (index < word.length) { + const char = word[index] + if (char === "'") { + const end = word.indexOf("'", index + 1) + if (end === -1) { + return null + } + decoded += word.slice(index + 1, end) + index = end + 1 + } else if (char === '"') { + index += 1 + for (;;) { + DOUBLE_QUOTED_RUN.lastIndex = index + if (DOUBLE_QUOTED_RUN.test(word)) { + decoded += word.slice(index, DOUBLE_QUOTED_RUN.lastIndex) + index = DOUBLE_QUOTED_RUN.lastIndex + } + if (index >= word.length) { + return null + } + if (word[index] === '"') { + index += 1 + break + } + const next = word[index + 1] + if (next !== undefined && DOUBLE_QUOTE_ESCAPABLE.includes(next)) { + // A backslash-newline inside double quotes is a line continuation. + decoded += next === '\n' ? '' : next + index += 2 + } else { + decoded += '\\' + index += 1 + } + } + } else if (char === '\\' && index + 1 < word.length) { + decoded += word[index + 1] === '\n' ? '' : word[index + 1] + index += 2 + } else if (PLAIN_WORD_CHAR.test(char)) { + decoded += char + index += 1 + } else { + return null + } + } + return decoded }