diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 82e7d9e1..a9dd1923 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -207,6 +207,15 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes break; } + // A result with a toolId but no matching tool_use in the loaded set is + // almost always a tool_use/tool_result pair split across a pagination + // boundary (older page not loaded yet). Rendering its raw content here + // produces an unstyled dump that "fixes itself" once the older page + // loads; skip it and let it attach to its tool_use when that arrives. + if (msg.toolId) { + break; + } + const content = formatToolResultContent(msg.content || ''); if (!content.trim()) { break; diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx index a48f6cb8..0d9e1f6a 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,39 @@ 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 = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData + ? String(parsedData.command || '') + : typeof toolInput === 'string' + ? toolInput + : typeof rawToolInput === 'string' + ? rawToolInput + : ''; + const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData + ? String(parsedData.description || '') + : undefined; + 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..bddce924 --- /dev/null +++ b/src/components/chat/tools/components/BashCommandDisplay.tsx @@ -0,0 +1,156 @@ +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/index.ts b/src/components/chat/tools/components/index.ts index 58f79ca4..225526cc 100644 --- a/src/components/chat/tools/components/index.ts +++ b/src/components/chat/tools/components/index.ts @@ -1,6 +1,7 @@ export { CollapsibleSection } from './CollapsibleSection'; export { ToolDiffViewer } from './ToolDiffViewer'; export { OneLineDisplay } from './OneLineDisplay'; +export { BashCommandDisplay } from './BashCommandDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay'; export { SubagentContainer } from './SubagentContainer'; export * from './ContentRenderers'; diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx index 2e391ebe..d80a97d6 100644 --- a/src/components/chat/view/subcomponents/CommandResultModal.tsx +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -2,9 +2,7 @@ import { useMemo, useState } from 'react'; import { Activity, BadgeCheck, - Check, CircleHelp, - Clipboard, Coins, Cpu, Gauge, @@ -59,19 +57,6 @@ type ModelOption = { description?: string; }; -const formatUpdatedAt = (value?: string) => { - if (!value) { - return 'Not cached yet'; - } - - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return 'Not cached yet'; - } - - return parsed.toLocaleString(); -}; - const PROVIDER_LABELS: Record = { claude: 'Claude', cursor: 'Cursor', @@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) { function ModelsContent({ data, providerModelCatalog, - providerModelCacheCatalog, providerModelsRefreshing, onHardRefreshProviderModels, currentSessionId, @@ -254,14 +238,12 @@ function ModelsContent({ }: { data: ModelCommandData; providerModelCatalog: Partial>; - providerModelCacheCatalog: Partial>; providerModelsRefreshing: boolean; onHardRefreshProviderModels: () => void; currentSessionId: string | null; onSelectProviderModel: CommandResultModalProps['onSelectProviderModel']; }) { const [query, setQuery] = useState(''); - const [copiedModel, setCopiedModel] = useState(null); const [changingModel, setChangingModel] = useState(null); const [pendingSessionModel, setPendingSessionModel] = useState(null); const [selectionNotice, setSelectionNotice] = useState(null); @@ -269,7 +251,6 @@ function ModelsContent({ const currentModel = data?.current?.model || 'Unknown'; const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider); const liveDefinition = providerModelCatalog[currentProvider]; - const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache; const availableOptions = useMemo(() => { if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) { return liveDefinition.OPTIONS; @@ -282,7 +263,6 @@ function ModelsContent({ const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : []; return availableModels.map((model) => ({ value: model, label: model })); }, [data, liveDefinition]); - const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel; const filteredOptions = useMemo(() => { const normalized = query.trim().toLowerCase(); @@ -296,18 +276,8 @@ function ModelsContent({ }); }, [availableOptions, query]); - const activeOption = availableOptions.find((option) => option.value === currentModel); const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0; - - const copyModel = (model: string) => { - if (typeof navigator !== 'undefined' && navigator.clipboard) { - void navigator.clipboard.writeText(model).catch(() => undefined); - } - setCopiedModel(model); - window.setTimeout(() => { - setCopiedModel((current) => (current === model ? null : current)); - }, 1300); - }; + const showSearch = availableOptions.length > 6; const handleSelectModel = async (model: string) => { setChangingModel(model); @@ -330,162 +300,106 @@ function ModelsContent({ }; return ( -
-
-
-
-
- - {providerLabel} - - - {availableOptions.length} models - -
- -
-

Active Model

-

- {currentModel} -

- {activeOption?.label && activeOption.label !== currentModel && ( -

{activeOption.label}

- )} - {activeOption?.description && ( -

{activeOption.description}

- )} - {pendingSessionModel && pendingSessionModel !== currentModel && ( -

- Next response: {pendingSessionModel} -

- )} -
-
- -
-
-

Default

-

{defaultModel}

-
-
-

Updated

-

{formatUpdatedAt(currentCache?.updatedAt)}

-
-
- -
-
-

- Catalog Refresh -

- - All providers - -
-

- Model lists are cached for 3 days. Refresh after CLI, auth, or config changes, - or when a new model is missing. -

