diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index c11798d..9e20d0a 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -14,6 +14,7 @@ import { getToolContextTokens, getToolStatus, getToolSummary, + hasBashContent, hasEditContent, hasReadContent, hasSkillInstructions, @@ -31,6 +32,7 @@ import { Wrench } from 'lucide-react'; import { BaseItem, StatusDot } from './BaseItem'; import { formatDuration } from './baseItemHelpers'; import { + BashToolViewer, DefaultToolViewer, EditToolViewer, ReadToolViewer, @@ -139,7 +141,9 @@ export const LinkedToolItem: React.FC = ({ const useWriteViewer = linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); - const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; + const useBashViewer = linkedTool.name === 'Bash' && hasBashContent(linkedTool); + const useDefaultViewer = + !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer && !useBashViewer; // Check if we should show error display for Read/Write tools const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError; @@ -177,6 +181,9 @@ export const LinkedToolItem: React.FC = ({ {/* Skill tool with instructions */} {useSkillViewer && } + {/* Bash tool with syntax-highlighted command */} + {useBashViewer && } + {/* Default rendering for other tools */} {useDefaultViewer && } diff --git a/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx new file mode 100644 index 0000000..437cbfe --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx @@ -0,0 +1,69 @@ +/** + * BashToolViewer + * + * Renders Bash tool calls with a clean command display with copy button, + * and collapsible output section. + */ + +import React from 'react'; + +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { COLOR_TEXT, COLOR_TEXT_MUTED } from '@renderer/constants/cssVariables'; + +import { type ItemStatus } from '../BaseItem'; + +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; +import { extractOutputText, renderOutput } from './renderHelpers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface BashToolViewerProps { + linkedTool: LinkedToolItem; + status: ItemStatus; +} + +export const BashToolViewer: React.FC = ({ linkedTool, status }) => { + const command = (linkedTool.input.command as string) || ''; + const description = linkedTool.input.description as string | undefined; + + // Extract output text for copy button + const outputText = + !linkedTool.isOrphaned && linkedTool.result + ? extractOutputText(linkedTool.result.content) + : ''; + + return ( + <> + {/* Input Section — Command with copy button */} +
+
+ Input +
+
+ {description && ( +
+ {description} +
+ )} + + {command} + + +
+
+ + {/* Output Section — Collapsible with copy button */} + {!linkedTool.isOrphaned && linkedTool.result && ( + + {renderOutput(linkedTool.result.content)} + + )} + + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx new file mode 100644 index 0000000..7a4c963 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -0,0 +1,68 @@ +/** + * CollapsibleOutputSection + * + * Reusable component that wraps tool output in a collapsed-by-default section. + * Shows a clickable header with label, StatusDot, and chevron toggle. + */ + +import React, { useState } from 'react'; + +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +interface CollapsibleOutputSectionProps { + status: ItemStatus; + children: React.ReactNode; + /** Label shown in the header (default: "Output") */ + label?: string; + /** Text content for the copy button (when provided, shows a copy button) */ + copyText?: string; +} + +export const CollapsibleOutputSection: React.FC = ({ + status, + children, + label = 'Output', + copyText, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {children} + {copyText && } +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 1be3f90..c0a06fc 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,8 +6,9 @@ import React from 'react'; -import { type ItemStatus, StatusDot } from '../BaseItem'; +import { type ItemStatus } from '../BaseItem'; +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC = ({ linkedTool - {/* Output Section */} + {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( -
-
- Output - -
-
- {renderOutput(linkedTool.result.content)} -
-
+ + {renderOutput(linkedTool.result.content)} + )} ); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index f0eeb68..35de33b 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { CodeBlockViewer } from '@renderer/components/chat/viewers'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -54,12 +54,55 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => ? startLine + limit - 1 : undefined; + const isMarkdownFile = /\.mdx?$/i.test(filePath); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + + React.useEffect(() => { + setViewMode(isMarkdownFile ? 'preview' : 'code'); + }, [isMarkdownFile]); + return ( - +
+ {isMarkdownFile && ( +
+ + +
+ )} + {isMarkdownFile && viewMode === 'preview' ? ( + + ) : ( + + )} +
); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index d08d700..1b7e1ee 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -23,6 +23,10 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) const isMarkdownFile = /\.mdx?$/i.test(filePath); const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + React.useEffect(() => { + setViewMode(isMarkdownFile ? 'preview' : 'code'); + }, [isMarkdownFile]); + return (
diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts index 5c415da..92ff5fe 100644 --- a/src/renderer/components/chat/items/linkedTool/index.ts +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -4,6 +4,8 @@ * Exports all specialized tool viewer components. */ +export { BashToolViewer } from './BashToolViewer'; +export { CollapsibleOutputSection } from './CollapsibleOutputSection'; export { DefaultToolViewer } from './DefaultToolViewer'; export { EditToolViewer } from './EditToolViewer'; export { ReadToolViewer } from './ReadToolViewer'; diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index e6e2f6b..101307f 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -402,12 +402,443 @@ const KEYWORDS: Record> = { 'DATE', 'TIMESTAMP', ]), + bash: new Set([ + 'if', + 'then', + 'else', + 'elif', + 'fi', + 'for', + 'while', + 'do', + 'done', + 'case', + 'esac', + 'in', + 'function', + 'return', + 'local', + 'export', + 'readonly', + 'declare', + 'typeset', + 'unset', + 'shift', + 'source', + 'eval', + 'exec', + 'exit', + 'trap', + 'break', + 'continue', + 'echo', + 'printf', + 'read', + 'test', + 'true', + 'false', + 'cd', + 'pwd', + 'mkdir', + 'rm', + 'cp', + 'mv', + 'ls', + 'cat', + 'grep', + 'sed', + 'awk', + 'find', + 'sort', + 'uniq', + 'wc', + 'head', + 'tail', + 'chmod', + 'chown', + 'sudo', + 'apt', + 'pip', + 'npm', + 'pnpm', + 'yarn', + 'git', + 'docker', + 'curl', + 'wget', + ]), + c: new Set([ + 'auto', + 'break', + 'case', + 'char', + 'const', + 'continue', + 'default', + 'do', + 'double', + 'else', + 'enum', + 'extern', + 'float', + 'for', + 'goto', + 'if', + 'inline', + 'int', + 'long', + 'register', + 'return', + 'short', + 'signed', + 'sizeof', + 'static', + 'struct', + 'switch', + 'typedef', + 'union', + 'unsigned', + 'void', + 'volatile', + 'while', + 'NULL', + 'true', + 'false', + 'include', + 'define', + 'ifdef', + 'ifndef', + 'endif', + 'pragma', + ]), + java: new Set([ + 'abstract', + 'assert', + 'boolean', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'class', + 'const', + 'continue', + 'default', + 'do', + 'double', + 'else', + 'enum', + 'extends', + 'final', + 'finally', + 'float', + 'for', + 'if', + 'implements', + 'import', + 'instanceof', + 'int', + 'interface', + 'long', + 'native', + 'new', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'short', + 'static', + 'strictfp', + 'super', + 'switch', + 'synchronized', + 'this', + 'throw', + 'throws', + 'transient', + 'try', + 'void', + 'volatile', + 'while', + 'true', + 'false', + 'null', + 'var', + 'yield', + 'record', + 'sealed', + 'permits', + ]), + kotlin: new Set([ + 'abstract', + 'annotation', + 'as', + 'break', + 'by', + 'catch', + 'class', + 'companion', + 'const', + 'constructor', + 'continue', + 'crossinline', + 'data', + 'do', + 'else', + 'enum', + 'external', + 'false', + 'final', + 'finally', + 'for', + 'fun', + 'if', + 'import', + 'in', + 'infix', + 'init', + 'inline', + 'inner', + 'interface', + 'internal', + 'is', + 'lateinit', + 'noinline', + 'null', + 'object', + 'open', + 'operator', + 'out', + 'override', + 'package', + 'private', + 'protected', + 'public', + 'reified', + 'return', + 'sealed', + 'super', + 'suspend', + 'this', + 'throw', + 'true', + 'try', + 'typealias', + 'val', + 'var', + 'vararg', + 'when', + 'where', + 'while', + ]), + swift: new Set([ + 'associatedtype', + 'break', + 'case', + 'catch', + 'class', + 'continue', + 'default', + 'defer', + 'deinit', + 'do', + 'else', + 'enum', + 'extension', + 'fallthrough', + 'false', + 'fileprivate', + 'for', + 'func', + 'guard', + 'if', + 'import', + 'in', + 'init', + 'inout', + 'internal', + 'is', + 'let', + 'nil', + 'open', + 'operator', + 'override', + 'private', + 'protocol', + 'public', + 'repeat', + 'rethrows', + 'return', + 'self', + 'static', + 'struct', + 'subscript', + 'super', + 'switch', + 'throw', + 'throws', + 'true', + 'try', + 'typealias', + 'var', + 'where', + 'while', + 'async', + 'await', + ]), + lua: new Set([ + 'and', + 'break', + 'do', + 'else', + 'elseif', + 'end', + 'false', + 'for', + 'function', + 'goto', + 'if', + 'in', + 'local', + 'nil', + 'not', + 'or', + 'repeat', + 'return', + 'then', + 'true', + 'until', + 'while', + 'self', + 'require', + 'print', + 'type', + 'tostring', + 'tonumber', + 'pairs', + 'ipairs', + 'error', + 'pcall', + 'xpcall', + 'setmetatable', + 'getmetatable', + ]), + html: new Set([ + 'div', + 'span', + 'html', + 'head', + 'body', + 'title', + 'meta', + 'link', + 'script', + 'style', + 'section', + 'article', + 'header', + 'footer', + 'nav', + 'main', + 'aside', + 'form', + 'input', + 'button', + 'select', + 'option', + 'textarea', + 'label', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'ul', + 'ol', + 'li', + 'a', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'img', + 'video', + 'audio', + 'canvas', + 'svg', + 'class', + 'id', + 'src', + 'href', + 'type', + 'name', + 'value', + 'placeholder', + 'alt', + 'width', + 'height', + 'true', + 'false', + ]), + yaml: new Set([ + 'true', + 'false', + 'null', + 'yes', + 'no', + 'on', + 'off', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords KEYWORDS.tsx = KEYWORDS.typescript; KEYWORDS.jsx = KEYWORDS.javascript; +// Extend zsh/fish to use bash keywords +KEYWORDS.zsh = KEYWORDS.bash; +KEYWORDS.fish = KEYWORDS.bash; + +// Extend cpp/hpp to use c keywords (superset) +KEYWORDS.cpp = new Set([...KEYWORDS.c, ...[ + 'class', + 'namespace', + 'template', + 'typename', + 'public', + 'private', + 'protected', + 'virtual', + 'override', + 'final', + 'new', + 'delete', + 'try', + 'catch', + 'throw', + 'noexcept', + 'constexpr', + 'decltype', + 'nullptr', + 'this', + 'using', + 'friend', + 'operator', + 'dynamic_cast', + 'static_cast', + 'reinterpret_cast', + 'const_cast', + 'bool', + 'wchar_t', + 'auto', +]]); +KEYWORDS.hpp = KEYWORDS.cpp; + /** * Very basic tokenization for syntax highlighting. * This is a simple approach without a full parser. @@ -416,7 +847,7 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const keywords = KEYWORDS[language] || new Set(); // If no highlighting support, return plain text as single-element array - if (keywords.size === 0 && !['json', 'css', 'html', 'bash', 'markdown'].includes(language)) { + if (keywords.size === 0 && !['json', 'css', 'bash'].includes(language)) { return [line]; } @@ -490,9 +921,10 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell/R/Ruby/PHP) + // Check for comment (# style for Python/Shell/R/Ruby/PHP/YAML) if ( - (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + (language === 'python' || language === 'bash' || language === 'zsh' || language === 'fish' || + language === 'r' || language === 'ruby' || language === 'php' || language === 'yaml') && remaining.startsWith('#') ) { segments.push( @@ -505,8 +937,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (-- style for SQL) - if (language === 'sql' && remaining.startsWith('--')) { + // Check for comment (-- style for SQL/Lua) + if ((language === 'sql' || language === 'lua') && remaining.startsWith('--')) { segments.push( React.createElement( 'span', diff --git a/src/renderer/utils/toolRendering/index.ts b/src/renderer/utils/toolRendering/index.ts index b293695..18eb6bc 100644 --- a/src/renderer/utils/toolRendering/index.ts +++ b/src/renderer/utils/toolRendering/index.ts @@ -5,6 +5,7 @@ */ export { + hasBashContent, hasEditContent, hasReadContent, hasSkillInstructions, diff --git a/src/renderer/utils/toolRendering/toolContentChecks.ts b/src/renderer/utils/toolRendering/toolContentChecks.ts index 090cf54..4f239bd 100644 --- a/src/renderer/utils/toolRendering/toolContentChecks.ts +++ b/src/renderer/utils/toolRendering/toolContentChecks.ts @@ -56,3 +56,11 @@ export function hasWriteContent(linkedTool: LinkedToolItem): boolean { return false; } + +/** + * Checks if a Bash tool has a command to display. + */ +export function hasBashContent(linkedTool: LinkedToolItem): boolean { + const command = linkedTool.input?.command; + return typeof command === 'string' && command.trim().length > 0; +}