⌨️ refactor: Clarify Bash Command Drafting State (#12963)

This commit is contained in:
Danny Avila 2026-05-05 22:28:05 -04:00 committed by GitHub
parent f839a447e1
commit 25a4556aee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 156 additions and 10 deletions

View file

@ -8,7 +8,7 @@ import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
import { ERROR_PATTERNS } from './ExecuteCode';
import { AttachmentGroup } from './Attachment';
import parseJsonField from './parseJsonField';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -27,6 +27,7 @@ export default function BashCall({
}) {
const localize = useLocalize();
const command = useMemo(() => parseJsonField(args, 'command'), [args]);
const isWritingCommand = !command || !areToolCallArgsComplete(args);
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
useToolCallState(initialProgress, isSubmitting, output, !!command);
@ -51,7 +52,11 @@ export default function BashCall({
<ProgressText
progress={progress}
onClick={toggleCode}
inProgressText={localize('com_ui_running_command')}
inProgressText={
isWritingCommand
? localize('com_ui_writing_command')
: localize('com_ui_running_command')
}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_command_finished')
}

View file

@ -0,0 +1,98 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import BashCall from '../BashCall';
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string => {
const translations: Record<string, string> = {
com_ui_writing_command: 'Writing command',
com_ui_running_command: 'Running command',
com_ui_command_finished: 'Finished running',
com_ui_cancelled: 'Cancelled',
com_ui_copy_code: 'Copy code',
};
return translations[key] ?? key;
},
useProgress: (initialProgress: number) => initialProgress,
useExpandCollapse: (isExpanded: boolean) => ({
style: {
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
opacity: isExpanded ? 1 : 0,
},
ref: { current: null },
}),
}));
jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({
__esModule: true,
default: ({
progress,
inProgressText,
finishedText,
}: {
progress: number;
inProgressText: string;
finishedText: string;
}) => <div data-testid="progress-text">{progress < 1 ? inProgressText : finishedText}</div>,
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
__esModule: true,
default: ({ label }: { label?: string }) => <button type="button">{label}</button>,
}));
jest.mock('~/components/Messages/Content/LangIcon', () => ({
__esModule: true,
default: () => <span data-testid="lang-icon" />,
}));
jest.mock('../Attachment', () => ({
AttachmentGroup: () => <div data-testid="attachment-group" />,
}));
jest.mock('../useLazyHighlight', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('copy-to-clipboard', () => jest.fn());
jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
const renderBashCall = (args?: string | Record<string, unknown>) =>
render(
<RecoilRoot>
<BashCall initialProgress={0.1} isSubmitting={true} args={args} output="" />
</RecoilRoot>,
);
describe('BashCall status text', () => {
it.each([undefined, '', '{"command":"sleep 10"', '{"command":"sleep 10","timeout":'])(
'shows "Writing command" while args are missing or incomplete: %s',
(args) => {
renderBashCall(args);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Writing command');
expect(screen.queryByText('Running command')).not.toBeInTheDocument();
},
);
it('keeps showing "Writing command" for partial JSON even after the command field is visible', () => {
renderBashCall('{"command":"sleep 10","incomplete":');
expect(screen.getByTestId('progress-text')).toHaveTextContent('Writing command');
expect(screen.getByText(/sleep 10/)).toBeInTheDocument();
});
it.each(['{"command":"sleep 10"}', { command: 'sleep 10' }])(
'shows "Running command" once command args are complete: %s',
(args) => {
renderBashCall(args);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command');
},
);
});

View file

@ -1,4 +1,4 @@
import parseJsonField from '../parseJsonField';
import parseJsonField, { areToolCallArgsComplete } from '../parseJsonField';
describe('parseJsonField', () => {
describe('object args', () => {
@ -101,3 +101,25 @@ describe('parseJsonField', () => {
});
});
});
describe('areToolCallArgsComplete', () => {
it.each([undefined, '', ' ', '{"command":"ls"', '{"command":"ls","timeout":'])(
'returns false for missing or partial args %s',
(args) => {
expect(areToolCallArgsComplete(args)).toBe(false);
},
);
it.each([
[{ command: 'ls -la' }],
['{"command":"ls -la"}'],
['{"command":"sleep 1","timeout":1000}'],
])('returns true for complete object args %s', (args) => {
expect(areToolCallArgsComplete(args)).toBe(true);
});
it('returns false for complete non-object JSON', () => {
expect(areToolCallArgsComplete('"ls -la"')).toBe(false);
expect(areToolCallArgsComplete('[]')).toBe(false);
});
});

View file

@ -1,8 +1,22 @@
type ToolCallArgs = string | Record<string, unknown> | undefined;
export function areToolCallArgsComplete(args: ToolCallArgs): boolean {
if (typeof args === 'object' && args !== null) {
return true;
}
if (typeof args !== 'string' || args.trim().length === 0) {
return false;
}
try {
const parsed = JSON.parse(args);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
} catch {
return false;
}
}
/** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */
export default function parseJsonField(
args: string | Record<string, unknown> | undefined,
field: string,
): string {
export default function parseJsonField(args: ToolCallArgs, field: string): string {
if (typeof args === 'object' && args !== null) {
return String(args[field] ?? '');
}
@ -20,7 +34,13 @@ export default function parseJsonField(
if (!match) {
return '';
}
return match[1].replace(/\\(.)/g, (_, c: string) =>
c === 'n' ? '\n' : c === '"' ? '"' : c === '\\' ? '\\' : `\\${c}`,
);
return match[1].replace(/\\(.)/g, (_, c: string) => {
if (c === 'n') {
return '\n';
}
if (c === '"' || c === '\\') {
return c;
}
return `\\${c}`;
});
}

View file

@ -853,6 +853,7 @@
"com_ui_command_finished": "Finished running",
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
"com_ui_writing_command": "Writing command",
"com_ui_complete": "Complete!",
"com_ui_complete_setup": "Complete Setup",
"com_ui_concise": "Concise",