From 8386268d43852eff8ba5254c1ef8479a168a0231 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 18 Mar 2026 10:17:35 -0700 Subject: [PATCH] fix(tui): reduce flicker with post-highlight clipping in live previews (#1423) Co-authored-by: Letta Code --- src/cli/App.tsx | 6 +- src/cli/components/InlineBashApproval.tsx | 10 ++- src/cli/components/StreamingOutputDisplay.tsx | 17 +++- .../components/SyntaxHighlightedCommand.tsx | 84 ++++++++++++++++++- src/cli/components/ToolCallMessageRich.tsx | 7 ++ src/cli/components/previews/BashPreview.tsx | 8 +- 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index b0bc42f..da581e5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -584,6 +584,7 @@ const APPROVAL_PREVIEW_BUFFER = 4; const MIN_WRAP_WIDTH = 10; const TEXT_WRAP_GUTTER = 6; const DIFF_WRAP_GUTTER = 12; +const SHELL_PREVIEW_MAX_LINES = 3; function countWrappedLines(text: string, width: number): number { if (!text) return 0; @@ -2611,7 +2612,10 @@ export default function App({ } let lines = 3; // solid line + header + blank line - lines += countWrappedLines(command, wrapWidth); + lines += Math.min( + countWrappedLines(command, wrapWidth), + SHELL_PREVIEW_MAX_LINES, + ); if (description) { lines += countWrappedLines(description, wrapWidth); } diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx index 84c0b7d..bb85bd5 100644 --- a/src/cli/components/InlineBashApproval.tsx +++ b/src/cli/components/InlineBashApproval.tsx @@ -28,6 +28,7 @@ type Props = { // Horizontal line character for Claude Code style const SOLID_LINE = "─"; +const BASH_PREVIEW_MAX_LINES = 3; /** * InlineBashApproval - Renders bash/shell approval UI inline (Claude Code style) @@ -152,7 +153,12 @@ export const InlineBashApproval = memo( {/* Command preview */} - + {bashInfo.description && ( {bashInfo.description} @@ -161,7 +167,7 @@ export const InlineBashApproval = memo( ), - [bashInfo.command, bashInfo.description, solidLine], + [bashInfo.command, bashInfo.description, solidLine, columns], ); // Hint text based on state diff --git a/src/cli/components/StreamingOutputDisplay.tsx b/src/cli/components/StreamingOutputDisplay.tsx index a8427fe..891982a 100644 --- a/src/cli/components/StreamingOutputDisplay.tsx +++ b/src/cli/components/StreamingOutputDisplay.tsx @@ -1,6 +1,7 @@ import { Box } from "ink"; import { memo, useEffect, useState } from "react"; import type { StreamingState } from "../helpers/accumulator"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { Text } from "./Text"; interface StreamingOutputDisplayProps { @@ -15,6 +16,7 @@ interface StreamingOutputDisplayProps { */ export const StreamingOutputDisplay = memo( ({ streaming, showInterruptHint }: StreamingOutputDisplayProps) => { + const columns = useTerminalWidth(); // Force re-render every second for elapsed timer const [, forceUpdate] = useState(0); useEffect(() => { @@ -25,6 +27,17 @@ export const StreamingOutputDisplay = memo( const elapsed = Math.floor((Date.now() - streaming.startTime) / 1000); const { tailLines, totalLineCount } = streaming; const hiddenCount = Math.max(0, totalLineCount - tailLines.length); + const contentWidth = Math.max(10, columns - 5); + + const clipToWidth = (text: string): string => { + if (text.length <= contentWidth) { + return text; + } + if (contentWidth <= 1) { + return "…"; + } + return `${text.slice(0, contentWidth - 1)}…`; + }; const firstLine = tailLines[0]; const interruptHint = showInterruptHint ? " (esc to interrupt)" : ""; @@ -47,7 +60,7 @@ export const StreamingOutputDisplay = memo( dimColor={!firstLine.isStderr} color={firstLine.isStderr ? "red" : undefined} > - {firstLine.text} + {clipToWidth(firstLine.text)} {/* Remaining lines with indent (5 spaces to align with content after bracket) */} @@ -59,7 +72,7 @@ export const StreamingOutputDisplay = memo( color={line.isStderr ? "red" : undefined} > {" "} - {line.text} + {clipToWidth(line.text)} ))} {/* Hidden count + elapsed time */} diff --git a/src/cli/components/SyntaxHighlightedCommand.tsx b/src/cli/components/SyntaxHighlightedCommand.tsx index 85e883f..5ed9bb6 100644 --- a/src/cli/components/SyntaxHighlightedCommand.tsx +++ b/src/cli/components/SyntaxHighlightedCommand.tsx @@ -14,6 +14,9 @@ type Props = { showPrompt?: boolean; prefix?: string; suffix?: string; + maxLines?: number; + maxColumns?: number; + showTruncationHint?: boolean; }; type ShellSyntaxPalette = typeof colors.shellSyntax; @@ -21,6 +24,39 @@ type ShellSyntaxPalette = typeof colors.shellSyntax; /** Styled text span with a resolved color. */ export type StyledSpan = { text: string; color: string }; +type ClippedSpans = { + spans: StyledSpan[]; + clipped: boolean; +}; + +function clipStyledSpans( + spans: StyledSpan[], + maxColumns: number, +): ClippedSpans { + if (maxColumns <= 0) { + return { spans: [], clipped: spans.length > 0 }; + } + + let remaining = maxColumns; + const clipped: StyledSpan[] = []; + + for (const span of spans) { + if (remaining <= 0) { + return { spans: clipped, clipped: true }; + } + if (span.text.length <= remaining) { + clipped.push(span); + remaining -= span.text.length; + continue; + } + + clipped.push({ text: span.text.slice(0, remaining), color: span.color }); + return { spans: clipped, clipped: true }; + } + + return { spans: clipped, clipped: false }; +} + /** Map file extension to a lowlight language name. */ const EXT_TO_LANG: Record = { ts: "typescript", @@ -317,13 +353,47 @@ export function highlightCode( } export const SyntaxHighlightedCommand = memo( - ({ command, showPrompt = true, prefix, suffix }: Props) => { + ({ + command, + showPrompt = true, + prefix, + suffix, + maxLines, + maxColumns, + showTruncationHint = false, + }: Props) => { const palette = colors.shellSyntax; - const lines = highlightCommand(command, palette); + const highlightedLines = highlightCommand(command, palette); + + const hasLineCap = typeof maxLines === "number" && maxLines >= 0; + const visibleLines = hasLineCap + ? highlightedLines.slice(0, maxLines) + : highlightedLines; + const hiddenLineCount = Math.max( + 0, + highlightedLines.length - visibleLines.length, + ); + + const renderedLines: StyledSpan[][] = []; + let anyColumnClipping = false; + for (let i = 0; i < visibleLines.length; i++) { + const spans = visibleLines[i] ?? []; + if (typeof maxColumns === "number") { + const prefixLen = i === 0 && prefix ? prefix.length : 0; + const suffixLen = + i === visibleLines.length - 1 && suffix ? suffix.length : 0; + const textBudget = Math.max(0, maxColumns - prefixLen - suffixLen); + const clipped = clipStyledSpans(spans, textBudget); + renderedLines.push(clipped.spans); + anyColumnClipping = anyColumnClipping || clipped.clipped; + } else { + renderedLines.push(spans); + } + } return ( - {lines.map((spans, lineIdx) => { + {renderedLines.map((spans, lineIdx) => { const lineKey = spans.map((s) => s.text).join(""); return ( @@ -339,11 +409,17 @@ export const SyntaxHighlightedCommand = memo( {span.text} ))} - {lineIdx === lines.length - 1 && suffix ? suffix : null} + {lineIdx === renderedLines.length - 1 && suffix ? suffix : null} ); })} + {showTruncationHint && hiddenLineCount > 0 && ( + {`… +${hiddenLineCount} more lines`} + )} + {showTruncationHint && hiddenLineCount === 0 && anyColumnClipping && ( + … output clipped + )} ); }, diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 3db47a8..cf17e45 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -102,6 +102,8 @@ import { StreamingOutputDisplay } from "./StreamingOutputDisplay"; import { SyntaxHighlightedCommand } from "./SyntaxHighlightedCommand"; import { TodoRenderer } from "./TodoRenderer.js"; +const LIVE_SHELL_ARGS_MAX_LINES = 2; + type ToolCallLine = { kind: "tool_call"; id: string; @@ -932,6 +934,11 @@ export const ToolCallMessage = memo( showPrompt={false} prefix="(" suffix=")" + maxLines={LIVE_SHELL_ARGS_MAX_LINES} + maxColumns={Math.max( + 10, + rightWidth - displayName.length, + )} /> ) : args ? ( diff --git a/src/cli/components/previews/BashPreview.tsx b/src/cli/components/previews/BashPreview.tsx index d1d84cc..f9c0edb 100644 --- a/src/cli/components/previews/BashPreview.tsx +++ b/src/cli/components/previews/BashPreview.tsx @@ -6,6 +6,7 @@ import { SyntaxHighlightedCommand } from "../SyntaxHighlightedCommand"; import { Text } from "../Text"; const SOLID_LINE = "─"; +const BASH_PREVIEW_MAX_LINES = 3; type Props = { command: string; @@ -37,7 +38,12 @@ export const BashPreview = memo(({ command, description }: Props) => { {/* Command preview */} - + {description && {description}}