|
| 1 | +import { memo, useMemo, useState } from 'react' |
| 2 | +import ReactMarkdown from 'react-markdown' |
| 3 | +import { Loader2 } from 'lucide-react' |
| 4 | +import { toolContentText, type SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' |
| 5 | +import { useSettings } from '@/hooks/useSettings' |
| 6 | +import { toolMetadata } from '@/lib/backgroundWork' |
| 7 | +import { clampDisplayText } from '@/lib/clampDisplayText' |
| 8 | +import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' |
| 9 | +import { CopyButton } from '@/components/ui/copy-button' |
| 10 | +import { FileReferenceText } from './FileReferenceText' |
| 11 | +import { ToolContentFiles } from './ToolContentFiles' |
| 12 | + |
| 13 | +type ExecuteCallStatus = 'running' | 'completed' | 'error' |
| 14 | + |
| 15 | +interface ExecuteCall { |
| 16 | + tool: string |
| 17 | + status: ExecuteCallStatus |
| 18 | + input?: Record<string, unknown> |
| 19 | +} |
| 20 | + |
| 21 | +interface ExecuteToolPartProps { |
| 22 | + part: SessionMessageAssistantTool |
| 23 | + isWaitingPermission: boolean |
| 24 | + onFileClick?: (filePath: string) => void |
| 25 | +} |
| 26 | + |
| 27 | +interface ParsedOutput { |
| 28 | + json: string |
| 29 | + rest?: string |
| 30 | +} |
| 31 | + |
| 32 | +const OUTPUT_TAIL_PATTERN = /\n\n(Warnings|Logs):\n/ |
| 33 | + |
| 34 | +function isRecord(value: unknown): value is Record<string, unknown> { |
| 35 | + return typeof value === 'object' && value !== null && !Array.isArray(value) |
| 36 | +} |
| 37 | + |
| 38 | +function executeCalls(value: unknown): ExecuteCall[] { |
| 39 | + if (!Array.isArray(value)) return [] |
| 40 | + return value.flatMap((call) => { |
| 41 | + if (!isRecord(call)) return [] |
| 42 | + const tool = call.tool |
| 43 | + const status = call.status |
| 44 | + if (typeof tool !== 'string') return [] |
| 45 | + if (status !== 'running' && status !== 'completed' && status !== 'error') return [] |
| 46 | + return [{ tool, status, input: isRecord(call.input) ? call.input : undefined }] |
| 47 | + }) |
| 48 | +} |
| 49 | + |
| 50 | +function primitiveInputSummary(input: Record<string, unknown>): string { |
| 51 | + const entries = Object.entries(input).filter(([, value]) => ( |
| 52 | + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' |
| 53 | + )) |
| 54 | + if (entries.length === 0) return '' |
| 55 | + return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(', ')}]` |
| 56 | +} |
| 57 | + |
| 58 | +function executeCallSummary(call: ExecuteCall): string { |
| 59 | + const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, ' ') |
| 60 | + return `${call.tool}${args ? ` ${args}` : ''}` |
| 61 | +} |
| 62 | + |
| 63 | +function executeCode(part: SessionMessageAssistantTool): string | undefined { |
| 64 | + if (part.state.status === 'streaming') return undefined |
| 65 | + const code = part.state.input.code |
| 66 | + return typeof code === 'string' && code ? code : undefined |
| 67 | +} |
| 68 | + |
| 69 | +function executeOutputText(part: SessionMessageAssistantTool): string { |
| 70 | + if (part.state.status === 'error') return part.state.error.message.trim() |
| 71 | + if (part.state.status !== 'completed') return '' |
| 72 | + return toolContentText(part.state.content).trim() |
| 73 | +} |
| 74 | + |
| 75 | +function parseOutput(text: string): ParsedOutput | undefined { |
| 76 | + if (!text.startsWith('{') && !text.startsWith('[')) return undefined |
| 77 | + const tail = text.search(OUTPUT_TAIL_PATTERN) |
| 78 | + const json = tail === -1 ? text : text.slice(0, tail) |
| 79 | + try { |
| 80 | + JSON.parse(json) |
| 81 | + } catch { |
| 82 | + return undefined |
| 83 | + } |
| 84 | + return { json, rest: tail === -1 ? undefined : text.slice(json.length).trim() || undefined } |
| 85 | +} |
| 86 | + |
| 87 | +function trimTrailingNewlines(text: string): string { |
| 88 | + return text.replace(/\n+$/, '') |
| 89 | +} |
| 90 | + |
| 91 | +function lineCount(text: string | undefined): number { |
| 92 | + return text ? trimTrailingNewlines(text).split('\n').length : 0 |
| 93 | +} |
| 94 | + |
| 95 | +function formatDuration(ms: number): string { |
| 96 | + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms` |
| 97 | + return `${(ms / 1000).toFixed(2)}s` |
| 98 | +} |
| 99 | + |
| 100 | +function longestBacktickRun(text: string): number { |
| 101 | + const runs = text.match(/`+/g) ?? [] |
| 102 | + return runs.reduce((longest, run) => Math.max(longest, run.length), 0) |
| 103 | +} |
| 104 | + |
| 105 | +const HighlightedCode = memo(function HighlightedCode({ |
| 106 | + content, |
| 107 | + language, |
| 108 | + digits, |
| 109 | +}: { |
| 110 | + content: string |
| 111 | + language: 'ts' | 'json' |
| 112 | + digits: number |
| 113 | +}) { |
| 114 | + const body = trimTrailingNewlines(content) |
| 115 | + const markdown = useMemo(() => { |
| 116 | + const fence = '`'.repeat(Math.max(3, longestBacktickRun(body) + 1)) |
| 117 | + return `${fence}${language}\n${body}\n${fence}` |
| 118 | + }, [body, language]) |
| 119 | + const gutter = useMemo( |
| 120 | + () => body.split('\n').map((_, index) => String(index + 1)).join('\n'), |
| 121 | + [body], |
| 122 | + ) |
| 123 | + |
| 124 | + return ( |
| 125 | + <div className="flex items-start gap-3 overflow-x-auto rounded bg-accent p-2 font-mono text-xs leading-5"> |
| 126 | + <div |
| 127 | + className="shrink-0 select-none whitespace-pre text-right text-muted-foreground" |
| 128 | + style={{ width: `${digits}ch` }} |
| 129 | + data-testid="execute-line-numbers" |
| 130 | + > |
| 131 | + {gutter} |
| 132 | + </div> |
| 133 | + <div className="min-w-fit [&_.hljs]:overflow-visible [&_.hljs]:bg-transparent [&_.hljs]:p-0 [&_code]:bg-transparent [&_code]:p-0 [&_pre]:m-0 [&_pre]:whitespace-pre [&_pre]:bg-transparent"> |
| 134 | + <ReactMarkdown rehypePlugins={markdownRehypePlugins}>{markdown}</ReactMarkdown> |
| 135 | + </div> |
| 136 | + </div> |
| 137 | + ) |
| 138 | +}) |
| 139 | + |
| 140 | +function ExecuteCallRow({ call, onFileClick }: { call: ExecuteCall; onFileClick?: (filePath: string) => void }) { |
| 141 | + const [expanded, setExpanded] = useState(false) |
| 142 | + const entries = Object.entries(call.input ?? {}) |
| 143 | + const expandable = entries.length > 0 |
| 144 | + const isError = call.status === 'error' |
| 145 | + const color = isError ? 'text-destructive' : 'text-muted-foreground' |
| 146 | + |
| 147 | + const summary = ( |
| 148 | + <> |
| 149 | + <span className="shrink-0">{isError ? '✗' : '›'}</span> |
| 150 | + <span className="min-w-0 truncate">{executeCallSummary(call)}</span> |
| 151 | + </> |
| 152 | + ) |
| 153 | + |
| 154 | + return ( |
| 155 | + <div className="pl-3"> |
| 156 | + {expandable ? ( |
| 157 | + <button |
| 158 | + type="button" |
| 159 | + onClick={() => setExpanded(!expanded)} |
| 160 | + aria-expanded={expanded} |
| 161 | + className={`flex w-full min-w-0 items-center gap-1 text-left font-mono text-xs hover:text-foreground ${color}`} |
| 162 | + title={executeCallSummary(call)} |
| 163 | + > |
| 164 | + {summary} |
| 165 | + </button> |
| 166 | + ) : ( |
| 167 | + <div className={`flex min-w-0 items-center gap-1 font-mono text-xs ${color}`}>{summary}</div> |
| 168 | + )} |
| 169 | + {expanded && ( |
| 170 | + <div className={`mt-1 space-y-1 border-l border-border pl-3 text-xs ${isError ? 'text-destructive' : 'text-foreground'}`}> |
| 171 | + {entries.map(([key, value]) => ( |
| 172 | + <div key={key} className="flex gap-2"> |
| 173 | + <span className="shrink-0 text-muted-foreground">{key}:</span> |
| 174 | + <span className="whitespace-pre-wrap break-words"> |
| 175 | + <FileReferenceText |
| 176 | + text={typeof value === 'string' ? value : JSON.stringify(value, null, 2)} |
| 177 | + onFileClick={onFileClick} |
| 178 | + /> |
| 179 | + </span> |
| 180 | + </div> |
| 181 | + ))} |
| 182 | + </div> |
| 183 | + )} |
| 184 | + </div> |
| 185 | + ) |
| 186 | +} |
| 187 | + |
| 188 | +export function ExecuteToolPart({ part, isWaitingPermission, onFileClick }: ExecuteToolPartProps) { |
| 189 | + const { preferences } = useSettings() |
| 190 | + const [expanded, setExpanded] = useState(preferences?.expandToolCalls ?? false) |
| 191 | + const metadata = toolMetadata(part) |
| 192 | + const calls = executeCalls(metadata.toolCalls) |
| 193 | + const code = executeCode(part) |
| 194 | + const output = executeOutputText(part) |
| 195 | + const displayOutput = useMemo(() => clampDisplayText(output), [output]) |
| 196 | + const parsedOutput = useMemo( |
| 197 | + () => (displayOutput === output ? parseOutput(output) : undefined), |
| 198 | + [displayOutput, output], |
| 199 | + ) |
| 200 | + const failed = part.state.status === 'error' |
| 201 | + || metadata.error === true |
| 202 | + || (part.state.status === 'completed' && calls.some((call) => call.status === 'error')) |
| 203 | + const status = part.state.status |
| 204 | + |
| 205 | + const statusLabel = (() => { |
| 206 | + if (status === 'streaming') return 'Receiving code…' |
| 207 | + if (isWaitingPermission) return 'awaiting permission' |
| 208 | + if (status === 'running') return 'Running' |
| 209 | + const duration = part.time.completed !== undefined |
| 210 | + ? ` · ${formatDuration(part.time.completed - (part.time.ran ?? part.time.created))}` |
| 211 | + : '' |
| 212 | + return `${failed ? 'Failed' : 'Completed'}${duration}` |
| 213 | + })() |
| 214 | + |
| 215 | + const statusColor = (() => { |
| 216 | + if (status === 'error') return 'text-destructive' |
| 217 | + if (status === 'running') return isWaitingPermission ? 'text-highlight' : 'text-warning' |
| 218 | + if (failed) return 'text-destructive' |
| 219 | + if (status === 'completed') return 'text-success' |
| 220 | + return 'text-muted-foreground' |
| 221 | + })() |
| 222 | + |
| 223 | + const statusIcon = (() => { |
| 224 | + if (status === 'streaming') { |
| 225 | + return <span className="inline-block w-2 h-2 rounded-full bg-current animate-pulse" /> |
| 226 | + } |
| 227 | + if (status === 'running') return <Loader2 className="w-3.5 h-3.5 animate-spin" /> |
| 228 | + if (failed) return <span>✗</span> |
| 229 | + if (status === 'completed') return <span>✓</span> |
| 230 | + return <span>○</span> |
| 231 | + })() |
| 232 | + |
| 233 | + const borderStyle = status === 'running' |
| 234 | + ? isWaitingPermission |
| 235 | + ? 'border-highlight/50 shadow-sm shadow-highlight/20' |
| 236 | + : 'border-warning/50 shadow-sm shadow-warning/10' |
| 237 | + : status === 'streaming' |
| 238 | + ? 'border-primary/30' |
| 239 | + : failed |
| 240 | + ? 'border-destructive/30' |
| 241 | + : 'border-border' |
| 242 | + |
| 243 | + const digits = String(Math.max(lineCount(code), lineCount(parsedOutput?.json), 1)).length |
| 244 | + const errorPreview = failed && output ? output.split('\n').slice(0, 4) : [] |
| 245 | + |
| 246 | + return ( |
| 247 | + <div className={`border rounded-lg overflow-hidden my-2 transition-all ${borderStyle}`}> |
| 248 | + <div className="flex items-center bg-card"> |
| 249 | + <button |
| 250 | + type="button" |
| 251 | + onClick={() => setExpanded(!expanded)} |
| 252 | + aria-expanded={expanded} |
| 253 | + className="min-w-0 flex-1 px-4 py-2 hover:bg-card-hover text-left flex items-center gap-2 text-sm" |
| 254 | + > |
| 255 | + <span className={statusColor}>{statusIcon}</span> |
| 256 | + <span className="font-medium">execute</span> |
| 257 | + <span className={`text-xs ml-auto ${failed ? 'text-destructive' : 'text-muted-foreground'}`}> |
| 258 | + {statusLabel} |
| 259 | + </span> |
| 260 | + </button> |
| 261 | + </div> |
| 262 | + |
| 263 | + {calls.length > 0 && ( |
| 264 | + <div className="px-4 pb-2 space-y-0.5"> |
| 265 | + {calls.map((call, index) => ( |
| 266 | + <ExecuteCallRow key={`${call.tool}-${index}`} call={call} onFileClick={onFileClick} /> |
| 267 | + ))} |
| 268 | + </div> |
| 269 | + )} |
| 270 | + |
| 271 | + <ToolContentFiles part={part} className="px-4 pb-2" /> |
| 272 | + |
| 273 | + {!expanded && errorPreview.length > 0 && ( |
| 274 | + <div className="px-4 pb-2 font-mono text-xs text-destructive"> |
| 275 | + {errorPreview.map((line, index) => ( |
| 276 | + <div key={index} className="whitespace-pre-wrap break-words"> |
| 277 | + {index === 0 ? `↳ ${line}` : ` ${line}`} |
| 278 | + </div> |
| 279 | + ))} |
| 280 | + </div> |
| 281 | + )} |
| 282 | + |
| 283 | + {expanded && ( |
| 284 | + <div className="bg-card space-y-3 p-3"> |
| 285 | + <div className="text-sm"> |
| 286 | + <div className="text-muted-foreground mb-1">Code</div> |
| 287 | + {code ? ( |
| 288 | + <HighlightedCode content={code} language="ts" digits={digits} /> |
| 289 | + ) : ( |
| 290 | + <div className="text-xs text-muted-foreground">Waiting for code…</div> |
| 291 | + )} |
| 292 | + </div> |
| 293 | + |
| 294 | + <div className="text-sm"> |
| 295 | + <div className="text-muted-foreground mb-1">Output</div> |
| 296 | + {parsedOutput ? ( |
| 297 | + <> |
| 298 | + <HighlightedCode content={parsedOutput.json} language="json" digits={digits} /> |
| 299 | + {parsedOutput.rest && ( |
| 300 | + <pre className="mt-1 whitespace-pre-wrap break-words text-xs text-foreground"> |
| 301 | + {parsedOutput.rest} |
| 302 | + </pre> |
| 303 | + )} |
| 304 | + </> |
| 305 | + ) : displayOutput ? ( |
| 306 | + <pre |
| 307 | + className={`whitespace-pre-wrap break-words text-xs ${failed ? 'text-destructive' : 'text-foreground'}`} |
| 308 | + > |
| 309 | + {displayOutput} |
| 310 | + </pre> |
| 311 | + ) : ( |
| 312 | + <div className="text-xs text-muted-foreground"> |
| 313 | + {status === 'completed' ? 'No output' : 'Waiting for output…'} |
| 314 | + </div> |
| 315 | + )} |
| 316 | + </div> |
| 317 | + |
| 318 | + <div className="flex items-center gap-4"> |
| 319 | + {code && ( |
| 320 | + <div className="flex items-center gap-1"> |
| 321 | + <CopyButton content={code} title="Copy code" /> |
| 322 | + <span className="text-xs text-muted-foreground">Copy code</span> |
| 323 | + </div> |
| 324 | + )} |
| 325 | + {output && ( |
| 326 | + <div className="flex items-center gap-1"> |
| 327 | + <CopyButton content={output} title="Copy output" /> |
| 328 | + <span className="text-xs text-muted-foreground">Copy output</span> |
| 329 | + </div> |
| 330 | + )} |
| 331 | + </div> |
| 332 | + </div> |
| 333 | + )} |
| 334 | + </div> |
| 335 | + ) |
| 336 | +} |
0 commit comments