diff --git a/frontend/src/components/message/ExecuteToolPart.tsx b/frontend/src/components/message/ExecuteToolPart.tsx new file mode 100644 index 00000000..3a7e226a --- /dev/null +++ b/frontend/src/components/message/ExecuteToolPart.tsx @@ -0,0 +1,336 @@ +import { memo, useMemo, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { Loader2 } from 'lucide-react' +import { toolContentText, type SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' +import { useSettings } from '@/hooks/useSettings' +import { toolMetadata } from '@/lib/backgroundWork' +import { clampDisplayText } from '@/lib/clampDisplayText' +import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' +import { CopyButton } from '@/components/ui/copy-button' +import { FileReferenceText } from './FileReferenceText' +import { ToolContentFiles } from './ToolContentFiles' + +type ExecuteCallStatus = 'running' | 'completed' | 'error' + +interface ExecuteCall { + tool: string + status: ExecuteCallStatus + input?: Record +} + +interface ExecuteToolPartProps { + part: SessionMessageAssistantTool + isWaitingPermission: boolean + onFileClick?: (filePath: string) => void +} + +interface ParsedOutput { + json: string + rest?: string +} + +const OUTPUT_TAIL_PATTERN = /\n\n(Warnings|Logs):\n/ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function executeCalls(value: unknown): ExecuteCall[] { + if (!Array.isArray(value)) return [] + return value.flatMap((call) => { + if (!isRecord(call)) return [] + const tool = call.tool + const status = call.status + if (typeof tool !== 'string') return [] + if (status !== 'running' && status !== 'completed' && status !== 'error') return [] + return [{ tool, status, input: isRecord(call.input) ? call.input : undefined }] + }) +} + +function primitiveInputSummary(input: Record): string { + const entries = Object.entries(input).filter(([, value]) => ( + typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + )) + if (entries.length === 0) return '' + return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(', ')}]` +} + +function executeCallSummary(call: ExecuteCall): string { + const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, ' ') + return `${call.tool}${args ? ` ${args}` : ''}` +} + +function executeCode(part: SessionMessageAssistantTool): string | undefined { + if (part.state.status === 'streaming') return undefined + const code = part.state.input.code + return typeof code === 'string' && code ? code : undefined +} + +function executeOutputText(part: SessionMessageAssistantTool): string { + if (part.state.status === 'error') return part.state.error.message.trim() + if (part.state.status !== 'completed') return '' + return toolContentText(part.state.content).trim() +} + +function parseOutput(text: string): ParsedOutput | undefined { + if (!text.startsWith('{') && !text.startsWith('[')) return undefined + const tail = text.search(OUTPUT_TAIL_PATTERN) + const json = tail === -1 ? text : text.slice(0, tail) + try { + JSON.parse(json) + } catch { + return undefined + } + return { json, rest: tail === -1 ? undefined : text.slice(json.length).trim() || undefined } +} + +function trimTrailingNewlines(text: string): string { + return text.replace(/\n+$/, '') +} + +function lineCount(text: string | undefined): number { + return text ? trimTrailingNewlines(text).split('\n').length : 0 +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function longestBacktickRun(text: string): number { + const runs = text.match(/`+/g) ?? [] + return runs.reduce((longest, run) => Math.max(longest, run.length), 0) +} + +const HighlightedCode = memo(function HighlightedCode({ + content, + language, + digits, +}: { + content: string + language: 'ts' | 'json' + digits: number +}) { + const body = trimTrailingNewlines(content) + const markdown = useMemo(() => { + const fence = '`'.repeat(Math.max(3, longestBacktickRun(body) + 1)) + return `${fence}${language}\n${body}\n${fence}` + }, [body, language]) + const gutter = useMemo( + () => body.split('\n').map((_, index) => String(index + 1)).join('\n'), + [body], + ) + + return ( +
+
+ {gutter} +
+
+ {markdown} +
+
+ ) +}) + +function ExecuteCallRow({ call, onFileClick }: { call: ExecuteCall; onFileClick?: (filePath: string) => void }) { + const [expanded, setExpanded] = useState(false) + const entries = Object.entries(call.input ?? {}) + const expandable = entries.length > 0 + const isError = call.status === 'error' + const color = isError ? 'text-destructive' : 'text-muted-foreground' + + const summary = ( + <> + {isError ? '✗' : '›'} + {executeCallSummary(call)} + + ) + + return ( +
+ {expandable ? ( + + ) : ( +
{summary}
+ )} + {expanded && ( +
+ {entries.map(([key, value]) => ( +
+ {key}: + + + +
+ ))} +
+ )} +
+ ) +} + +export function ExecuteToolPart({ part, isWaitingPermission, onFileClick }: ExecuteToolPartProps) { + const { preferences } = useSettings() + const [expanded, setExpanded] = useState(preferences?.expandToolCalls ?? false) + const metadata = toolMetadata(part) + const calls = executeCalls(metadata.toolCalls) + const code = executeCode(part) + const output = executeOutputText(part) + const displayOutput = useMemo(() => clampDisplayText(output), [output]) + const parsedOutput = useMemo( + () => (displayOutput === output ? parseOutput(output) : undefined), + [displayOutput, output], + ) + const failed = part.state.status === 'error' + || metadata.error === true + || (part.state.status === 'completed' && calls.some((call) => call.status === 'error')) + const status = part.state.status + + const statusLabel = (() => { + if (status === 'streaming') return 'Receiving code…' + if (isWaitingPermission) return 'awaiting permission' + if (status === 'running') return 'Running' + const duration = part.time.completed !== undefined + ? ` · ${formatDuration(part.time.completed - (part.time.ran ?? part.time.created))}` + : '' + return `${failed ? 'Failed' : 'Completed'}${duration}` + })() + + const statusColor = (() => { + if (status === 'error') return 'text-destructive' + if (status === 'running') return isWaitingPermission ? 'text-highlight' : 'text-warning' + if (failed) return 'text-destructive' + if (status === 'completed') return 'text-success' + return 'text-muted-foreground' + })() + + const statusIcon = (() => { + if (status === 'streaming') { + return + } + if (status === 'running') return + if (failed) return ✗ + if (status === 'completed') return ✓ + return ○ + })() + + const borderStyle = status === 'running' + ? isWaitingPermission + ? 'border-highlight/50 shadow-sm shadow-highlight/20' + : 'border-warning/50 shadow-sm shadow-warning/10' + : status === 'streaming' + ? 'border-primary/30' + : failed + ? 'border-destructive/30' + : 'border-border' + + const digits = String(Math.max(lineCount(code), lineCount(parsedOutput?.json), 1)).length + const errorPreview = failed && output ? output.split('\n').slice(0, 4) : [] + + return ( +
+
+ +
+ + {calls.length > 0 && ( +
+ {calls.map((call, index) => ( + + ))} +
+ )} + + + + {!expanded && errorPreview.length > 0 && ( +
+ {errorPreview.map((line, index) => ( +
+ {index === 0 ? `↳ ${line}` : ` ${line}`} +
+ ))} +
+ )} + + {expanded && ( +
+
+
Code
+ {code ? ( + + ) : ( +
Waiting for code…
+ )} +
+ +
+
Output
+ {parsedOutput ? ( + <> + + {parsedOutput.rest && ( +
+                    {parsedOutput.rest}
+                  
+ )} + + ) : displayOutput ? ( +
+                {displayOutput}
+              
+ ) : ( +
+ {status === 'completed' ? 'No output' : 'Waiting for output…'} +
+ )} +
+ +
+ {code && ( +
+ + Copy code +
+ )} + {output && ( +
+ + Copy output +
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/message/FileReferenceText.tsx b/frontend/src/components/message/FileReferenceText.tsx new file mode 100644 index 00000000..344f1731 --- /dev/null +++ b/frontend/src/components/message/FileReferenceText.tsx @@ -0,0 +1,49 @@ +import { useMemo, type ReactNode } from 'react' +import { detectFileReferences } from '@/lib/fileReferences' + +interface FileReferenceTextProps { + text: string + onFileClick?: (filePath: string) => void +} + +export function FileReferenceText({ text, onFileClick }: FileReferenceTextProps) { + const parts = useMemo(() => { + const references = detectFileReferences(text) + if (references.length === 0) return text + + const segments: ReactNode[] = [] + let lastIndex = 0 + + references.forEach((ref, index) => { + if (ref.startIndex > lastIndex) { + segments.push(text.slice(lastIndex, ref.startIndex)) + } + + segments.push( + , + ) + + lastIndex = ref.endIndex + }) + + if (lastIndex < text.length) { + segments.push(text.slice(lastIndex)) + } + + return segments + }, [text, onFileClick]) + + return <>{parts} +} diff --git a/frontend/src/components/message/FileToolRender.tsx b/frontend/src/components/message/FileToolRender.tsx index 7f3a1044..aa889508 100644 --- a/frontend/src/components/message/FileToolRender.tsx +++ b/frontend/src/components/message/FileToolRender.tsx @@ -3,6 +3,7 @@ import type { FileDiffInfo, SessionMessageAssistantTool } from '@opencode-manage import { useSettings } from '@/hooks/useSettings' import { DiffStats } from './DiffStats' import { CodePreview } from './CodePreview' +import { ToolContentFiles } from './ToolContentFiles' import { ChevronDown, ChevronUp } from 'lucide-react' function isFileDiff(data: unknown): data is FileDiffInfo { @@ -122,6 +123,8 @@ function FileToolRender({ part, filediff, filePath, content, toolName, onFileCli + + {expanded && hasExpandableContent && (
{filediff && } diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index 87e752d3..da0a074c 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import type { PermissionRequest, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' @@ -292,6 +292,226 @@ describe('ToolCallPart background indicator', () => { }) }) +describe('ToolCallPart execute renderer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + }) + + const completedExecute = ( + metadata: Record, + text = '{"windows":[{"title":"Untitled"}]}', + ): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_exec', + name: 'execute', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { + code: 'const result = await tools.listWindows({ app: "TextEdit" })\nconsole.log(result)', + }, + content: [{ type: 'text', text }], + metadata, + }, + }) + + const childCalls = [ + { tool: 'cua-driver.list_windows', status: 'completed', input: { app: 'TextEdit' } }, + { tool: 'cua-driver.click', status: 'error', input: { x: 10 } }, + ] + + it('lists child calls while collapsed and marks errors', () => { + renderWithProviders() + + expect(screen.getByText('cua-driver.list_windows [app=TextEdit]')).toBeInTheDocument() + expect(screen.getByText('cua-driver.click [x=10]')).toBeInTheDocument() + + const errorRow = screen.getByRole('button', { name: /cua-driver\.click/ }) + expect(within(errorRow).getByText('✗')).toBeInTheDocument() + expect(screen.getByText(/Failed/)).toBeInTheDocument() + }) + + it('renders line-numbered code and output sections when expanded', () => { + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: true }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + + const { container } = renderWithProviders( + , + ) + + expect(screen.getByText('Code')).toBeInTheDocument() + expect(screen.getByText('Output')).toBeInTheDocument() + expect(container.querySelector('code.language-ts')?.textContent).toContain('const result = await tools.listWindows') + expect(container.querySelector('code.language-json')?.textContent).toContain('"windows"') + + const gutters = screen.getAllByTestId('execute-line-numbers') + expect(gutters[0].textContent).toBe('1\n2') + expect(gutters[1].textContent).toBe('1') + expect(gutters[0].style.width).toBe(gutters[1].style.width) + }) + + it('reports a completed script that failed at runtime', () => { + renderWithProviders( + , + ) + + expect(screen.getByText(/Failed/)).toBeInTheDocument() + expect(screen.getByText('↳ Error: tool not found')).toBeInTheDocument() + expect(screen.queryByText('Completed')).not.toBeInTheDocument() + }) + + const runningExecute = (): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_exec', + name: 'execute', + time: { created: 1, ran: 2 }, + state: { + status: 'running', + input: { code: 'await tools.listWindows({ app: "TextEdit" })' }, + metadata: {}, + }, + }) + + it('shows the waiting permission state while the script awaits permission', () => { + mocks.useToolCallPermission.mockReturnValue( + permissionFor({ type: 'tool', messageID: 'msg_1', id: 'call_exec' }), + ) + + renderWithProviders() + + expect(screen.getByText('awaiting permission')).toBeInTheDocument() + expect(screen.queryByText('Running')).not.toBeInTheDocument() + }) + + it('toggles aria-expanded on the header button', () => { + renderWithProviders() + + const header = screen.getByRole('button', { name: /execute/ }) + expect(header).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(header) + + expect(header).toHaveAttribute('aria-expanded', 'true') + }) + + it('opens a file reference from an expanded child call input', () => { + const onFileClick = vi.fn() + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_exec', + name: 'execute', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { code: 'await tools.read({ path: "src/app.ts" })' }, + content: [{ type: 'text', text: 'read ok' }], + metadata: { toolCalls: [{ tool: 'read', status: 'completed', input: { path: 'src/app.ts' } }] }, + }, + } + + renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: /read \[path=src\/app.ts\]/ })) + const reference = screen.getByRole('button', { name: 'Open src/app.ts' }) + expect(reference).toHaveAttribute('title', 'Click to open src/app.ts') + fireEvent.click(reference) + + expect(onFileClick).toHaveBeenCalledWith('src/app.ts') + }) +}) + +describe('ToolCallPart file content', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + useUserBash.setState({ userBashCommands: new Map() }) + }) + + const screenshotUri = 'data:image/png;base64,aGVsbG8=' + + it('shows an execute screenshot while collapsed', () => { + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_exec_files', + name: 'execute', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { code: 'await tools.screenshot()' }, + content: [ + { type: 'text', text: 'Captured the window' }, + { type: 'file', uri: screenshotUri, mime: 'image/png', name: 'screenshot.png' }, + ], + metadata: {}, + }, + } + + renderWithProviders() + + expect(screen.getByRole('button', { name: /execute/ })).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByAltText('screenshot.png')).toHaveAttribute('src', screenshotUri) + }) + + it('shows a generic tool screenshot while collapsed', () => { + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_screenshot', + name: 'screenshot', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: {}, + content: [{ type: 'file', uri: screenshotUri, mime: 'image/png', name: 'screenshot.png' }], + metadata: {}, + }, + } + + renderWithProviders() + + expect(screen.getByAltText('screenshot.png')).toHaveAttribute('src', screenshotUri) + }) + + it('does not render an empty file content wrapper', () => { + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_text_only', + name: 'screenshot', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: {}, + content: [{ type: 'text', text: 'no files' }], + metadata: {}, + }, + } + + const { container } = renderWithProviders() + + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('.flex-wrap')).toBeNull() + }) +}) + describe('ToolCallPart open walkthrough action', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 0e435e91..93cedf52 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -7,7 +7,7 @@ import { useChildLifecycleForSession } from '@/stores/sessionStatusStore' import { useToolCallPermission } from '@/contexts/EventContext' import { useChildSessionReconciliation } from '@/hooks/useOpenCode' import { useShell } from '@/hooks/useSessionShells' -import { detectFileReferences } from '@/lib/fileReferences' +import { clampDisplayText } from '@/lib/clampDisplayText' import { ExternalLink, Loader2, Shield } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -32,30 +32,11 @@ import { readShowArtifactCall } from '@/lib/artifactTool' import { getWorkspaceFilePath } from '@/lib/markdownLinks' import { getFileApiUrl } from '@/api/files' import { ArtifactPart } from './ArtifactPart' +import { ExecuteToolPart } from './ExecuteToolPart' +import { FileReferenceText } from './FileReferenceText' +import { ToolContentFiles } from './ToolContentFiles' import type { WalkthroughSource } from '@opencode-manager/shared/schemas' -const DISPLAY_LIMIT = 30_000 -const DISPLAY_HEAD_LENGTH = 20_000 -const DISPLAY_TAIL_LENGTH = 10_000 - -function formatOmittedSize(size: number): string { - return size < 1024 * 1024 - ? `${(size / 1024).toFixed(1)} KB` - : `${(size / (1024 * 1024)).toFixed(1)} MB` -} - -function clampDisplayText(text: string): string { - if (text.length <= DISPLAY_LIMIT) return text - const headCut = text.lastIndexOf('\n', DISPLAY_HEAD_LENGTH) - const head = headCut === -1 ? text.slice(0, DISPLAY_HEAD_LENGTH) : text.slice(0, headCut) - const tailFrom = text.length - DISPLAY_TAIL_LENGTH - const tailCut = text.indexOf('\n', tailFrom) - const tail = tailCut === -1 ? text.slice(tailFrom) : text.slice(tailCut + 1) - const omitted = formatOmittedSize(text.length - head.length - tail.length) - const marker = `\n[… ${omitted} omitted — use the copy button for the full output …]\n` - return head + marker + tail -} - function BoundedPre({ content, className }: { content: string; className: string }) { const clamped = useMemo(() => clampDisplayText(content), [content]) return
{clamped}
@@ -85,42 +66,12 @@ function toolOutputText(part: SessionMessageAssistantTool): string { function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { const jsonString = useMemo(() => JSON.stringify(json, null, 2), [json]) - const references = useMemo(() => detectFileReferences(jsonString), [jsonString]) - - if (references.length === 0) { - return
{jsonString}
- } - - const parts: React.ReactNode[] = [] - let lastIndex = 0 - - references.forEach((ref, index) => { - if (ref.startIndex > lastIndex) { - parts.push(jsonString.slice(lastIndex, ref.startIndex)) - } - parts.push( - { - e.stopPropagation() - onFileClick?.(ref.filePath) - }} - className="text-primary hover:text-primary-hover cursor-pointer underline decoration-dotted" - title={`Click to open ${ref.filePath}`} - > - {ref.fullMatch} - - ) - - lastIndex = ref.endIndex - }) - - if (lastIndex < jsonString.length) { - parts.push(jsonString.slice(lastIndex)) - } - - return
{parts}
+ return ( +
+      
+    
+ ) } export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, repoFullPath, repoLocalPath, shellOutcome, onFileClick, onChildSessionClick, onOpenWalkthrough }: ToolCallPartProps) { @@ -327,6 +278,10 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct ) } + if (part.name === 'execute') { + return + } + const toolSpecificRender = getToolSpecificRender(part, onFileClick) if (toolSpecificRender) { return toolSpecificRender @@ -423,6 +378,8 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct ) : null}
+ + {expanded && (
{part.state.status === 'streaming' && ( diff --git a/frontend/src/components/message/ToolContentFiles.test.tsx b/frontend/src/components/message/ToolContentFiles.test.tsx new file mode 100644 index 00000000..f47d78cd --- /dev/null +++ b/frontend/src/components/message/ToolContentFiles.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' +import { ToolContentFiles } from './ToolContentFiles' + +const partWithContent = ( + content: Extract['content'], +): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_files', + name: 'execute', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { code: 'await tools.screenshot()' }, + content, + }, +}) + +describe('ToolContentFiles', () => { + it('renders an image data uri with the file name as alt text', () => { + render( + , + ) + + const image = screen.getByAltText('shot.png') + expect(image).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=') + }) + + it('falls back to the mime type for an unnamed image', () => { + render( + , + ) + + expect(screen.getByAltText('image/png')).toHaveAttribute('src', 'https://example.test/shot.png') + }) + + it('renders a chip for a non-image file and no image', () => { + const { container } = render( + , + ) + + expect(screen.getByText('report.pdf')).toBeInTheDocument() + expect(container.querySelector('img')).toBeNull() + }) + + it.each(['javascript:alert(1)', 'file:///tmp/secret.png'])( + 'renders a chip instead of an image for the disallowed uri %s', + (uri) => { + const { container } = render( + , + ) + + expect(screen.getByText('secret.png')).toBeInTheDocument() + expect(container.querySelector('img')).toBeNull() + }, + ) + + it('renders text-only content as nothing', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing for a non-completed part', () => { + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_running', + name: 'execute', + time: { created: 1, ran: 2 }, + state: { status: 'running', input: { code: 'await tools.screenshot()' }, metadata: {} }, + } + + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/message/ToolContentFiles.tsx b/frontend/src/components/message/ToolContentFiles.tsx new file mode 100644 index 00000000..d047cd45 --- /dev/null +++ b/frontend/src/components/message/ToolContentFiles.tsx @@ -0,0 +1,47 @@ +import type { SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' +import { cn } from '@/lib/utils' +import { InlineImage } from '@/components/ui/inline-image' + +type ToolContentEntry = Extract['content'][number] +type ToolFileEntry = Extract + +const IMAGE_URI_PREFIXES = ['data:image/', 'https://', 'http://'] as const + +function fileEntries(part: SessionMessageAssistantTool): ToolFileEntry[] { + if (part.state.status !== 'completed') return [] + return part.state.content.filter((entry): entry is ToolFileEntry => entry.type === 'file') +} + +function isRenderableImage(entry: ToolFileEntry): boolean { + if (!entry.mime.startsWith('image/')) return false + return IMAGE_URI_PREFIXES.some((prefix) => entry.uri.startsWith(prefix)) +} + +function fileLabel(entry: ToolFileEntry): string { + return entry.name || entry.mime +} + +export function ToolContentFiles({ part, className }: { + part: SessionMessageAssistantTool + className?: string +}) { + const files = fileEntries(part) + if (files.length === 0) return null + + return ( +
+ {files.map((file, index) => ( + isRenderableImage(file) ? ( + + ) : ( + + {fileLabel(file)} + + ) + ))} +
+ ) +} diff --git a/frontend/src/lib/clampDisplayText.test.ts b/frontend/src/lib/clampDisplayText.test.ts new file mode 100644 index 00000000..3a432899 --- /dev/null +++ b/frontend/src/lib/clampDisplayText.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { clampDisplayText } from './clampDisplayText' + +const line = (index: number) => `line ${index} ${'x'.repeat(20)}` + +describe('clampDisplayText', () => { + it('returns short text unchanged', () => { + const text = 'short output\nwith two lines' + + expect(clampDisplayText(text)).toBe(text) + }) + + it('shortens text over the display limit while keeping the head and tail', () => { + const lines = Array.from({ length: 2000 }, (_, index) => line(index)) + const text = lines.join('\n') + + const clamped = clampDisplayText(text) + + expect(clamped.length).toBeLessThan(text.length) + expect(clamped).toContain('omitted — use the copy button') + expect(clamped.startsWith(lines[0])).toBe(true) + expect(clamped.endsWith(lines[lines.length - 1])).toBe(true) + }) +}) diff --git a/frontend/src/lib/clampDisplayText.ts b/frontend/src/lib/clampDisplayText.ts new file mode 100644 index 00000000..d846c6e6 --- /dev/null +++ b/frontend/src/lib/clampDisplayText.ts @@ -0,0 +1,21 @@ +const DISPLAY_LIMIT = 30_000 +const DISPLAY_HEAD_LENGTH = 20_000 +const DISPLAY_TAIL_LENGTH = 10_000 + +function formatOmittedSize(size: number): string { + return size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +export function clampDisplayText(text: string): string { + if (text.length <= DISPLAY_LIMIT) return text + const headCut = text.lastIndexOf('\n', DISPLAY_HEAD_LENGTH) + const head = headCut === -1 ? text.slice(0, DISPLAY_HEAD_LENGTH) : text.slice(0, headCut) + const tailFrom = text.length - DISPLAY_TAIL_LENGTH + const tailCut = text.indexOf('\n', tailFrom) + const tail = tailCut === -1 ? text.slice(tailFrom) : text.slice(tailCut + 1) + const omitted = formatOmittedSize(text.length - head.length - tail.length) + const marker = `\n[… ${omitted} omitted — use the copy button for the full output …]\n` + return head + marker + tail +} diff --git a/ocm-cli/README.md b/ocm-cli/README.md index e02b1894..748af819 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -55,9 +55,13 @@ directory entries and skips file entries. ## Login ```bash -ocm login [token] +ocm login [token] [--name ] ``` +Pass `--name` to label the manager; the attached TUI shows that name instead of +the manager host. A name is dropped when you log in to a different manager URL +without `--name`. + The token is stored in a platform-specific token store: | Platform | Store | @@ -86,7 +90,7 @@ If `[token]` is omitted, `ocm login` reads it from hidden TTY input (requires ```bash ocm -ocm login [token] +ocm login [token] [--name ] ocm status ocm list ocm use diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index cdcc9029..9f8cbfb6 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -24,7 +24,8 @@ Usage: project id; inside a git repo with no match, launch local opencode; outside a git repo, use the last selected repo, else launch local opencode - ocm login [token] Save manager URL + token (token via stdin if omitted) + ocm login [token] [--name ] + Save manager URL + token (token via stdin if omitted) ocm logout Forget saved token and state ocm status Show current manager URL, repo, and whether token is set ocm list List ready repos from the manager @@ -134,12 +135,34 @@ function findRepo(repos: ManagerRepo[], needle: string | number): ManagerRepo | return repos.find((r) => r.name === needle) ?? repos.find((r) => r.name.toLowerCase() === needle.toLowerCase()) } +const LOGIN_USAGE = 'usage: ocm login [token] [--name ]' + +function parseLoginArgs(args: string[]): { url: string; token?: string; managerName?: string } { + const positional: string[] = [] + const managerNames: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--name') { + const value = args[index + 1] + if (!value || value.startsWith('-')) die(LOGIN_USAGE) + managerNames.push(value) + index++ + continue + } + if (arg.startsWith('-')) die(LOGIN_USAGE) + positional.push(arg) + } + if (managerNames.length > 1) die(LOGIN_USAGE) + const url = positional[0] + if (!url) die(LOGIN_USAGE) + return { url, token: positional[1], managerName: managerNames[0] } +} + export async function cmdLogin(args: string[]): Promise { - const url = args[0] - if (!url) die('usage: ocm login [token]') + const { url, token: providedToken, managerName } = parseLoginArgs(args) const normalisedUrl = normalizeManagerUrl(url) - let token = args[1] + let token = providedToken if (!token) { if (process.stdin.isTTY) { process.stderr.write('Paste token (input hidden): ') @@ -167,9 +190,11 @@ export async function cmdLogin(args: string[]): Promise { } const existing = readState() + const sameManager = existing ? normalizeManagerUrl(existing.managerUrl) === normalisedUrl : false writeState({ ...existing, managerUrl: normalisedUrl, + managerName: managerName ?? (sameManager ? existing?.managerName : undefined), }) const store = describeTokenWriteTarget() @@ -229,6 +254,7 @@ export async function cmdStatus(): Promise { } info(`version: ${VERSION}`) info(`manager url: ${state.managerUrl}`) + if (state.managerName) info(`manager name: ${state.managerName}`) info(`token: ${await describeTokenPresence(state.managerUrl)}`) info(`token store: ${store.kind} (${store.location})`) if (state.lastRepoId !== undefined) { diff --git a/ocm-cli/src/remote-context.ts b/ocm-cli/src/remote-context.ts index 97c61fa6..9f40ed91 100644 --- a/ocm-cli/src/remote-context.ts +++ b/ocm-cli/src/remote-context.ts @@ -1,3 +1,6 @@ +import type { OcmState } from './state.js' +import { normalizeManagerUrl } from './manager-auth.js' + export const REMOTE_MANAGER_URL_ENV = 'OCM_REMOTE_MANAGER_URL' export const REMOTE_REPO_NAME_ENV = 'OCM_REMOTE_REPO_NAME' export const REMOTE_REPO_ID_ENV = 'OCM_REMOTE_REPO_ID' @@ -37,3 +40,9 @@ export function readRemoteContext(env: NodeJS.ProcessEnv): RemoteContext | undef return { managerUrl, managerHost, repoName, repoId } } + +/** Returns the saved manager name when it belongs to the attached manager, else the manager host. */ +export function remoteDisplayName(remote: RemoteContext, state: OcmState | null): string { + if (state?.managerName && normalizeManagerUrl(state.managerUrl) === normalizeManagerUrl(remote.managerUrl)) return state.managerName + return remote.managerHost +} diff --git a/ocm-cli/src/state.ts b/ocm-cli/src/state.ts index f47f7f0f..bc8f050c 100644 --- a/ocm-cli/src/state.ts +++ b/ocm-cli/src/state.ts @@ -5,6 +5,7 @@ import { readJsonFile, writeJsonFileAtomic } from './json-store.js' export interface OcmState { managerUrl: string + managerName?: string lastRepoId?: number lastRepoName?: string lastRepoDir?: string diff --git a/ocm-cli/src/tui.tsx b/ocm-cli/src/tui.tsx index 420566e4..a6567176 100644 --- a/ocm-cli/src/tui.tsx +++ b/ocm-cli/src/tui.tsx @@ -6,7 +6,8 @@ import { showGoalDialog } from './tui-goal-dialog.js' import { showMultiRunLaunchDialog, showMultiRunsDialog } from './tui-multi-run-dialogs.js' import { formatMoveProgress } from './move-progress.js' import type { MoveProgress } from './move-progress.js' -import { readRemoteContext } from './remote-context.js' +import { readRemoteContext, remoteDisplayName } from './remote-context.js' +import { readState } from './state.js' import { createGoalStore } from './goal-store.js' import type { GoalStore } from './goal-store.js' import { formatGoalStatus, goalOutcomeToast } from './tui-goal.js' @@ -40,9 +41,10 @@ export default Plugin.define({ const remote = readRemoteContext(process.env) if (remote) { + const managerLabel = remoteDisplayName(remote, readState()) const indicator = () => ( - {remote.managerHost} + {managerLabel} {remote.repoName && · {remote.repoName}} ) diff --git a/ocm-cli/src/warp.ts b/ocm-cli/src/warp.ts index 1958f120..f4ab2b22 100644 --- a/ocm-cli/src/warp.ts +++ b/ocm-cli/src/warp.ts @@ -23,6 +23,8 @@ export type WarpSpawn = ( options: { stdio: 'inherit'; env: NodeJS.ProcessEnv }, ) => unknown +export type WarpExec = (binary: string, args: string[], env: NodeJS.ProcessEnv) => void + const ATTACH_ENV_KEYS = ['OPENCODE_PASSWORD', REMOTE_MANAGER_URL_ENV, REMOTE_REPO_NAME_ENV, REMOTE_REPO_ID_ENV] let pending: PendingWarp | undefined @@ -54,13 +56,25 @@ export function buildLocalInvocation(env: NodeJS.ProcessEnv = process.env): Atta } } -export function runPendingWarp(spawn: WarpSpawn = spawnSync): void { +export function runPendingWarp(spawn: WarpSpawn = spawnSync, exec: WarpExec | undefined = defaultExec): void { const warp = takePendingWarp() if (!warp) return try { const { args, env } = warp.kind === 'attach' ? buildAttachInvocation(warp.target) : buildLocalInvocation() + try { + exec?.('opencode', ['opencode', ...args], env) + } catch { + void 0 + } spawn('opencode', args, { stdio: 'inherit', env }) } catch { void 0 } } + +function defaultExec(command: string, args: string[], env: NodeJS.ProcessEnv): void { + if (typeof Bun === 'undefined') return + const binary = Bun.which(command) + if (!binary || typeof process.execve !== 'function') return + process.execve(binary, args, env) +} diff --git a/ocm-cli/test/cli-auth-commands.test.ts b/ocm-cli/test/cli-auth-commands.test.ts index ab8eb5b0..b6d37cf1 100644 --- a/ocm-cli/test/cli-auth-commands.test.ts +++ b/ocm-cli/test/cli-auth-commands.test.ts @@ -160,6 +160,46 @@ describe('cli auth commands', () => { restore() }) + const expectLoginUsageError = async (args: string[]) => { + const { cli, stderr, setToken, restore } = await loadCli() + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit') + }) + await expect(cli.cmdLogin(args)).rejects.toThrow('exit') + expect(stderr.join('')).toContain('usage: ocm login [token] [--name ]') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(setToken).not.toHaveBeenCalled() + restore() + } + + it('ocm login rejects a repeated --name flag without saving a token', async () => { + await expectLoginUsageError(['https://manager.example.com', '--name', 'prod', '--name', 'staging']) + }) + + it('ocm login rejects a --name flag without a value', async () => { + await expectLoginUsageError(['https://manager.example.com', 'tok_xyz', '--name']) + }) + + it('ocm login rejects a --name value that looks like a flag', async () => { + await expectLoginUsageError(['https://manager.example.com', '--name', '--force']) + }) + + it('ocm login rejects an unknown flag', async () => { + await expectLoginUsageError(['https://manager.example.com', 'tok_xyz', '--force']) + }) + + it('ocm login accepts the token before or after --name', async () => { + const afterName = await loadCli() + await afterName.cli.cmdLogin(['https://manager.example.com', 'tok_xyz', '--name', 'prod']) + expect(afterName.setToken).toHaveBeenCalledWith('https://manager.example.com', 'tok_xyz') + afterName.restore() + + const beforeName = await loadCli() + await beforeName.cli.cmdLogin(['https://manager.example.com', '--name', 'prod', 'tok_xyz']) + expect(beforeName.setToken).toHaveBeenCalledWith('https://manager.example.com', 'tok_xyz') + beforeName.restore() + }) + it('ocm logout reports removal', async () => { const { cli, written, restore } = await loadCli({ deleteTokenImpl: () => Promise.resolve(true) }) await cli.cmdLogout() diff --git a/ocm-cli/test/remote-context.test.ts b/ocm-cli/test/remote-context.test.ts index 8e259691..c41973b1 100644 --- a/ocm-cli/test/remote-context.test.ts +++ b/ocm-cli/test/remote-context.test.ts @@ -4,7 +4,10 @@ import { REMOTE_REPO_ID_ENV, buildRemoteAttachEnv, readRemoteContext, + remoteDisplayName, } from '../src/remote-context.js' +import type { RemoteContext } from '../src/remote-context.js' +import type { OcmState } from '../src/state.js' describe('buildRemoteAttachEnv', () => { it('round-trips through readRemoteContext', () => { @@ -77,4 +80,36 @@ describe('readRemoteContext', () => { expect(result?.repoId).toBe(42) }) +}) + +describe('remoteDisplayName', () => { + const remote: RemoteContext = { managerUrl: 'https://mgr.example.com', managerHost: 'mgr.example.com' } + + it('uses the saved manager name when the state matches the attached manager', () => { + const state: OcmState = { managerUrl: 'https://mgr.example.com', managerName: 'work' } + + expect(remoteDisplayName(remote, state)).toBe('work') + }) + + it('ignores trailing slashes when matching the manager url', () => { + const state: OcmState = { managerUrl: 'https://mgr.example.com/', managerName: 'work' } + + expect(remoteDisplayName(remote, state)).toBe('work') + }) + + it('falls back to the host when the state is for another manager', () => { + const state: OcmState = { managerUrl: 'https://other.example.com', managerName: 'work' } + + expect(remoteDisplayName(remote, state)).toBe('mgr.example.com') + }) + + it('falls back to the host when no name is saved', () => { + const state: OcmState = { managerUrl: 'https://mgr.example.com' } + + expect(remoteDisplayName(remote, state)).toBe('mgr.example.com') + }) + + it('falls back to the host without state', () => { + expect(remoteDisplayName(remote, null)).toBe('mgr.example.com') + }) }) \ No newline at end of file diff --git a/ocm-cli/test/warp.test.ts b/ocm-cli/test/warp.test.ts index 8de30322..f0ef6f75 100644 --- a/ocm-cli/test/warp.test.ts +++ b/ocm-cli/test/warp.test.ts @@ -78,10 +78,14 @@ describe('runPendingWarp', () => { takePendingWarp() }) + const noExec = () => undefined + it('is a no-op when nothing is pending', () => { const spawn = vi.fn() - runPendingWarp(spawn) + const exec = vi.fn(noExec) + runPendingWarp(spawn, exec) expect(spawn).not.toHaveBeenCalled() + expect(exec).not.toHaveBeenCalled() }) it('calls spawn once with the correct args and env', () => { @@ -89,7 +93,7 @@ describe('runPendingWarp', () => { setPendingWarp(sampleWarp) const invocation = buildAttachInvocation(sampleTarget) - runPendingWarp(spawn) + runPendingWarp(spawn, noExec) expect(spawn).toHaveBeenCalledOnce() expect(spawn).toHaveBeenCalledWith( @@ -106,7 +110,7 @@ describe('runPendingWarp', () => { const spawn = vi.fn() setPendingWarp({ kind: 'local' }) - runPendingWarp(spawn) + runPendingWarp(spawn, noExec) expect(spawn).toHaveBeenCalledWith('opencode', [], { stdio: 'inherit', env: buildLocalInvocation().env }) }) @@ -115,8 +119,8 @@ describe('runPendingWarp', () => { const spawn = vi.fn() setPendingWarp(sampleWarp) - runPendingWarp(spawn) - runPendingWarp(spawn) + runPendingWarp(spawn, noExec) + runPendingWarp(spawn, noExec) expect(spawn).toHaveBeenCalledOnce() }) @@ -125,7 +129,29 @@ describe('runPendingWarp', () => { const spawn = vi.fn(() => { throw new Error('spawn failed') }) setPendingWarp(sampleWarp) - expect(() => runPendingWarp(spawn)).not.toThrow() + expect(() => runPendingWarp(spawn, noExec)).not.toThrow() expect(spawn).toHaveBeenCalledOnce() }) + + it('tries to exec opencode with the attach invocation before spawning', () => { + const spawn = vi.fn() + const exec = vi.fn(noExec) + setPendingWarp(sampleWarp) + const invocation = buildAttachInvocation(sampleTarget) + + runPendingWarp(spawn, exec) + + expect(exec).toHaveBeenCalledOnce() + expect(exec).toHaveBeenCalledWith('opencode', ['opencode', ...invocation.args], invocation.env) + }) + + it('falls back to spawn when exec throws', () => { + const spawn = vi.fn() + const exec = vi.fn(() => { throw new Error('exec failed') }) + setPendingWarp(sampleWarp) + const invocation = buildAttachInvocation(sampleTarget) + + expect(() => runPendingWarp(spawn, exec)).not.toThrow() + expect(spawn).toHaveBeenCalledWith('opencode', invocation.args, { stdio: 'inherit', env: invocation.env }) + }) })