Skip to content

Commit 7f5c242

Browse files
Merge pull request #440 from chriswritescode-dev/feat/execute-tool-card
feat(chat): render execute calls with a dedicated card and show tool images
2 parents 851a63f + 801a4cd commit 7f5c242

18 files changed

Lines changed: 982 additions & 74 deletions
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useMemo, type ReactNode } from 'react'
2+
import { detectFileReferences } from '@/lib/fileReferences'
3+
4+
interface FileReferenceTextProps {
5+
text: string
6+
onFileClick?: (filePath: string) => void
7+
}
8+
9+
export function FileReferenceText({ text, onFileClick }: FileReferenceTextProps) {
10+
const parts = useMemo(() => {
11+
const references = detectFileReferences(text)
12+
if (references.length === 0) return text
13+
14+
const segments: ReactNode[] = []
15+
let lastIndex = 0
16+
17+
references.forEach((ref, index) => {
18+
if (ref.startIndex > lastIndex) {
19+
segments.push(text.slice(lastIndex, ref.startIndex))
20+
}
21+
22+
segments.push(
23+
<button
24+
key={`ref-${index}`}
25+
type="button"
26+
onClick={(e) => {
27+
e.stopPropagation()
28+
onFileClick?.(ref.filePath)
29+
}}
30+
className="inline p-0 m-0 bg-transparent border-0 font-[inherit] text-left text-primary hover:text-primary-hover cursor-pointer underline decoration-dotted"
31+
title={`Click to open ${ref.filePath}`}
32+
aria-label={`Open ${ref.filePath}`}
33+
>
34+
{ref.fullMatch}
35+
</button>,
36+
)
37+
38+
lastIndex = ref.endIndex
39+
})
40+
41+
if (lastIndex < text.length) {
42+
segments.push(text.slice(lastIndex))
43+
}
44+
45+
return segments
46+
}, [text, onFileClick])
47+
48+
return <>{parts}</>
49+
}

0 commit comments

Comments
 (0)