diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a4f3a2777..1975236a5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -325,9 +325,9 @@ runtime cost control, and shared AI transport surfaces. `packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx`. Pulse's compact tool rows must follow that operator-language model: the row should summarize the actual governed action (`search "prowlarr"`, `list - active alerts`, `topology summary`) instead of exposing only internal - action names such as `QUERY search` or raw JSON; raw input and output stay - available behind Details. + active alerts`, `topology summary`, `$ ls /dev | wc -l on current resource`) + instead of exposing only internal action names such as `QUERY search`, + `exec`, or raw JSON; raw input and output stay available behind Details. The referenced OpenCode source at fetched `origin/dev` commit `fa2b63f850fc0a23bec2bdff9e660450d3fe7913` also keeps assistant text, reasoning, and tool invocation as typed message parts in diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index 17219bf8e..456b1deef 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -46,7 +46,58 @@ const stringField = (record: Record, keys: string[]) => { const booleanField = (record: Record, key: string) => record[key] === true; const inlineValue = (value: string, maxLength = 24) => - value.replace(/["\r\n]+/g, ' ').trim().substring(0, maxLength); + value + .replace(/["\r\n]+/g, ' ') + .trim() + .substring(0, maxLength); + +const targetSuffix = (record: Record) => { + const target = stringField(record, ['target_host', 'targetHost', 'resource_id', 'resourceId']); + if (!target) return ''; + return ` on ${formatIdentifierLabel(target, { maxLength: 18 })}`; +}; + +const formatCommandSummary = (record: Record) => { + const command = stringField(record, ['command', 'cmd']); + if (!command) return ''; + return `$ ${inlineValue(command, 64)}${targetSuffix(record)}`; +}; + +const formatPulseReadInputSummary = (record: Record) => { + const action = stringField(record, ['action', 'type']).toLowerCase(); + const path = inlineValue(stringField(record, ['path', 'file', 'file_path', 'filePath']), 36); + const pattern = inlineValue( + stringField(record, ['pattern', 'grep', 'grep_pattern', 'grepPattern']), + 28, + ); + const container = inlineValue(stringField(record, ['container', 'service', 'unit']), 24); + const source = stringField(record, ['source']).toLowerCase(); + + if (action === 'exec') { + return formatCommandSummary(record) || `run read-only command${targetSuffix(record)}`; + } + if (action === 'file') { + return path ? `read ${path}${targetSuffix(record)}` : `read file${targetSuffix(record)}`; + } + if (action === 'tail') { + return path ? `tail ${path}${targetSuffix(record)}` : `tail file${targetSuffix(record)}`; + } + if (action === 'find') { + if (pattern && path) return `find "${pattern}" in ${path}${targetSuffix(record)}`; + return pattern + ? `find "${pattern}"${targetSuffix(record)}` + : `find files${targetSuffix(record)}`; + } + if (action === 'logs') { + if (container) return `logs ${container}${targetSuffix(record)}`; + return source + ? `${formatIdentifierLabel(source, { maxLength: 18 })} logs${targetSuffix(record)}` + : `read logs${targetSuffix(record)}`; + } + return ( + formatCommandSummary(record) || (action ? formatIdentifierLabel(action, { maxLength: 28 }) : '') + ); +}; const formatQueryInputSummary = (record: Record) => { const action = stringField(record, ['action', 'type']).toLowerCase(); @@ -105,6 +156,12 @@ const parseToolInputSummary = (input: string, toolName?: string) => { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const record = parsed as Record; const tool = normalizedToolName(toolName); + if (tool === 'read') { + return formatPulseReadInputSummary(record) || 'read resource'; + } + if (tool === 'run_command' || tool === 'control') { + return formatCommandSummary(record) || 'run command'; + } if (tool === 'query') { return formatQueryInputSummary(record); } @@ -150,9 +207,7 @@ export const ToolExecutionBlock: Component = (props) => const toolLabel = createMemo(() => getToolLabel(props.tool.name)); const inputText = createMemo(() => toolValueText(props.tool.input)); const outputText = createMemo(() => toolValueText(props.tool.output)); - const inputSummary = createMemo(() => - parseToolInputSummary(inputText(), props.tool.name), - ); + const inputSummary = createMemo(() => parseToolInputSummary(inputText(), props.tool.name)); const hasInput = createMemo(() => inputText().trim().length > 0); const hasOutput = createMemo(() => hasReadableToolOutput(outputText())); const hasDetails = createMemo(() => hasInput() || hasOutput()); @@ -232,9 +287,7 @@ interface PendingToolBlockProps { export const PendingToolBlock: Component = (props) => { const toolLabel = createMemo(() => getToolLabel(props.tool.name)); const inputText = createMemo(() => toolValueText(props.tool.input)); - const inputSummary = createMemo(() => - parseToolInputSummary(inputText(), props.tool.name), - ); + const inputSummary = createMemo(() => parseToolInputSummary(inputText(), props.tool.name)); const status = createMemo(() => props.tool.status || 'pending'); const [now, setNow] = createSignal(Date.now()); const statusLabel = createMemo(() => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx index a08e6dafd..e0920915d 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx @@ -180,6 +180,49 @@ describe('ToolExecutionBlock', () => { expect(screen.getByText('list active alerts')).toBeInTheDocument(); }); + it('renders Pulse read exec input as the actual command', () => { + render(() => ( + + )); + + expect(screen.getByText('$ ls /dev | wc -l on current resource')).toBeInTheDocument(); + expect(screen.queryByText(/"target_host"/)).not.toBeInTheDocument(); + }); + + it('renders Pulse read log input as a readable log action', () => { + render(() => ( + + )); + + expect(screen.getByText('logs jellyfin on jellyfin')).toBeInTheDocument(); + }); + + it('renders governed command input as the command being run', () => { + render(() => ( + + )); + + expect(screen.getByText('$ systemctl restart nginx on tower')).toBeInTheDocument(); + }); + // --- Output display --- it('does not show output by default when non-empty', () => { @@ -357,6 +400,21 @@ describe('PendingToolBlock', () => { expect(screen.queryByText(/"type"/)).not.toBeInTheDocument(); }); + it('renders pending Pulse read exec input as the command being prepared', () => { + render(() => ( + + )); + + expect(screen.getByText('$ lsblk -o NAME,SIZE on current resource')).toBeInTheDocument(); + expect(screen.queryByText(/"command"/)).not.toBeInTheDocument(); + }); + // --- Activity state --- it('renders a spinner SVG with animate-spin class while pending', () => {