From afe0e8b892315b5ae01001752b1a7f55a8001422 Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 21 Dec 2025 08:51:45 +0800 Subject: [PATCH] feat(web): add CLI output message block support with layout improvements Implement CLI output message type for displaying command output from user/assistant messages. Adds CliOutputBlock component and type with detection logic based on message metadata and CLI tags. Includes merging of adjacent CLI output blocks for cleaner presentation. Enhance layout throughout components with proper overflow handling and width constraints for improved text wrapping and scrolling behavior. --- web/src/chat/reconcile.ts | 14 ++ web/src/chat/reducer.ts | 100 +++++++++++- web/src/chat/types.ts | 12 +- .../components/AssistantChat/HappyThread.tsx | 4 +- .../messages/AssistantMessage.tsx | 23 ++- .../AssistantChat/messages/ToolMessage.tsx | 16 +- .../AssistantChat/messages/UserMessage.tsx | 20 +++ web/src/components/CliOutputBlock.tsx | 146 ++++++++++++++++++ web/src/components/CodeBlock.tsx | 10 +- web/src/components/LazyRainbowText.tsx | 3 +- web/src/components/MarkdownRenderer.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 30 ++-- .../components/assistant-ui/markdown-utils.ts | 10 ++ .../assistant-ui/shiki-highlighter.tsx | 6 +- web/src/index.css | 2 - web/src/lib/assistant-runtime.ts | 18 ++- 16 files changed, 385 insertions(+), 31 deletions(-) create mode 100644 web/src/components/CliOutputBlock.tsx create mode 100644 web/src/components/assistant-ui/markdown-utils.ts diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 8e679ae1..9e9b37ea 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -3,6 +3,7 @@ import type { AgentEventBlock, AgentTextBlock, ChatBlock, + CliOutputBlock, ToolCallBlock, ToolPermission, UserTextBlock, @@ -105,6 +106,14 @@ function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): b && left.meta === right.meta } +function areCliOutputBlocksEqual(left: CliOutputBlock, right: CliOutputBlock): boolean { + return left.text === right.text + && left.localId === right.localId + && left.createdAt === right.createdAt + && left.source === right.source + && left.meta === right.meta +} + function areAgentEventBlocksEqual(left: AgentEventBlock, right: AgentEventBlock): boolean { return left.createdAt === right.createdAt && left.meta === right.meta @@ -173,6 +182,11 @@ function reconcileBlock(block: ChatBlock, prevById: ChatBlocksById): ChatBlock { return areAgentTextBlocksEqual(prevBlock, block) ? prevBlock : block } + if (block.kind === 'cli-output') { + const prevBlock = prev as CliOutputBlock + return areCliOutputBlocksEqual(prevBlock, block) ? prevBlock : block + } + const prevBlock = prev as AgentEventBlock return areAgentEventBlocksEqual(prevBlock, block) ? prevBlock : block } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index a34e89d0..4715cf28 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,7 +1,11 @@ import type { AgentState } from '@/types/api' -import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types' +import type { AgentEvent, ChatBlock, ChatToolCall, CliOutputBlock, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types' import { traceMessages, type TracedMessage } from '@/chat/tracer' +const CLI_TAG_REGEX = /<(?:local-command-[a-z-]+|command-(?:name|message|args))>/i +const CLI_COMMAND_NAME_REGEX = //i +const CLI_COMMAND_STDOUT_REGEX = //i + // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens @@ -161,6 +165,76 @@ function getPermissions(agentState: AgentState | null | undefined): Map, @@ -296,6 +370,17 @@ function reduceTimeline( } if (msg.role === 'user') { + if (isCliOutputText(msg.content.text, msg.meta)) { + blocks.push(createCliOutputBlock({ + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + text: msg.content.text, + source: 'user', + meta: msg.meta + })) + continue + } blocks.push({ kind: 'user-text', id: msg.id, @@ -313,6 +398,17 @@ function reduceTimeline( for (let idx = 0; idx < msg.content.length; idx += 1) { const c = msg.content[idx] if (c.type === 'text') { + if (isCliOutputText(c.text, msg.meta)) { + blocks.push(createCliOutputBlock({ + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + source: 'assistant', + meta: msg.meta + })) + continue + } blocks.push({ kind: 'agent-text', id: `${msg.id}:${idx}`, @@ -447,7 +543,7 @@ function reduceTimeline( } } - return { blocks, toolBlocksById, hasReadyEvent } + return { blocks: mergeCliOutputBlocks(blocks), toolBlocksById, hasReadyEvent } } export type LatestUsage = { diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index d07c47a8..fe06e2af 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -122,6 +122,16 @@ export type AgentTextBlock = { meta?: unknown } +export type CliOutputBlock = { + kind: 'cli-output' + id: string + localId: string | null + createdAt: number + text: string + source: 'user' | 'assistant' + meta?: unknown +} + export type AgentEventBlock = { kind: 'agent-event' id: string @@ -140,4 +150,4 @@ export type ToolCallBlock = { meta?: unknown } -export type ChatBlock = UserTextBlock | AgentTextBlock | ToolCallBlock | AgentEventBlock +export type ChatBlock = UserTextBlock | AgentTextBlock | CliOutputBlock | ToolCallBlock | AgentEventBlock diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index f9404296..aa2c01fd 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -32,8 +32,8 @@ export function HappyThread(props: { onRetryMessage: props.onRetryMessage }}> - -
+ +
{props.header}
diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 9c91d7f5..13d76dfe 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -1,6 +1,8 @@ import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' +import { CliOutputBlock } from '@/components/CliOutputBlock' +import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' const TOOL_COMPONENTS = { Fallback: HappyToolMessage @@ -12,12 +14,31 @@ const MESSAGE_PART_COMPONENTS = { } as const export function HappyAssistantMessage() { + const isCliOutput = useAssistantState(({ message }) => { + const custom = message.metadata.custom as Partial | undefined + return custom?.kind === 'cli-output' + }) + const cliText = useAssistantState(({ message }) => { + const custom = message.metadata.custom as Partial | undefined + if (custom?.kind !== 'cli-output') return '' + return message.content.find((part) => part.type === 'text')?.text ?? '' + }) const toolOnly = useAssistantState(({ message }) => { if (message.role !== 'assistant') return false const parts = message.content return parts.length > 0 && parts.every((part) => part.type === 'tool-call') }) - const rootClass = toolOnly ? 'py-1' : 'px-1' + const rootClass = toolOnly + ? 'py-1 min-w-0 max-w-full overflow-x-hidden' + : 'px-1 min-w-0 max-w-full overflow-x-hidden' + + if (isCliOutput) { + return ( + + + + ) + } return ( diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 1d101d7f..e8a54e3b 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -8,6 +8,7 @@ import { LazyRainbowText } from '@/components/LazyRainbowText' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' +import { CliOutputBlock } from '@/components/CliOutputBlock' function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' @@ -76,6 +77,17 @@ function HappyNestedBlockList(props: { ) } + if (block.kind === 'cli-output') { + const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : '' + return ( +
+
+ +
+
+ ) + } + if (block.kind === 'agent-event') { const presentation = getEventPresentation(block.event) return ( @@ -140,7 +152,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { const resultText = hasResult ? safeStringify(props.result) : '' return ( -
+
@@ -174,7 +186,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { const isTask = block.tool.name === 'Task' return ( -
+
| undefined return custom?.localId ?? null }) + const isCliOutput = useAssistantState(({ message }) => { + const custom = message.metadata.custom as Partial | undefined + return custom?.kind === 'cli-output' + }) + const cliText = useAssistantState(({ message }) => { + const custom = message.metadata.custom as Partial | undefined + if (custom?.kind !== 'cli-output') return '' + return message.content.find((part) => part.type === 'text')?.text ?? '' + }) if (role !== 'user') return null const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) @@ -28,6 +38,16 @@ export function HappyUserMessage() { const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm' + if (isCliOutput) { + return ( + +
+ +
+
+ ) + } + return (
diff --git a/web/src/components/CliOutputBlock.tsx b/web/src/components/CliOutputBlock.tsx new file mode 100644 index 00000000..36f96b1a --- /dev/null +++ b/web/src/components/CliOutputBlock.tsx @@ -0,0 +1,146 @@ +import { useMemo } from 'react' +import { stripAnsiAndControls } from '@/components/assistant-ui/markdown-utils' +import { Card, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' + +const CLI_TAG_PATTERN = '(?:local-command-[a-z-]+|command-(?:name|message|args))' +const CLI_TAG_CHECK_REGEX = new RegExp(`<${CLI_TAG_PATTERN}>`, 'i') +const CLI_TAG_REGEX_SOURCE = `<(${CLI_TAG_PATTERN})>([\\s\\S]*?)<\\/\\1>` +const BR_REGEX = //gi + +const LABELS: Record = { + 'command-name': 'Command', + 'command-message': 'Command message', + 'command-args': 'Command args', + 'local-command-stdout': 'Stdout', + 'local-command-stderr': 'Stderr', +} +const COMMAND_NAME_REGEX = /([\s\S]*?)<\/command-name>/i + +export function hasCliOutputTags(text: string): boolean { + return CLI_TAG_CHECK_REGEX.test(text) +} + +function normalizeCliText(text: string): string { + const withoutAnsi = stripAnsiAndControls(text) + return withoutAnsi.replace(BR_REGEX, '\n') +} + +function formatLabel(tag: string): string { + const normalized = tag.toLowerCase() + if (LABELS[normalized]) { + return LABELS[normalized] + } + return normalized.replace(/-/g, ' ') +} + +function buildCliOutput(text: string): string { + const matches = Array.from(text.matchAll(new RegExp(CLI_TAG_REGEX_SOURCE, 'gi'))) + if (matches.length === 0) { + return normalizeCliText(text) + } + + const sections: string[] = [] + let lastIndex = 0 + + for (const match of matches) { + const startIndex = match.index ?? 0 + if (startIndex > lastIndex) { + const before = normalizeCliText(text.slice(lastIndex, startIndex)) + if (before.trim().length > 0) { + sections.push(before.trimEnd()) + } + } + + const tagName = match[1] ?? '' + const content = normalizeCliText(match[2] ?? '') + const label = formatLabel(tagName) + + if (content.length > 0) { + sections.push(`${label}:\n${content}`) + } else { + sections.push(`${label}:`) + } + + lastIndex = startIndex + match[0].length + } + + if (lastIndex < text.length) { + const tail = normalizeCliText(text.slice(lastIndex)) + if (tail.trim().length > 0) { + sections.push(tail.trimEnd()) + } + } + + return sections.join('\n\n') +} + +function extractCommandName(text: string): string | null { + const match = text.match(COMMAND_NAME_REGEX) + if (!match) return null + const normalized = normalizeCliText(match[1] ?? '') + const firstLine = normalized.split('\n').find((line) => line.trim().length > 0)?.trim() + return firstLine && firstLine.length > 0 ? firstLine : null +} + +function DetailsIcon() { + return ( + + + + ) +} + +function CliIcon() { + return ( + + + + + ) +} + +export function CliOutputBlock(props: { text: string }) { + const content = useMemo(() => buildCliOutput(props.text), [props.text]) + const commandName = useMemo(() => extractCommandName(props.text), [props.text]) + + return ( + + + + + + + + + CLI output + +
+
+
+                                    {content}
+                                
+
+
+
+
+
+
+ ) +} diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index 90343a75..d3c34fee 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -72,7 +72,7 @@ export function CodeBlock(props: { } return ( -
+
{showCopyButton ? (
) } diff --git a/web/src/components/LazyRainbowText.tsx b/web/src/components/LazyRainbowText.tsx index efd2a5e0..52be0612 100644 --- a/web/src/components/LazyRainbowText.tsx +++ b/web/src/components/LazyRainbowText.tsx @@ -102,7 +102,8 @@ function processChildrenForRainbow(children: React.ReactNode): React.ReactNode { }) } -export function LazyRainbowText({ text }: { text: string }) { +export function LazyRainbowText(props: { text: string }) { + const text = props.text const ref = useRef(null) const [hasBeenVisible, setHasBeenVisible] = useState(false) diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx index 106a5ce5..dede0697 100644 --- a/web/src/components/MarkdownRenderer.tsx +++ b/web/src/components/MarkdownRenderer.tsx @@ -19,7 +19,7 @@ function MarkdownContent(props: MarkdownRendererProps) { ) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 8ec911dd..d25b6cae 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -95,14 +95,18 @@ function CodeHeader(props: CodeHeaderProps) { } function Pre(props: ComponentPropsWithoutRef<'pre'>) { + const { className, ...rest } = props + return ( -
+        
+
+        
) } @@ -122,7 +126,7 @@ function Code(props: ComponentPropsWithoutRef<'code'>) { @@ -174,7 +178,13 @@ function Hr(props: ComponentPropsWithoutRef<'hr'>) { } function Table(props: ComponentPropsWithoutRef<'table'>) { - return + const { className, ...rest } = props + + return ( +
+
+ + ) } function Thead(props: ComponentPropsWithoutRef<'thead'>) { @@ -275,7 +285,7 @@ export function MarkdownText() { ) } diff --git a/web/src/components/assistant-ui/markdown-utils.ts b/web/src/components/assistant-ui/markdown-utils.ts new file mode 100644 index 00000000..38429e98 --- /dev/null +++ b/web/src/components/assistant-ui/markdown-utils.ts @@ -0,0 +1,10 @@ +const ANSI_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g +const ANSI_OSC_REGEX = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g +const CONTROL_CHARS_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g + +export function stripAnsiAndControls(text: string): string { + const normalized = text.replace(/\r\n?/g, '\n') + const withoutOsc = normalized.replace(ANSI_OSC_REGEX, '') + const withoutAnsi = withoutOsc.replace(ANSI_REGEX, '') + return withoutAnsi.replace(CONTROL_CHARS_REGEX, '') +} diff --git a/web/src/components/assistant-ui/shiki-highlighter.tsx b/web/src/components/assistant-ui/shiki-highlighter.tsx index 2ca5ec0e..383d545e 100644 --- a/web/src/components/assistant-ui/shiki-highlighter.tsx +++ b/web/src/components/assistant-ui/shiki-highlighter.tsx @@ -5,9 +5,9 @@ export function SyntaxHighlighter(props: SyntaxHighlighterProps) { const highlighted = useShikiHighlighter(props.code, props.language) return ( -
-
-                {highlighted ?? props.code}
+        
+
+                {highlighted ?? props.code}
             
) diff --git a/web/src/index.css b/web/src/index.css index 9ed34bfd..9a39ceee 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -112,11 +112,9 @@ body { .markdown-content table { border-collapse: collapse; width: 100%; } .markdown-content th, .markdown-content td { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; } -/* assistant-ui markdown + shiki */ .aui-md-codeblock pre.shiki { margin: 0; padding: 0.5rem; - overflow: auto; background-color: transparent !important; font-size: 0.75rem; } diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 4fd92661..e42327ec 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react' import type { AppendMessage, ThreadMessageLike } from '@assistant-ui/react' import { useExternalMessageConverter, useExternalStoreRuntime } from '@assistant-ui/react' import { renderEventLabel } from '@/chat/presentation' -import type { ChatBlock } from '@/chat/types' +import type { ChatBlock, CliOutputBlock } from '@/chat/types' import type { AgentEvent, ToolCallBlock } from '@/chat/types' import type { MessageStatus as HappyMessageStatus, Session } from '@/types/api' @@ -17,12 +17,13 @@ function safeStringify(value: unknown): string { } export type HappyChatMessageMetadata = { - kind: 'user' | 'assistant' | 'tool' | 'event' + kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output' status?: HappyMessageStatus localId?: string | null originalText?: string toolCallId?: string event?: AgentEvent + source?: CliOutputBlock['source'] } function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { @@ -70,6 +71,19 @@ function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { } } + if (block.kind === 'cli-output') { + const messageId = `cli:${block.id}` + return { + role: block.source === 'user' ? 'user' : 'assistant', + id: messageId, + createdAt: new Date(block.createdAt), + content: [{ type: 'text', text: block.text }], + metadata: { + custom: { kind: 'cli-output', source: block.source } satisfies HappyChatMessageMetadata + } + } + } + const toolBlock: ToolCallBlock = block const messageId = `tool:${toolBlock.id}` const inputText = safeStringify(toolBlock.tool.input)