From c88baaf8dc2d2683356617308941570fdfa0ae6c Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:19:57 +0300 Subject: [PATCH] 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. --- src/components/chat/tools/ToolRenderer.tsx | 27 ++- .../tools/components/BashCommandDisplay.tsx | 155 ++++++++++++++++++ .../chat/tools/components/CommandRunGroup.tsx | 124 ++++++++++++++ src/components/chat/tools/components/index.ts | 2 + .../view/subcomponents/ChatMessagesPane.tsx | 87 ++++++---- .../view/subcomponents/MessageComponent.tsx | 4 +- 6 files changed, 367 insertions(+), 32 deletions(-) create mode 100644 src/components/chat/tools/components/BashCommandDisplay.tsx create mode 100644 src/components/chat/tools/components/CommandRunGroup.tsx diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index a48f6cb8..a02e5aa1 100644 --- a/src/components/chat/tools/ToolRenderer.tsx +++ b/src/components/chat/tools/ToolRenderer.tsx @@ -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 = 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 ( + + ); + } + if (displayConfig.type === 'one-line') { const value = displayConfig.getValue?.(parsedData) || ''; const secondary = displayConfig.getSecondary?.(parsedData); diff --git a/src/components/chat/tools/components/BashCommandDisplay.tsx b/src/components/chat/tools/components/BashCommandDisplay.tsx new file mode 100644 index 00000000..8b9fc744 --- /dev/null +++ b/src/components/chat/tools/components/BashCommandDisplay.tsx @@ -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 = ({ + 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 ( +
+ {/* Command header — clickable when there is output to expand */} +
{ + 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', + )} + > + + + $ + + + {command} + + + {isRunning && ( + + )} + {status && status !== 'running' && } + {!open && hasOutput && !isRunning && ( + + {outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'} + + )} + + +
+ + {description && !open && ( +
+ {description} +
+ )} + + {/* Expanded output */} + {open && hasOutput && ( +
+ {description && ( +
{description}
+ )} +
+            {trimmedOutput}
+          
+
+ )} +
+ ); +}; diff --git a/src/components/chat/tools/components/CommandRunGroup.tsx b/src/components/chat/tools/components/CommandRunGroup.tsx new file mode 100644 index 00000000..83de9032 --- /dev/null +++ b/src/components/chat/tools/components/CommandRunGroup.tsx @@ -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 = ({ 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 ( +
+ + + {open && ( +
+ {commands.map((cmd) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/components/chat/tools/components/index.ts b/src/components/chat/tools/components/index.ts index 58f79ca4..f0b6249e 100644 --- a/src/components/chat/tools/components/index.ts +++ b/src/components/chat/tools/components/index.ts @@ -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'; diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 64e8b641..5432c9f3 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -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; @@ -252,35 +253,63 @@ export default function ChatMessagesPane({ )} - {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( + , + ); + 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( + , + ); } - return ( - - ); - })} + + return items; + })()} )} diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 17b27918..e9615a85 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -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