mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 07:55:29 +08:00
feat(chat): render shell commands as collapsible Codex-style rows
Show Bash tool calls as a compact, single-line command with a chevron that expands to reveal the output inline, instead of hiding successful output and popping a separate red box on error. - Add BashCommandDisplay: command row with $ prompt, status/spinner, line-count hint, copy button, and an inline output panel (errors auto-expand and tint red). - Add CommandRunGroup: collapse 2+ consecutive commands under one "Ran N commands" header; expanding reveals each command, which stays independently expandable. Collapsed by default; opens on error. - Group consecutive Bash runs in ChatMessagesPane and route single Bash calls through BashCommandDisplay in ToolRenderer. - Suppress the duplicate generic result section for Bash in MessageComponent since output now lives in the command row. - Theme-integrated surfaces (no hard black boxes), emerald accent, subtle motion, and clean focus states for a modern, uncluttered look.
This commit is contained in:
@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import { PlanDisplay } from './components/PlanDisplay';
|
||||
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
||||
import type { ToolStatus } from './components/ToolStatusBadge';
|
||||
@@ -125,6 +125,31 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
|
||||
if (!displayConfig) return null;
|
||||
|
||||
// Bash renders as a Codex-style command row: the command on a single line with
|
||||
// a chevron that expands to show the output inline. The combined view lives on
|
||||
// the input render; the separate result section is suppressed in MessageComponent.
|
||||
if (toolName === 'Bash' && mode === 'input') {
|
||||
const command = parsedData?.command || '';
|
||||
const description = parsedData?.description;
|
||||
const output = typeof toolResult?.content === 'string'
|
||||
? toolResult.content
|
||||
: toolResult?.content != null
|
||||
? String(toolResult.content)
|
||||
: '';
|
||||
return (
|
||||
<BashCommandDisplay
|
||||
command={command}
|
||||
description={description}
|
||||
output={output}
|
||||
isError={Boolean(toolResult?.isError)}
|
||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
||||
// Commands stay collapsed by default (even consecutive ones); only
|
||||
// failures auto-expand so they remain visible.
|
||||
defaultOpen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'one-line') {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||
|
||||
155
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
155
src/components/chat/tools/components/BashCommandDisplay.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Copy, Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||
import type { ToolStatus } from './ToolStatusBadge';
|
||||
|
||||
interface BashCommandDisplayProps {
|
||||
command: string;
|
||||
description?: string;
|
||||
/** Combined stdout/stderr from the tool result (empty while running). */
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
status?: ToolStatus;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex-in-VSCode style command row: a compact, single-line command with a
|
||||
* chevron on the left. When the command produced output, the row becomes a
|
||||
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
|
||||
* keep it clean in both light and dark mode; consecutive commands stack tightly
|
||||
* into a clean list.
|
||||
*/
|
||||
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
||||
command,
|
||||
description,
|
||||
output,
|
||||
isError = false,
|
||||
status,
|
||||
defaultOpen = false,
|
||||
}) => {
|
||||
const trimmedOutput = (output || '').replace(/\s+$/, '');
|
||||
const hasOutput = trimmedOutput.length > 0;
|
||||
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
|
||||
const isRunning = status === 'running';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Output (and errors) often arrive after this component first mounts, so apply
|
||||
// the auto-open intent once when there is finally something to show. After that
|
||||
// the user is in control of the toggle.
|
||||
const autoAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
|
||||
autoAppliedRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasOutput, defaultOpen, isError]);
|
||||
|
||||
const toggle = () => {
|
||||
if (hasOutput) {
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
const didCopy = await copyTextToClipboard(command);
|
||||
if (!didCopy) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
|
||||
isError ? 'border-red-500/30' : 'border-border/60',
|
||||
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
|
||||
open && 'bg-muted/50 shadow-sm',
|
||||
)}
|
||||
>
|
||||
{/* Command header — clickable when there is output to expand */}
|
||||
<div
|
||||
role={hasOutput ? 'button' : undefined}
|
||||
tabIndex={hasOutput ? 0 : undefined}
|
||||
aria-expanded={hasOutput ? open : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
|
||||
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
!hasOutput && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
|
||||
$
|
||||
</span>
|
||||
<code
|
||||
className={cn(
|
||||
'min-w-0 flex-1 font-mono text-xs text-foreground',
|
||||
open ? 'whitespace-pre-wrap break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
|
||||
{isRunning && (
|
||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||
)}
|
||||
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
|
||||
{!open && hasOutput && !isRunning && (
|
||||
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
|
||||
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
||||
title="Copy command"
|
||||
aria-label="Copy command"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{description && !open && (
|
||||
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded output */}
|
||||
{open && hasOutput && (
|
||||
<div className="settings-content-enter border-t border-border/50 bg-background/50">
|
||||
{description && (
|
||||
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
|
||||
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{trimmedOutput}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
src/components/chat/tools/components/CommandRunGroup.tsx
Normal file
124
src/components/chat/tools/components/CommandRunGroup.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRight, Terminal } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import { BashCommandDisplay } from './BashCommandDisplay';
|
||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
||||
import type { ToolStatus } from './ToolStatusBadge';
|
||||
|
||||
interface CommandRunGroupProps {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
type ExtractedCommand = {
|
||||
key: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
status: ToolStatus;
|
||||
};
|
||||
|
||||
function extractCommand(message: ChatMessage, index: number): ExtractedCommand {
|
||||
let command = '';
|
||||
let description: string | undefined;
|
||||
try {
|
||||
const parsed =
|
||||
typeof message.toolInput === 'string' ? JSON.parse(message.toolInput) : message.toolInput;
|
||||
command = parsed?.command || '';
|
||||
description = parsed?.description;
|
||||
} catch {
|
||||
command = typeof message.toolInput === 'string' ? message.toolInput : '';
|
||||
}
|
||||
|
||||
const result = message.toolResult;
|
||||
const rawContent = result?.content;
|
||||
const output =
|
||||
typeof rawContent === 'string' ? rawContent : rawContent != null ? String(rawContent) : '';
|
||||
const isError = Boolean(result?.isError);
|
||||
const status: ToolStatus = !result ? 'running' : isError ? 'error' : 'completed';
|
||||
|
||||
return {
|
||||
key: message.toolId || `${command}-${index}`,
|
||||
command,
|
||||
description,
|
||||
output,
|
||||
isError,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a run of consecutive shell commands under a single collapsible header
|
||||
* (Codex-in-VSCode style). Collapsed by default so long command runs stay tidy;
|
||||
* expanding reveals every command in the run, each independently expandable for
|
||||
* its own output.
|
||||
*/
|
||||
export const CommandRunGroup: React.FC<CommandRunGroupProps> = ({ messages }) => {
|
||||
const commands = messages.map(extractCommand);
|
||||
const count = commands.length;
|
||||
const anyRunning = commands.some((c) => c.status === 'running');
|
||||
const anyError = commands.some((c) => c.isError);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Surface failed runs without a click: open once when an error first appears.
|
||||
const autoAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoAppliedRef.current && anyError) {
|
||||
autoAppliedRef.current = true;
|
||||
setOpen(true);
|
||||
}
|
||||
}, [anyError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border bg-muted/30 transition-all duration-200',
|
||||
anyError ? 'border-red-500/30' : 'border-border/60',
|
||||
open && 'shadow-sm',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">
|
||||
{anyRunning ? 'Running' : 'Ran'} <span className="text-muted-foreground">{count} commands</span>
|
||||
</span>
|
||||
{anyRunning && (
|
||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
||||
)}
|
||||
{anyError && <ToolStatusBadge status="error" className="flex-shrink-0" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="settings-content-enter space-y-1 border-t border-border/50 p-2">
|
||||
{commands.map((cmd) => (
|
||||
<BashCommandDisplay
|
||||
key={cmd.key}
|
||||
command={cmd.command}
|
||||
description={cmd.description}
|
||||
output={cmd.output}
|
||||
isError={cmd.isError}
|
||||
status={cmd.status !== 'completed' ? cmd.status : undefined}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { BashCommandDisplay } from './BashCommandDisplay';
|
||||
export { CommandRunGroup } from './CommandRunGroup';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export { SubagentContainer } from './SubagentContainer';
|
||||
export * from './ContentRenderers';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type {
|
||||
@@ -13,6 +13,7 @@ import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import { CommandRunGroup } from '../../tools';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
@@ -252,35 +253,63 @@ export default function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((message, index) => {
|
||||
// Walk back past messages that are not actually rendered (e.g. thinking
|
||||
// messages hidden when showThinking is off). Otherwise a hidden thinking
|
||||
// message would make the following message look "grouped" and suppress its
|
||||
// provider header/icon — which is why Claude turns lost their icon.
|
||||
let prevMessage: ChatMessage | null = null;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const candidate = visibleMessages[i];
|
||||
if (candidate.isThinking && !showThinking) continue;
|
||||
prevMessage = candidate;
|
||||
break;
|
||||
{(() => {
|
||||
const isBashCommand = (m: ChatMessage | null | undefined) =>
|
||||
Boolean(m && m.isToolUse && m.toolName === 'Bash' && !m.isSubagentContainer);
|
||||
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
for (let index = 0; index < visibleMessages.length; index++) {
|
||||
const message = visibleMessages[index];
|
||||
|
||||
// Collapse a run of 2+ consecutive shell commands under a single
|
||||
// header so long command runs stay tidy (Codex-in-VSCode style).
|
||||
if (isBashCommand(message)) {
|
||||
let end = index;
|
||||
while (end + 1 < visibleMessages.length && isBashCommand(visibleMessages[end + 1])) {
|
||||
end++;
|
||||
}
|
||||
if (end > index) {
|
||||
const groupMessages = visibleMessages.slice(index, end + 1);
|
||||
items.push(
|
||||
<CommandRunGroup key={getMessageKey(groupMessages[0])} messages={groupMessages} />,
|
||||
);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk back past messages that are not actually rendered (e.g. thinking
|
||||
// messages hidden when showThinking is off). Otherwise a hidden thinking
|
||||
// message would make the following message look "grouped" and suppress its
|
||||
// provider header/icon — which is why Claude turns lost their icon.
|
||||
let prevMessage: ChatMessage | null = null;
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const candidate = visibleMessages[i];
|
||||
if (candidate.isThinking && !showThinking) continue;
|
||||
prevMessage = candidate;
|
||||
break;
|
||||
}
|
||||
items.push(
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
return items;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
||||
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
message.toolResult.isError ? (
|
||||
// Error results - red error box with content
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user