mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</pre>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<pre
|
||||
{...props}
|
||||
className={cn(
|
||||
'aui-md-pre overflow-auto rounded-b-md rounded-t-none bg-[var(--app-code-bg)] p-2 text-xs',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
<div className="aui-md-pre-wrapper min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden">
|
||||
<pre
|
||||
{...rest}
|
||||
className={cn(
|
||||
'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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user