mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -171,6 +171,7 @@ export default {
|
||||
|
||||
// Code block
|
||||
'code.copy': '复制',
|
||||
'code.truncated': '预览已截断 — 打开详情查看完整输出',
|
||||
|
||||
// Diff view
|
||||
'diff.title': '差异',
|
||||
|
||||
Reference in New Issue
Block a user