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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user