- -
-
- -
- {hasConcreteSessionId - ? 'Selecting a model stores a session override and applies it on the next response for this session.' - : 'Selecting a model updates the default model used for new turns in this provider.'} - {selectionNotice && {selectionNotice}} +
+ {/* Compact context bar: active model + refresh, no clutter */} +
+
+

+ Active model ยท {providerLabel} +

+

+ {currentModel} + {pendingSessionModel && pendingSessionModel !== currentModel && ( + + โ†’ {pendingSessionModel} next + + )} +

+
-
-
-
- -
- - {filteredOptions.length} shown - -
+ {showSearch && ( + + )} - {filteredOptions.length > 0 ? ( -
-
- {filteredOptions.map((option, index) => { - const isCurrent = option.value === currentModel; - const wasCopied = copiedModel === option.value; - const isPendingSelection = option.value === pendingSessionModel; - const isChanging = option.value === changingModel; - return ( -
- - -
- ); - })} -
+ {filteredOptions.length > 0 ? ( +
+
+ {filteredOptions.map((option, index) => { + const isCurrent = option.value === currentModel; + const isPendingSelection = option.value === pendingSessionModel; + const isChanging = option.value === changingModel; + return ( + + ); + })}
+
+ ) : ( +
+ No models match that search. +
+ )} + + {/* Single quiet line of guidance / feedback */} +

+ {selectionNotice ? ( + {selectionNotice} + ) : hasConcreteSessionId ? ( + 'Your choice applies to this session on the next response.' ) : ( -

- No models match that search. -
+ 'Your choice becomes the default model for new turns.' )} -
+

); } @@ -606,7 +520,6 @@ export default function CommandResultModal({ payload, onClose, providerModelCatalog, - providerModelCacheCatalog, providerModelsRefreshing, onHardRefreshProviderModels, currentSessionId, @@ -624,9 +537,9 @@ export default function CommandResultModal({ icon: CircleHelp, }, models: { - eyebrow: 'Model inventory', - title: 'Available Models', - subtitle: 'Browse, search, and copy model IDs for the active provider.', + eyebrow: 'Model selection', + title: 'Choose a Model', + subtitle: 'Pick the model this provider should use.', icon: Cpu, }, cost: { @@ -700,7 +613,6 @@ export default function CommandResultModal({ + !!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#')); + +// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`). +const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, ''); + +// A usable file path contains a separator or a filename with an extension. +const looksLikeFilePath = (value?: string): value is string => { + if (!value) { + return false; + } + const cleaned = stripLineSuffix(value.trim()); + if (!cleaned || cleaned === '#') { + return false; + } + return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned); +}; + +// Extract plain text from link children so a reference rendered only as link +// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened. +const childrenToText = (children: React.ReactNode): string => { + if (typeof children === 'string' || typeof children === 'number') { + return String(children); + } + if (Array.isArray(children)) { + return children.map(childrenToText).join(''); + } + if (React.isValidElement(children)) { + return childrenToText((children.props as { children?: React.ReactNode }).children); + } + return ''; +}; + type CodeBlockProps = { node?: any; inline?: boolean; @@ -123,11 +159,6 @@ const markdownComponents = { {children} ), - a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( - - {children} - - ), p: ({ children }: { children?: React.ReactNode }) =>
{children}
, table: ({ children }: { children?: React.ReactNode }) => (
@@ -147,10 +178,50 @@ export function Markdown({ children, className }: MarkdownProps) { const content = normalizeInlineCodeFences(String(children ?? '')); const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const rehypePlugins = useMemo(() => [rehypeKatex], []); + const { openFileInEditor } = usePaletteOps(); + + const components = useMemo( + () => ({ + ...markdownComponents, + a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => { + // Prefer the href when it is a real path; otherwise fall back to the + // link text, since models often emit `[src/foo.ts]()` with an empty href. + const linkText = childrenToText(linkChildren); + const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined; + + if (fileRef && !isExternalHref(href)) { + return ( + { + event.preventDefault(); + openFileInEditor(stripLineSuffix(fileRef)); + }} + > + {linkChildren} + + ); + } + + return ( + + {linkChildren} + + ); + }, + }), + [openFileInEditor], + ); return (
- + {content}
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
{ // Identify projects by DB `projectId`; the TaskMaster context uses the // same identifier to key its internal maps. @@ -179,6 +184,10 @@ function MainContent({ setActiveTab('files'); handleFileOpen(filePath); }, + // Opens the editor side panel in place, keeping the current tab (e.g. chat). + openFileInEditor: (filePath: string) => { + resolvedFileOpen(filePath); + }, }); if (isLoading) { diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 3318899d..a936c124 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh parts.push(snippet.slice(cursor)); } return ( - + {parts} ); diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index 1fdb2c6a..1ab40475 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react'; import type { TFunction } from 'i18next'; -import { Badge, Button, Tooltip } from '../../../../shared/view/ui'; +import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui'; import { cn } from '../../../../lib/utils'; import type { Project, ProjectSession, LLMProvider } from '../../../../types/app'; import type { SessionWithProvider } from '../../types/types'; @@ -195,9 +195,10 @@ export default function SidebarSessionItem({
- +
void; + // Opens a file in the editor side panel without changing the active tab + // (used by in-chat file links so they behave like the inline edit view). + openFileInEditor: (path: string) => void; openSettings: (tab?: string) => void; refreshProjects: () => Promise | void; }; @@ -13,6 +16,7 @@ const PaletteOpsContext = createContext(null); const defaultOps: PaletteOps = { openFile: () => undefined, + openFileInEditor: () => undefined, openSettings: () => undefined, refreshProjects: () => undefined, }; @@ -27,6 +31,8 @@ export function usePaletteOps(): PaletteOps { return useMemo( () => ({ openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path), + openFileInEditor: (path) => + (ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path), openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab), refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(), }), @@ -36,18 +42,20 @@ export function usePaletteOps(): PaletteOps { export function usePaletteOpsRegister(partial: Partial) { const ref = useContext(PaletteOpsContext); - const { openFile, openSettings, refreshProjects } = partial; + const { openFile, openFileInEditor, openSettings, refreshProjects } = partial; useEffect(() => { if (!ref) return undefined; const prev = { ...ref.current }; if (openFile) ref.current.openFile = openFile; + if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor; if (openSettings) ref.current.openSettings = openSettings; if (refreshProjects) ref.current.refreshProjects = refreshProjects; return () => { if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile; + if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor; if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings; if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects; }; - }, [ref, openFile, openSettings, refreshProjects]); + }, [ref, openFile, openFileInEditor, openSettings, refreshProjects]); } diff --git a/src/hooks/useFileOpenResolver.ts b/src/hooks/useFileOpenResolver.ts new file mode 100644 index 00000000..ed5fd1fa --- /dev/null +++ b/src/hooks/useFileOpenResolver.ts @@ -0,0 +1,108 @@ +import { useCallback, useRef } from 'react'; + +import { api } from '../utils/api'; +import type { Project } from '../types/app'; + +type FileNode = { + type: 'file' | 'directory'; + name: string; + path: string; + children?: FileNode[]; +}; + +type FlatFile = { + name: string; + path: string; +}; + +// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers +// that expect a concrete diff payload type as well as generic callers. +type OnFileOpen = (filePath: string, diffInfo?: any) => void; + +const normalize = (value: string): string => value.replace(/\\/g, '/'); + +const flatten = (nodes: FileNode[], out: FlatFile[]): void => { + for (const node of nodes) { + if (node.type === 'file') { + out.push({ name: node.name, path: node.path }); + } else if (node.children && node.children.length > 0) { + flatten(node.children, out); + } + } +}; + +// References inside chat messages are often bare basenames (`foo.ts`) or partial +// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and +// fall back to filename equality. +const findBestMatch = (files: FlatFile[], ref: string): string | null => { + const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, ''); + if (!target) { + return null; + } + + const suffixMatch = files.find((file) => { + const filePath = normalize(file.path); + return filePath === target || filePath.endsWith(`/${target}`); + }); + if (suffixMatch) { + return suffixMatch.path; + } + + const base = target.split('/').pop() || target; + return files.find((file) => file.name === base)?.path ?? null; +}; + +/** + * Wraps an `onFileOpen` handler so a possibly bare/partial file reference is + * resolved against the project's file tree (cached per project) before the file + * is opened in the in-app editor. + */ +export function useFileOpenResolver( + selectedProject: Project | null | undefined, + onFileOpen: OnFileOpen, +): OnFileOpen { + const projectId = selectedProject?.projectId; + const cacheRef = useRef<{ projectId?: string; files: Promise | null }>({ + projectId: undefined, + files: null, + }); + + const loadFiles = useCallback((): Promise => { + if (!projectId) { + return Promise.resolve([]); + } + if (cacheRef.current.projectId === projectId && cacheRef.current.files) { + return cacheRef.current.files; + } + + const filesPromise = (async () => { + try { + const response = await api.getFiles(projectId); + if (!response.ok) { + return []; + } + const data = await response.json(); + const tree: FileNode[] = Array.isArray(data) ? data : []; + const flat: FlatFile[] = []; + flatten(tree, flat); + return flat; + } catch { + return []; + } + })(); + + cacheRef.current = { projectId, files: filesPromise }; + return filesPromise; + }, [projectId]); + + return useCallback( + (filePath: string, diffInfo?: any) => { + const ref = normalize(filePath).trim(); + void loadFiles().then((files) => { + const match = findBestMatch(files, ref); + onFileOpen(match ?? filePath, diffInfo); + }); + }, + [loadFiles, onFileOpen], + ); +} diff --git a/src/shared/view/ui/LanguageSelector.tsx b/src/shared/view/ui/LanguageSelector.tsx index 2d792a3e..bbc4a69b 100644 --- a/src/shared/view/ui/LanguageSelector.tsx +++ b/src/shared/view/ui/LanguageSelector.tsx @@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr