fix(tui): reduce flicker with post-highlight clipping in live previews (#1423)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-18 10:17:35 -07:00
committed by GitHub
parent 10138d20d5
commit 8386268d43
6 changed files with 122 additions and 10 deletions

View File

@@ -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);
}

View File

@@ -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 */}
<Box paddingLeft={2} flexDirection="column">
<SyntaxHighlightedCommand command={bashInfo.command} />
<SyntaxHighlightedCommand
command={bashInfo.command}
maxLines={BASH_PREVIEW_MAX_LINES}
maxColumns={Math.max(10, columns - 2)}
showTruncationHint
/>
{bashInfo.description && (
<Box marginTop={1}>
<Text dimColor>{bashInfo.description}</Text>
@@ -161,7 +167,7 @@ export const InlineBashApproval = memo(
</Box>
</>
),
[bashInfo.command, bashInfo.description, solidLine],
[bashInfo.command, bashInfo.description, solidLine, columns],
);
// Hint text based on state

View File

@@ -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)}
</Text>
</Box>
{/* 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)}
</Text>
))}
{/* Hidden count + elapsed time */}

View File

@@ -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<string, string> = {
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 (
<Box flexDirection="column">
{lines.map((spans, lineIdx) => {
{renderedLines.map((spans, lineIdx) => {
const lineKey = spans.map((s) => s.text).join("");
return (
<Box key={`${lineIdx}:${lineKey}`}>
@@ -339,11 +409,17 @@ export const SyntaxHighlightedCommand = memo(
{span.text}
</Text>
))}
{lineIdx === lines.length - 1 && suffix ? suffix : null}
{lineIdx === renderedLines.length - 1 && suffix ? suffix : null}
</Text>
</Box>
);
})}
{showTruncationHint && hiddenLineCount > 0 && (
<Text dimColor>{`… +${hiddenLineCount} more lines`}</Text>
)}
{showTruncationHint && hiddenLineCount === 0 && anyColumnClipping && (
<Text dimColor> output clipped</Text>
)}
</Box>
);
},

View File

@@ -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,
)}
/>
</Box>
) : args ? (

View File

@@ -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 */}
<Box paddingLeft={2} flexDirection="column">
<SyntaxHighlightedCommand command={command} />
<SyntaxHighlightedCommand
command={command}
maxLines={BASH_PREVIEW_MAX_LINES}
maxColumns={Math.max(10, columns - 2)}
showTruncationHint
/>
{description && <Text dimColor>{description}</Text>}
</Box>
</>