From 9b55c81e3d5cb1bc6a8e817ee5bc50adb0bed68c Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 27 Nov 2025 11:07:24 +0100 Subject: [PATCH 01/28] feat(tui): add bash output viewer with ANSI color support Adds an bash output viewer with full ANSI color support. When bash commands produce more than 20 lines of output, users can click to expand into a full-screen viewer with syntax highlighting and keyboard navigation. Key features: - Full ANSI/VT color rendering using opentui-ansi-vt terminal emulation - Truncated preview (20 lines) with 'Click to view full output' expansion - Prompt text preservation when entering/exiting viewer - Force color output in bash commands (FORCE_COLOR, CLICOLOR, TERM env vars) Technical changes: - Added opentui-ansi-vt dependency for terminal buffer rendering - Added initialValue prop to Prompt component to restore draft text - Strip ANSI codes for search to match actual text content --- bun.lock | 3 + packages/opencode/package.json | 1 + .../cli/cmd/tui/component/prompt/index.tsx | 9 + .../src/cli/cmd/tui/routes/session/index.tsx | 346 +++++++++++------- packages/opencode/src/tool/bash.ts | 14 + packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 7 files changed, 247 insertions(+), 130 deletions(-) diff --git a/bun.lock b/bun.lock index 9ea4c7de3023..31bff9d085ae 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,7 @@ "jsonc-parser": "3.3.1", "minimatch": "10.0.3", "open": "10.1.2", + "opentui-ansi-vt": "1.2.5", "opentui-spinner": "0.0.5", "partial-json": "0.1.7", "remeda": "catalog:", @@ -2969,6 +2970,8 @@ "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], + "opentui-ansi-vt": ["opentui-ansi-vt@1.2.5", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-9m2U3un7c5thRjFM2xNNAgbFbC0Zi53s2adj52lnZfR8u7q8P6KBRZ0dNVCuN/KxAsPgpFRuQXXmZXufMrSaZg=="], + "opentui-spinner": ["opentui-spinner@0.0.5", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-abSWzWA7eyuD0PjerAWbBznLmOQn+8xRDaLGCVIs4ctETi2laNFr5KwicYnPXsHZpPc2neV7WtQm+diCEfOhLA=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e0909c194602..f348caf119c0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,6 +82,7 @@ "jsonc-parser": "3.3.1", "minimatch": "10.0.3", "open": "10.1.2", + "opentui-ansi-vt": "1.2.5", "opentui-spinner": "0.0.5", "partial-json": "0.1.7", "remeda": "catalog:", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 06e9a49e676f..95b649ad336b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -30,10 +30,12 @@ export type PromptProps = { ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean + initialValue?: string } export type PromptRef = { focused: boolean + text: string set(prompt: PromptInfo): void reset(): void blur(): void @@ -277,6 +279,10 @@ export function Prompt(props: PromptProps) { onMount(() => { promptPartTypeId = input.extmarks.registerType("prompt-part") + if (props.initialValue) { + input.setText(props.initialValue) + setStore("prompt", "input", props.initialValue) + } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -361,6 +367,9 @@ export function Prompt(props: PromptProps) { get focused() { return input.focused }, + get text() { + return input.plainText + }, focus() { input.focus() }, 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 fdbcb34f9df9..2e034f51d8ef 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -19,6 +19,7 @@ import { SplitBorder } from "@tui/component/border" import { useTheme } from "@tui/context/theme" import { BoxRenderable, + InputRenderable, ScrollBoxRenderable, addDefaultParsers, MacOSScrollAccel, @@ -63,9 +64,13 @@ import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" +import { extend } from "@opentui/solid" +import { TerminalBufferRenderable } from "opentui-ansi-vt/terminal-buffer" addDefaultParsers(parsers.parsers) +extend({ "terminal-buffer": TerminalBufferRenderable }) + class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} @@ -76,12 +81,19 @@ class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } +type BashOutputView = { + command: string + output: () => string +} + const context = createContext<{ width: number conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean sync: ReturnType + bashOutput: () => BashOutputView | undefined + showBashOutput: (view: BashOutputView | undefined) => void }>() function use() { @@ -113,6 +125,8 @@ export function Session() { const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = createSignal(true) const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") + const [bashOutput, setBashOutput] = createSignal(undefined) + const [promptDraft, setPromptDraft] = createSignal("") const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -176,12 +190,35 @@ export function Session() { }) let scroll: ScrollBoxRenderable + let bashScroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() useKeyboard((evt) => { if (dialog.stack.length > 0) return + if (bashOutput()) { + const scroll = bashScroll + const amount = 3 + if (evt.name === "escape" || (evt.name === "c" && evt.ctrl)) { + setBashOutput(undefined) + evt.preventDefault() + } else if (evt.name === "up") { + scroll?.scrollBy(-amount) + evt.preventDefault() + } else if (evt.name === "down") { + scroll?.scrollBy(amount) + evt.preventDefault() + } else if (evt.name === "home") { + scroll?.scrollTo(0) + evt.preventDefault() + } else if (evt.name === "end") { + scroll?.scrollTo(scroll.scrollHeight) + evt.preventDefault() + } + return + } + const first = permissions()[0] if (first) { const response = iife(() => { @@ -740,6 +777,11 @@ export function Session() { showThinking, showTimestamps, sync, + bashOutput, + showBashOutput: (view) => { + if (view && prompt) setPromptDraft(prompt.text) + setBashOutput(view) + }, }} > @@ -748,130 +790,158 @@ export function Session() {
- (scroll = r)} - scrollbarOptions={{ - paddingLeft: 2, - visible: false, - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(message, index) => ( - - - {(function () { - const command = useCommandDialog() - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.trigger("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {keybind.print("messages_redo")} or /redo to - restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - + + + {(view) => ( + + + $ {view().command} + + (bashScroll = r)} + flexGrow={1} + paddingLeft={1} + paddingBottom={1} + scrollAcceleration={scrollAcceleration()} + > + + + + ESC to close | ↑/↓ scroll | Home/End top/bottom + + + )} + + + <> + (scroll = r)} + scrollbarOptions={{ + paddingLeft: 2, + visible: false, + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(message, index) => ( + + + {(function () { + const command = useCommandDialog() + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.trigger("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted + + {keybind.print("messages_redo")} or /redo + to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt.set(promptInfo)} + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - - - (prompt = r)} - disabled={permissions().length > 0} - onSubmit={() => { - toBottom() - }} - sessionID={route.sessionID} - /> - - -