feat(mobile): copy button on shell command rows in chat (#34432)

* feat(mobile): copy button on shell command rows in chat

* test(mobile): stub the copy button where chat tests count tool row presses

* fix(chat): decode the login-shell script the way the shell does

Codex joins argv by switching quote styles, so stripping only the outer
quotes left \" escapes and quote splices in about a fifth of real
commands. Argv arrays are now shell-quoted when joined.

* fix(chat): keep the shell-path match to the outer command; reuse the label's full command

Argv arrays go back to a plain join: no argv-shaped shell calls exist in
real transcripts, and quoting them broke PowerShell and cmd.exe argv.

* test(mobile): leave quote decoding to the shared unwrap test

* refactor(chat): slice double-quoted runs and name the row's command fields plainly

* style(mobile): format copy button mocks in chat row tests
This commit is contained in:
Kelvin Amoaba
2026-10-27 16:34:48 +00:00
committed by GitHub
parent 93a065031b
commit 53e6a7d4a9
7 changed files with 154 additions and 53 deletions
@@ -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'
}))
@@ -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'
@@ -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 (
<View testID={memberId}>
<Pressable
style={styles.line}
onPress={() => hasDetail && setExpanded(!expanded)}
disabled={!hasDetail}
accessibilityRole="button"
accessibilityState={hasDetail ? { expanded } : undefined}
>
<View style={[styles.iconSlot, { width: iconSize }]}>
{call ? (
<MobileNativeChatToolGlyph
name={nativeChatToolIconName(call.name, call.mcpIdentity, call.input)}
size={iconSize}
color={failed ? colors.statusRed : colors.textMuted}
/>
) : null}
</View>
{commandAlone ? null : (
<Text style={[styles.lineWord, type.row, !verb && styles.lineName]} numberOfLines={1}>
{verb ?? name}
</Text>
)}
{target ? (
<Text
style={[styles.lineTarget, type.row, filePath && onOpenFile && styles.lineLink]}
numberOfLines={1}
onPress={filePath && onOpenFile ? () => onOpenFile(filePath) : undefined}
suppressHighlighting={!(filePath && onOpenFile)}
accessibilityRole={filePath && onOpenFile ? 'link' : undefined}
>
{target}
</Text>
) : null}
{/* Held at the row's right edge, so the carets of a run line up in one column. */}
<View style={styles.lineTrailing}>
{exitCode !== undefined ? (
<Text style={[styles.lineMeta, type.meta, exitCode !== 0 && styles.errorText]}>
{`exit ${exitCode}`}
<View style={styles.line}>
<Pressable
style={[styles.line, styles.cardToggle]}
onPress={() => hasDetail && setExpanded(!expanded)}
disabled={!hasDetail}
accessibilityRole="button"
accessibilityState={hasDetail ? { expanded } : undefined}
>
<View style={[styles.iconSlot, { width: iconSize }]}>
{call ? (
<MobileNativeChatToolGlyph
name={nativeChatToolIconName(call.name, call.mcpIdentity, call.input)}
size={iconSize}
color={failed ? colors.statusRed : colors.textMuted}
/>
) : null}
</View>
{commandAlone ? null : (
<Text style={[styles.lineWord, type.row, !verb && styles.lineName]} numberOfLines={1}>
{verb ?? name}
</Text>
)}
{target ? (
<Text
style={[styles.lineTarget, type.row, filePath && onOpenFile && styles.lineLink]}
numberOfLines={1}
onPress={filePath && onOpenFile ? () => onOpenFile(filePath) : undefined}
suppressHighlighting={!(filePath && onOpenFile)}
accessibilityRole={filePath && onOpenFile ? 'link' : undefined}
>
{target}
</Text>
) : null}
{duration ? <Text style={[styles.lineMeta, type.meta]}>{duration}</Text> : null}
{hasDetail ? (
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</View>
</Pressable>
{/* Held at the row's right edge, so the carets of a run line up in one column. */}
<View style={styles.lineTrailing}>
{exitCode !== undefined ? (
<Text style={[styles.lineMeta, type.meta, exitCode !== 0 && styles.errorText]}>
{`exit ${exitCode}`}
</Text>
) : null}
{duration ? <Text style={[styles.lineMeta, type.meta]}>{duration}</Text> : null}
{hasDetail ? (
<View style={expanded ? styles.caretOpen : undefined}>
<ChevronRight size={14 * fontScale} color={colors.textMuted} strokeWidth={2} />
</View>
) : null}
</View>
</Pressable>
{fullCommand ? (
<MobileSourceCopyButton text={fullCommand} accessibilityLabel="Copy command" />
) : null}
</View>
{hasDetail && expanded ? (
<ToolLineDetail
memberId={memberId}
disclosureKey={disclosureKey}
call={call}
body={body}
fullCommand={fullCommand}
fullCommand={commandDetail}
commandLanguage={label?.commandLanguage ?? null}
results={results}
fontScale={fontScale}
@@ -206,6 +206,21 @@ describe('ToolRun', () => {
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']))
@@ -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 }
}
@@ -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')
})
@@ -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
}