mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
+3
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user