import { useMemo } from 'react'
import { stripAnsiAndControls } from '@/components/assistant-ui/markdown-utils'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { CodeBlock } from '@/components/CodeBlock'
import { useTranslation } from '@/lib/use-translation'
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': 'terminal.commandName',
'command-message': 'terminal.commandMessage',
'command-args': 'terminal.commandArgs',
'local-command-stdout': 'terminal.stdout',
'local-command-stderr': 'terminal.stderr',
}
const COMMAND_NAME_REGEX = /([\s\S]*?)<\/command-name>/i
const PREVIEW_LINE_THRESHOLD = 14
const PREVIEW_CHAR_THRESHOLD = 1600
const PREVIEW_MAX_HEIGHT = 220
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, t?: (key: string) => string): string {
const normalized = tag.toLowerCase()
if (LABELS[normalized]) {
return t ? t(LABELS[normalized]) : LABELS[normalized]
}
return normalized.replace(/-/g, ' ')
}
function buildCliOutput(text: string, t?: (key: string) => 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, t)
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 shouldCollapsePreview(text: string): boolean {
if (text.length > PREVIEW_CHAR_THRESHOLD) return true
return text.split('\n').length > PREVIEW_LINE_THRESHOLD
}
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 { t } = useTranslation()
const content = useMemo(() => buildCliOutput(props.text, t), [props.text, t])
const commandName = useMemo(() => extractCommandName(props.text), [props.text])
const isCollapsedPreview = useMemo(() => shouldCollapsePreview(content), [content])
const title = commandName ?? t('terminal.commandName')
return (
)
}