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)