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 780809bd69c..7a2ba3c7394 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" import { Filesystem } from "@/util/filesystem" @@ -1377,7 +1378,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") +}