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
}