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.
This commit is contained in:
weishu
2025-12-21 09:16:46 +08:00
parent 1028547a3a
commit afe0e8b892
16 changed files with 385 additions and 31 deletions
+14
View File
@@ -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
}
+98 -2
View File
@@ -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 = /<command-name>/i
const CLI_COMMAND_STDOUT_REGEX = /<local-command-stdout>/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<string,
return map
}
function getMetaSentFrom(meta: unknown): string | null {
if (!meta || typeof meta !== 'object') return null
const sentFrom = (meta as { sentFrom?: unknown }).sentFrom
return typeof sentFrom === 'string' ? sentFrom : null
}
function hasCliOutputTags(text: string): boolean {
return CLI_TAG_REGEX.test(text)
}
function hasCommandNameTag(text: string): boolean {
return CLI_COMMAND_NAME_REGEX.test(text)
}
function hasLocalCommandStdoutTag(text: string): boolean {
return CLI_COMMAND_STDOUT_REGEX.test(text)
}
function isCliOutputText(text: string, meta: unknown): boolean {
return getMetaSentFrom(meta) === 'cli' && hasCliOutputTags(text)
}
function createCliOutputBlock(props: {
id: string
localId: string | null
createdAt: number
text: string
source: CliOutputBlock['source']
meta?: unknown
}): CliOutputBlock {
return {
kind: 'cli-output',
id: props.id,
localId: props.localId,
createdAt: props.createdAt,
text: props.text,
source: props.source,
meta: props.meta
}
}
function mergeCliOutputBlocks(blocks: ChatBlock[]): ChatBlock[] {
const merged: ChatBlock[] = []
for (const block of blocks) {
if (block.kind !== 'cli-output') {
merged.push(block)
continue
}
const prev = merged[merged.length - 1]
if (
prev
&& prev.kind === 'cli-output'
&& prev.source === block.source
&& hasCommandNameTag(prev.text)
&& !hasLocalCommandStdoutTag(prev.text)
&& hasLocalCommandStdoutTag(block.text)
) {
const separator = prev.text.endsWith('\n') || block.text.startsWith('\n') ? '' : '\n'
merged[merged.length - 1] = { ...prev, text: `${prev.text}${separator}${block.text}` }
continue
}
merged.push(block)
}
return merged
}
function ensureToolBlock(
blocks: ChatBlock[],
toolBlocksById: Map<string, ToolCallBlock>,
@@ -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 = {
+11 -1
View File
@@ -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
@@ -32,8 +32,8 @@ export function HappyThread(props: {
onRetryMessage: props.onRetryMessage
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="min-h-0 flex-1 overflow-y-auto" autoScroll={false}>
<div className="mx-auto w-full max-w-[720px] p-3">
<ThreadPrimitive.Viewport className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden" autoScroll={false}>
<div className="mx-auto w-full max-w-[720px] min-w-0 p-3">
{props.header}
<div className="flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
@@ -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<HappyChatMessageMetadata> | undefined
return custom?.kind === 'cli-output'
})
const cliText = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | 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 (
<MessagePrimitive.Root className="px-1 min-w-0 max-w-full overflow-x-hidden">
<CliOutputBlock text={cliText} />
</MessagePrimitive.Root>
)
}
return (
<MessagePrimitive.Root className={rootClass}>
@@ -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<string, unknown> {
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 (
<div key={`cli:${block.id}`} className="px-1 min-w-0 max-w-full overflow-x-hidden">
<div className={alignClass}>
<CliOutputBlock text={block.text} />
</div>
</div>
)
}
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 (
<div className="py-1">
<div className="py-1 min-w-0 max-w-full overflow-x-hidden">
<div className="rounded-xl bg-[var(--app-secondary-bg)] p-3 shadow-sm">
<div className="flex items-center gap-2 text-xs">
<div className="font-mono text-[var(--app-hint)]">
@@ -174,7 +186,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) {
const isTask = block.tool.name === 'Task'
return (
<div className="py-1">
<div className="py-1 min-w-0 max-w-full overflow-x-hidden">
<ToolCard
api={ctx.api}
sessionId={ctx.sessionId}
@@ -3,6 +3,7 @@ import { LazyRainbowText } from '@/components/LazyRainbowText'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator'
import { CliOutputBlock } from '@/components/CliOutputBlock'
export function HappyUserMessage() {
const ctx = useHappyChatContext()
@@ -21,6 +22,15 @@ export function HappyUserMessage() {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.localId ?? null
})
const isCliOutput = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.kind === 'cli-output'
})
const cliText = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | 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 (
<MessagePrimitive.Root className="px-1 min-w-0 max-w-full overflow-x-hidden">
<div className="ml-auto w-full max-w-[92%]">
<CliOutputBlock text={cliText} />
</div>
</MessagePrimitive.Root>
)
}
return (
<MessagePrimitive.Root className={userBubbleClass}>
<div className="flex items-end gap-2">
+146
View File
@@ -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 = /<br\s*\/?>/gi
const LABELS: Record<string, string> = {
'command-name': 'Command',
'command-message': 'Command message',
'command-args': 'Command args',
'local-command-stdout': 'Stdout',
'local-command-stderr': 'Stderr',
}
const COMMAND_NAME_REGEX = /<command-name>([\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 (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function CliIcon() {
return (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
<path d="M3 4.5l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 10.5h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export function CliOutputBlock(props: { text: string }) {
const content = useMemo(() => buildCliOutput(props.text), [props.text])
const commandName = useMemo(() => extractCommandName(props.text), [props.text])
return (
<Card className="min-w-0 max-w-full overflow-hidden shadow-sm">
<CardHeader className="p-3 space-y-0">
<Dialog>
<DialogTrigger asChild>
<button type="button" className="w-full text-left">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<div className="shrink-0 flex h-4 w-4 items-center justify-center text-[var(--app-hint)] leading-none">
<CliIcon />
</div>
<CardTitle className="min-w-0 text-sm font-medium leading-tight break-words">
{commandName ?? 'CLI output'}
</CardTitle>
</div>
<span className="text-[var(--app-hint)]">
<DetailsIcon />
</span>
</div>
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>CLI output</DialogTitle>
</DialogHeader>
<div className="mt-3 max-h-[75vh] overflow-auto">
<div className="min-w-0 max-w-full overflow-x-auto overflow-y-hidden">
<pre className="m-0 w-max min-w-full bg-[var(--app-code-bg)] p-2 text-xs font-mono">
{content}
</pre>
</div>
</div>
</DialogContent>
</Dialog>
</CardHeader>
</Card>
)
}
+5 -3
View File
@@ -72,7 +72,7 @@ export function CodeBlock(props: {
}
return (
<div className="relative overflow-hidden rounded-md bg-[var(--app-code-bg)]">
<div className="relative min-w-0 max-w-full">
{showCopyButton ? (
<button
type="button"
@@ -84,9 +84,11 @@ export function CodeBlock(props: {
</button>
) : null}
<pre className="shiki overflow-auto p-2 pr-8 text-xs font-mono">
<code>{highlighted ?? props.code}</code>
<div className="min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden rounded-md bg-[var(--app-code-bg)]">
<pre className="shiki m-0 w-max min-w-full p-2 pr-8 text-xs font-mono">
<code className="block">{highlighted ?? props.code}</code>
</pre>
</div>
</div>
)
}
+2 -1
View File
@@ -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<HTMLDivElement>(null)
const [hasBeenVisible, setHasBeenVisible] = useState(false)
+1 -1
View File
@@ -19,7 +19,7 @@ function MarkdownContent(props: MarkdownRendererProps) {
<MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS}
components={mergedComponents}
className={cn('aui-md text-sm')}
className={cn('aui-md min-w-0 max-w-full break-words text-sm')}
/>
</TextMessagePartProvider>
)
@@ -95,14 +95,18 @@ function CodeHeader(props: CodeHeaderProps) {
}
function Pre(props: ComponentPropsWithoutRef<'pre'>) {
const { className, ...rest } = props
return (
<div className="aui-md-pre-wrapper min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden">
<pre
{...props}
{...rest}
className={cn(
'aui-md-pre overflow-auto rounded-b-md rounded-t-none bg-[var(--app-code-bg)] p-2 text-xs',
props.className
'aui-md-pre m-0 w-max min-w-full rounded-b-md rounded-t-none bg-[var(--app-code-bg)] p-2 text-xs',
className
)}
/>
</div>
)
}
@@ -122,7 +126,7 @@ function Code(props: ComponentPropsWithoutRef<'code'>) {
<code
{...props}
className={cn(
'aui-md-code rounded bg-[var(--app-inline-code-bg)] px-[0.3em] py-[0.1em] font-mono text-[0.9em]',
'aui-md-code break-words rounded bg-[var(--app-inline-code-bg)] px-[0.3em] py-[0.1em] font-mono text-[0.9em]',
props.className
)}
/>
@@ -174,7 +178,13 @@ function Hr(props: ComponentPropsWithoutRef<'hr'>) {
}
function Table(props: ComponentPropsWithoutRef<'table'>) {
return <table {...props} className={cn('aui-md-table w-full border-collapse', props.className)} />
const { className, ...rest } = props
return (
<div className="aui-md-table-wrapper max-w-full overflow-x-auto">
<table {...rest} className={cn('aui-md-table w-full border-collapse', className)} />
</div>
)
}
function Thead(props: ComponentPropsWithoutRef<'thead'>) {
@@ -275,7 +285,7 @@ export function MarkdownText() {
<MarkdownTextPrimitive
remarkPlugins={MARKDOWN_PLUGINS}
components={defaultComponents}
className={cn('aui-md text-sm')}
className={cn('aui-md min-w-0 max-w-full break-words text-sm')}
/>
)
}
@@ -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, '')
}
@@ -5,9 +5,9 @@ export function SyntaxHighlighter(props: SyntaxHighlighterProps) {
const highlighted = useShikiHighlighter(props.code, props.language)
return (
<div className="aui-md-codeblock overflow-hidden rounded-b-md bg-[var(--app-code-bg)]">
<pre className="shiki overflow-auto p-2 text-xs font-mono">
<code>{highlighted ?? props.code}</code>
<div className="aui-md-codeblock min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden rounded-b-md bg-[var(--app-code-bg)]">
<pre className="shiki m-0 w-max min-w-full p-2 text-xs font-mono">
<code className="block">{highlighted ?? props.code}</code>
</pre>
</div>
)
-2
View File
@@ -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;
}
+16 -2
View File
@@ -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)