feat(web): collapse long code and terminal output previews (#546)

* feat(web): collapse long code and terminal output previews

* fix(web): keep fallback tool output complete

* fix(web): keep raw json details uncollapsed
This commit is contained in:
CoColate
2026-04-29 09:19:57 +08:00
committed by GitHub
parent 7c954ad901
commit 9c117679f0
7 changed files with 90 additions and 30 deletions
+27 -1
View File
@@ -17,6 +17,9 @@ const LABELS: Record<string, string> = {
'local-command-stderr': 'terminal.stderr',
}
const COMMAND_NAME_REGEX = /<command-name>([\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)
@@ -76,6 +79,11 @@ function buildCliOutput(text: string, t?: (key: string) => string): string {
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
@@ -105,6 +113,7 @@ 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])
return (
<Card className="min-w-0 max-w-full overflow-hidden shadow-sm">
@@ -112,7 +121,7 @@ export function CliOutputBlock(props: { text: string }) {
<Dialog>
<DialogTrigger asChild>
<button type="button" className="w-full text-left">
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2">
<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">
@@ -126,6 +135,23 @@ export function CliOutputBlock(props: { text: string }) {
<DetailsIcon />
</span>
</div>
<div className="relative min-w-0 max-w-full overflow-hidden rounded-md bg-[var(--app-code-bg)]">
<div
className="min-w-0 max-w-full overflow-x-auto"
style={isCollapsedPreview ? { maxHeight: PREVIEW_MAX_HEIGHT, overflowY: 'hidden' } : { overflowY: 'hidden' }}
>
<pre className="m-0 w-max min-w-full p-2 text-xs font-mono">
{content}
</pre>
</div>
{isCollapsedPreview ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-[var(--app-code-bg)] via-[var(--app-code-bg)]/90 to-transparent px-2 pb-2 pt-10">
<span className="rounded-full border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-0.5 text-[10px] text-[var(--app-hint)] shadow-sm">
{t('code.truncated')}
</span>
</div>
) : null}
</div>
</div>
</button>
</DialogTrigger>
+30 -1
View File
@@ -3,15 +3,34 @@ import { useShikiHighlighter } from '@/lib/shiki'
import { CopyIcon, CheckIcon } from '@/components/icons'
import { useTranslation } from '@/lib/use-translation'
const DEFAULT_COLLAPSE_LINE_THRESHOLD = 18
const DEFAULT_COLLAPSE_CHAR_THRESHOLD = 1800
const DEFAULT_COLLAPSED_HEIGHT = 260
function shouldCollapseCode(code: string, lineThreshold: number, charThreshold: number): boolean {
if (code.length > charThreshold) return true
return code.split('\n').length > lineThreshold
}
export function CodeBlock(props: {
code: string
language?: string
showCopyButton?: boolean
collapseLongContent?: boolean
collapsedHeight?: number
collapseLineThreshold?: number
collapseCharThreshold?: number
}) {
const { t } = useTranslation()
const showCopyButton = props.showCopyButton ?? true
const { copied, copy } = useCopyToClipboard()
const highlighted = useShikiHighlighter(props.code, props.language)
const isCollapsed = Boolean(props.collapseLongContent) && shouldCollapseCode(
props.code,
props.collapseLineThreshold ?? DEFAULT_COLLAPSE_LINE_THRESHOLD,
props.collapseCharThreshold ?? DEFAULT_COLLAPSE_CHAR_THRESHOLD
)
const collapsedHeight = props.collapsedHeight ?? DEFAULT_COLLAPSED_HEIGHT
return (
<div className="relative min-w-0 max-w-full">
@@ -26,11 +45,21 @@ export function CodeBlock(props: {
</button>
) : null}
<div className="min-w-0 w-full max-w-full overflow-x-auto overflow-y-hidden rounded-md bg-[var(--app-code-bg)]">
<div
className="min-w-0 w-full max-w-full overflow-x-auto rounded-md bg-[var(--app-code-bg)]"
style={isCollapsed ? { maxHeight: collapsedHeight, overflowY: 'hidden' } : { overflowY: 'hidden' }}
>
<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>
{isCollapsed ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center rounded-b-md bg-gradient-to-t from-[var(--app-code-bg)] via-[var(--app-code-bg)]/90 to-transparent px-2 pb-2 pt-10">
<span className="rounded-full border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-0.5 text-[10px] text-[var(--app-hint)] shadow-sm">
{t('code.truncated')}
</span>
</div>
) : null}
</div>
)
}
+12 -11
View File
@@ -114,7 +114,8 @@ function renderExitPlanModeInput(input: unknown): ReactNode | null {
return <MarkdownRenderer content={plan} />
}
function renderToolInput(block: ToolCallBlock): ReactNode {
function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'inline'): ReactNode {
const collapseLongContent = surface === 'inline'
const toolName = block.tool.name
const input = block.tool.input
@@ -170,14 +171,14 @@ function renderToolInput(block: ToolCallBlock): ReactNode {
<div className="text-xs text-[var(--app-hint)] font-mono break-all">
{filePath}
</div>
<CodeBlock code={content} language="text" />
<CodeBlock code={content} language="text" collapseLongContent={collapseLongContent} />
</div>
)
}
}
if (toolName === 'CodexDiff' && isObject(input) && typeof input.unified_diff === 'string') {
return <CodeBlock code={input.unified_diff} language="diff" />
return <CodeBlock code={input.unified_diff} language="diff" collapseLongContent={collapseLongContent} />
}
if (toolName === 'ExitPlanMode' || toolName === 'exit_plan_mode') {
@@ -191,11 +192,11 @@ function renderToolInput(block: ToolCallBlock): ReactNode {
? commandArray.filter((part) => typeof part === 'string').join(' ')
: getInputStringAny(input, ['command', 'cmd'])
if (cmd) {
return <CodeBlock code={cmd} language="bash" />
return <CodeBlock code={cmd} language="bash" collapseLongContent={collapseLongContent} />
}
}
return <CodeBlock code={safeStringify(input)} language="json" />
return <CodeBlock code={safeStringify(input)} language="json" collapseLongContent={collapseLongContent} />
}
function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) {
@@ -359,16 +360,16 @@ function ToolCardInner(props: ToolCardProps) {
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} />
<FullToolView block={props.block} metadata={props.metadata} surface="dialog" />
) : (
renderToolInput(props.block)
renderToolInput(props.block, 'dialog')
)}
</div>
<TraceSection block={props.block} metadata={props.metadata} />
{!isQuestionToolWithAnswers && (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} />
<ResultToolView block={props.block} metadata={props.metadata} surface="dialog" />
</div>
)}
</div>
@@ -389,17 +390,17 @@ function ToolCardInner(props: ToolCardProps) {
{showInline ? (
CompactToolView ? (
<div className="mt-3">
<CompactToolView block={props.block} metadata={props.metadata} />
<CompactToolView block={props.block} metadata={props.metadata} surface="inline" />
</div>
) : (
<div className="mt-3 flex flex-col gap-3">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.input')}</div>
{renderToolInput(props.block)}
{renderToolInput(props.block, 'inline')}
</div>
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} />
<ResultToolView block={props.block} metadata={props.metadata} surface="inline" />
</div>
</div>
)
@@ -17,6 +17,7 @@ import { getInputStringAny } from '@/lib/toolInputUtils'
export type ToolViewProps = {
block: ToolCallBlock
metadata: SessionMetadataSummary | null
surface?: 'inline' | 'dialog'
}
export type ToolViewComponent = ComponentType<ToolViewProps>
+18 -17
View File
@@ -122,9 +122,9 @@ function looksLikeJson(text: string): boolean {
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
}
function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; language?: string } = { mode: 'auto' }) {
function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; language?: string; collapseLongContent?: boolean } = { mode: 'auto' }) {
if (opts.mode === 'code') {
return <CodeBlock code={text} language={opts.language ?? 'text'} />
return <CodeBlock code={text} language={opts.language ?? 'text'} collapseLongContent={opts.collapseLongContent} />
}
if (opts.mode === 'markdown') {
@@ -132,7 +132,7 @@ function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; la
}
if (looksLikeHtml(text) || looksLikeJson(text)) {
return <CodeBlock code={text} language={looksLikeJson(text) ? 'json' : 'html'} />
return <CodeBlock code={text} language={looksLikeJson(text) ? 'json' : 'html'} collapseLongContent={opts.collapseLongContent} />
}
return <MarkdownRenderer content={text} />
@@ -235,7 +235,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result
return (
<>
<CodeBlock code={display} language="text" />
<CodeBlock code={display} language="text" collapseLongContent={props.surface === 'inline'} />
<RawJsonDevOnly value={result} />
</>
)
@@ -246,8 +246,8 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
<div className="flex flex-col gap-2">
{stdio.stdout ? <CodeBlock code={stdio.stdout} language="text" /> : null}
{stdio.stderr ? <CodeBlock code={stdio.stderr} language="text" /> : null}
{stdio.stdout ? <CodeBlock code={stdio.stdout} language="text" collapseLongContent={props.surface === 'inline'} /> : null}
{stdio.stderr ? <CodeBlock code={stdio.stderr} language="text" collapseLongContent={props.surface === 'inline'} /> : null}
</div>
<RawJsonDevOnly value={result} />
</>
@@ -258,7 +258,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'code', language: 'text' })}
{renderText(text, { mode: 'code', language: 'text', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -272,6 +272,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => {
)
}
const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => {
const result = props.block.tool.result
@@ -283,7 +284,7 @@ const MarkdownResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'auto' })}
{renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -364,7 +365,7 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
{basename(path)}
</div>
) : null}
<CodeBlock code={file.content} language="text" />
<CodeBlock code={file.content} language="text" collapseLongContent={props.surface === 'inline'} />
<RawJsonDevOnly value={result} />
</>
)
@@ -374,7 +375,7 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'code', language: 'text' })}
{renderText(text, { mode: 'code', language: 'text', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -405,7 +406,7 @@ const MutationResultView: ToolViewComponent = (props: ToolViewProps) => {
return (
<>
<div className={`text-sm ${className}`}>
{renderText(text, { mode, language })}
{renderText(text, { mode, language, collapseLongContent: props.surface === 'inline' })}
</div>
<RawJsonDevOnly value={result} />
</>
@@ -428,7 +429,7 @@ const CodexPatchResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'auto' })}
{renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -458,7 +459,7 @@ const CodexReasoningResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'auto' })}
{renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -484,7 +485,7 @@ const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'code', language: 'diff' })}
{renderText(text, { mode: 'code', language: 'diff', collapseLongContent: props.surface === 'inline' })}
<RawJsonDevOnly value={result} />
</>
)
@@ -605,17 +606,17 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => {
if (text) {
return (
<>
{renderText(text, { mode: 'auto' })}
{renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })}
{typeof result === 'object' ? <RawJsonDevOnly value={result} /> : null}
</>
)
}
if (typeof result === 'string') {
return renderText(result, { mode: 'auto' })
return renderText(result, { mode: 'auto', collapseLongContent: props.surface === 'inline' })
}
return <CodeBlock code={safeStringify(result)} language="json" />
return <CodeBlock code={safeStringify(result)} language="json" collapseLongContent={props.surface === 'inline'} />
}
export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
+1
View File
@@ -169,6 +169,7 @@ export default {
// Code block
'code.copy': 'Copy',
'code.truncated': 'Preview truncated — open details for full output',
// Diff view
'diff.title': 'Diff',
+1
View File
@@ -171,6 +171,7 @@ export default {
// Code block
'code.copy': '复制',
'code.truncated': '预览已截断 — 打开详情查看完整输出',
// Diff view
'diff.title': '差异',