From 6d818d8ae4e8461088ca71661856615d16d99a26 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 13 Dec 2025 10:29:41 -0500 Subject: [PATCH] fix(tui): normalize animated terminal output in bash tool Collapse spinner frames, progress bars, and status indicator lines that share the same base text to show only the latest frame. This prevents the TUI from displaying hundreds of redundant spinner animation frames when running CLI tools like ora or clack. - Add normalizeTerminalOutput utility to process terminal output - Strip ANSI codes before normalization for reliable detection - Handle carriage returns, spinner prefixes, progress bars, and dots - Track base text across lines with animated prefixes to collapse them --- .../src/cli/cmd/tui/routes/session/index.tsx | 7 +- .../opencode/src/cli/cmd/tui/util/output.ts | 140 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/output.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1c1e4b65ec1..97b1b90347c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -62,6 +62,7 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" +import { normalizeTerminalOutput } from "@tui/util/output" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" @@ -1367,7 +1368,11 @@ ToolRegistry.register({ name: "bash", container: "block", render(props) { - const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) + const output = createMemo(() => { + const raw = props.metadata.output?.trim() ?? "" + const sanitized = stripAnsi(raw) + return normalizeTerminalOutput(sanitized) + }) const { theme } = useTheme() return ( <> diff --git a/packages/opencode/src/cli/cmd/tui/util/output.ts b/packages/opencode/src/cli/cmd/tui/util/output.ts new file mode 100644 index 00000000000..a861393e638 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/output.ts @@ -0,0 +1,140 @@ +// Spinner characters and status prefixes used by CLI tools (ora, clack, listr, etc.) +const ANIMATED_PREFIXES = [ + // Quarter-circle spinners (ora "dots" variants) + "◒", + "◐", + "◓", + "◑", + // Braille spinners (ora default) + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", + "⣾", + "⣽", + "⣻", + "⢿", + "⡿", + "⣟", + "⣯", + "⣷", + // ASCII spinners + "|", + "/", + "-", + "\\", + // Status indicators (clack, listr, etc.) - these prefix "in progress" lines + "○", + "◇", + "●", + "◆", + "■", + "□", + "▪", + "▫", + "►", + "▸", + "→", + "•", + "·", + "∙", + "⋅", + "★", + "☆", + "✓", + "✔", + "✗", + "✘", + "⧗", + "⧖", +] + +const ANIMATED_PREFIX_SET = new Set(ANIMATED_PREFIXES) + +const PROGRESS_BAR = /\[[=>#\-\s.]+\]/g +const PERCENT_SUFFIX = /\s+\d+(?:\.\d+)?%\s*$/ +const FRACTION_SUFFIX = /\s+\d+\s*\/\s*\d+\s*$/ +const DOT_SUFFIX = /(?:\.{1,}|…)+\s*$/ + +function stripCarriageReturn(line: string): string { + const idx = line.lastIndexOf("\r") + if (idx === -1) return line + return line.slice(idx + 1) +} + +function hasAnimatedPrefix(line: string): boolean { + const trimmed = line.trimStart() + for (const symbol of ANIMATED_PREFIX_SET) { + if (trimmed.startsWith(symbol)) { + return true + } + } + return false +} + +function stripAnimatedPrefix(line: string): string { + const trimmed = line.trimStart() + for (const symbol of ANIMATED_PREFIXES) { + if (trimmed.startsWith(symbol)) { + return trimmed.slice(symbol.length).trimStart() + } + } + return trimmed +} + +function normalizeBase(line: string): string { + const trimmed = line.trimEnd() + if (!trimmed) return "" + return stripAnimatedPrefix(trimmed) + .replace(PROGRESS_BAR, " ") + .replace(PERCENT_SUFFIX, " ") + .replace(FRACTION_SUFFIX, " ") + .replace(DOT_SUFFIX, " ") + .replace(/\s+/g, " ") + .trim() +} + +/** + * Normalizes terminal output by collapsing animated frames (spinners, progress bars, dots) + * and applying carriage-return semantics. + * + * Lines with animated prefixes (spinners, status indicators) that share the same + * base text are collapsed to show only the latest frame. + */ +export function normalizeTerminalOutput(text: string): string { + const lines = text.split("\n") + const result: string[] = [] + let prev = "" + let idx = -1 + + for (const raw of lines) { + const line = stripCarriageReturn(raw) + const animated = hasAnimatedPrefix(line) + const base = normalizeBase(line) + + // If this line has an animated prefix and matches the last tracked base, replace it + if (animated && base && prev === base && idx >= 0) { + result[idx] = line + continue + } + + result.push(line) + + // Track base text for lines with animated prefixes + if (!animated || !base) { + prev = "" + idx = -1 + continue + } + prev = base + idx = result.length - 1 + } + + return result.join("\n") +}