From 9c117679f004dd2b9b71b66f2b42372b5fa24494 Mon Sep 17 00:00:00 2001 From: CoColate Date: Wed, 29 Apr 2026 09:19:57 +0800 Subject: [PATCH] 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 --- web/src/components/CliOutputBlock.tsx | 28 ++++++++++++++- web/src/components/CodeBlock.tsx | 31 +++++++++++++++- web/src/components/ToolCard/ToolCard.tsx | 23 ++++++------ web/src/components/ToolCard/views/_all.tsx | 1 + .../components/ToolCard/views/_results.tsx | 35 ++++++++++--------- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/web/src/components/CliOutputBlock.tsx b/web/src/components/CliOutputBlock.tsx index 939696cc..2311eb97 100644 --- a/web/src/components/CliOutputBlock.tsx +++ b/web/src/components/CliOutputBlock.tsx @@ -17,6 +17,9 @@ const LABELS: Record = { '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) @@ -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 ( @@ -112,7 +121,7 @@ export function CliOutputBlock(props: { text: string }) { diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index e979d867..710b0025 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -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 (
@@ -26,11 +45,21 @@ export function CodeBlock(props: { ) : null} -
+
                     {highlighted ?? props.code}
                 
+ {isCollapsed ? ( +
+ + {t('code.truncated')} + +
+ ) : null}
) } diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 73947e3a..112c8c6c 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -114,7 +114,8 @@ function renderExitPlanModeInput(input: unknown): ReactNode | null { return } -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 {
{filePath}
- +
) } } if (toolName === 'CodexDiff' && isObject(input) && typeof input.unified_diff === 'string') { - return + return } 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 + return } } - return + return } function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { @@ -359,16 +360,16 @@ function ToolCardInner(props: ToolCardProps) { {isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')} {FullToolView ? ( - + ) : ( - renderToolInput(props.block) + renderToolInput(props.block, 'dialog') )} {!isQuestionToolWithAnswers && (
{t('tool.result')}
- +
)} @@ -389,17 +390,17 @@ function ToolCardInner(props: ToolCardProps) { {showInline ? ( CompactToolView ? (
- +
) : (
{t('tool.input')}
- {renderToolInput(props.block)} + {renderToolInput(props.block, 'inline')}
{t('tool.result')}
- +
) diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index 784649ed..f4fd73d2 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -17,6 +17,7 @@ import { getInputStringAny } from '@/lib/toolInputUtils' export type ToolViewProps = { block: ToolCallBlock metadata: SessionMetadataSummary | null + surface?: 'inline' | 'dialog' } export type ToolViewComponent = ComponentType diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index 26d7b15a..889d50ca 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -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 + return } if (opts.mode === 'markdown') { @@ -132,7 +132,7 @@ function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; la } if (looksLikeHtml(text) || looksLikeJson(text)) { - return + return } return @@ -235,7 +235,7 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => { const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result return ( <> - + ) @@ -246,8 +246,8 @@ const BashResultView: ToolViewComponent = (props: ToolViewProps) => { return ( <>
- {stdio.stdout ? : null} - {stdio.stderr ? : null} + {stdio.stdout ? : null} + {stdio.stderr ? : null}
@@ -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' })} ) @@ -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' })} ) @@ -364,7 +365,7 @@ const ReadResultView: ToolViewComponent = (props: ToolViewProps) => { {basename(path)} ) : null} - + ) @@ -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' })} ) @@ -405,7 +406,7 @@ const MutationResultView: ToolViewComponent = (props: ToolViewProps) => { return ( <>
- {renderText(text, { mode, language })} + {renderText(text, { mode, language, collapseLongContent: props.surface === 'inline' })}
@@ -428,7 +429,7 @@ const CodexPatchResultView: ToolViewComponent = (props: ToolViewProps) => { if (text) { return ( <> - {renderText(text, { mode: 'auto' })} + {renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })} ) @@ -458,7 +459,7 @@ const CodexReasoningResultView: ToolViewComponent = (props: ToolViewProps) => { if (text) { return ( <> - {renderText(text, { mode: 'auto' })} + {renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline' })} ) @@ -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' })} ) @@ -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' ? : null} ) } if (typeof result === 'string') { - return renderText(result, { mode: 'auto' }) + return renderText(result, { mode: 'auto', collapseLongContent: props.surface === 'inline' }) } - return + return } export const toolResultViewRegistry: Record = { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 4443ad1a..88403d7e 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fb92ebe9..0d6fbc15 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -171,6 +171,7 @@ export default { // Code block 'code.copy': '复制', + 'code.truncated': '预览已截断 — 打开详情查看完整输出', // Diff view 'diff.title': '差异',