diff --git a/bun.lock b/bun.lock index 706347b5995..332695aaaa3 100644 --- a/bun.lock +++ b/bun.lock @@ -262,6 +262,7 @@ "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", + "ghostty-opentui": "1.3.8", "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", @@ -2473,6 +2474,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "ghostty-opentui": ["ghostty-opentui@1.3.8", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-EvL24Ct7Ut40IavwNM9SL9sL7w9o7p5tJOWPl4FEO0NGcYzfZPKILzhjksID5XCNGz+ebKEuwyX5SEPYo2VtzA=="], + "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index dfba43513ee..7f2ea7208b2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", + "ghostty-opentui": "1.3.8", "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "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 a892c83daf8..84d6582e5af 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -35,10 +35,12 @@ export type PromptProps = { ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean + initialValue?: string } export type PromptRef = { focused: boolean + text: string current: PromptInfo set(prompt: PromptInfo): void reset(): void @@ -340,6 +342,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"]) { @@ -424,6 +430,9 @@ export function Prompt(props: PromptProps) { get focused() { return input.focused }, + get text() { + return input.plainText + }, get current() { return store.prompt }, 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 3b1c58966d0..7c142d2da10 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -62,14 +62,23 @@ import { Clipboard } from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" 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 { GhosttyTerminalRenderable } from "ghostty-opentui/opentui" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" +declare module "@opentui/solid" { + interface OpenTUIComponents { + "ghostty-terminal": typeof GhosttyTerminalRenderable + } +} + addDefaultParsers(parsers.parsers) +extend({ "ghostty-terminal": GhosttyTerminalRenderable }) + class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} @@ -80,6 +89,12 @@ class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } +type BashOutputView = { + command: string + messageID: string + partID: string +} + const context = createContext<{ width: number conceal: () => boolean @@ -90,6 +105,8 @@ const context = createContext<{ userMessageMarkdown: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType + bashOutput: () => BashOutputView | undefined + showBashOutput: (view: BashOutputView | undefined) => void }>() function use() { @@ -122,6 +139,8 @@ export function Session() { const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true)) const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") + const [bashOutput, setBashOutput] = createSignal(undefined) + const [promptDraft, setPromptDraft] = createSignal("") const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) @@ -198,6 +217,7 @@ export function Session() { }) let scroll: ScrollBoxRenderable + let bashScroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -250,6 +270,35 @@ export function Session() { useKeyboard((evt) => { if (dialog.stack.length > 0) return + if (bashOutput()) { + const scroll = bashScroll + const amount = 3 + const pageAmount = Math.max(1, dimensions().height - 4) + 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 === "pageup") { + scroll?.scrollBy(-pageAmount) + evt.preventDefault() + } else if (evt.name === "pagedown") { + scroll?.scrollBy(pageAmount) + 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(() => { @@ -939,6 +988,11 @@ export function Session() { userMessageMarkdown, diffWrapMode, sync, + bashOutput, + showBashOutput: (view) => { + if (view && prompt) setPromptDraft(prompt.text) + setBashOutput(view) + }, }} > @@ -947,136 +1001,181 @@ export function Session() {
- (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - 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) => { + const output = createMemo(() => { + const parts = sync.data.part[view().messageID] ?? [] + const partIndex = parts.findIndex((p) => p.id === view().partID) + if (partIndex < 0) return "" + const part = sync.data.part[view().messageID]?.[partIndex] + if (part?.type === "tool" && "metadata" in part.state) { + const metadata = part.state.metadata as { output?: string } | undefined + return (metadata?.output ?? "").trim() + } + return "" + }) + return ( + + + $ {view().command} + + (bashScroll = r)} + flexGrow={1} + paddingLeft={1} + paddingBottom={1} + scrollAcceleration={scrollAcceleration()} + stickyScroll={true} + stickyStart="bottom" + > + + + + + ESC to close | ↑/↓ scroll | PgUp/PgDn page | Home/End top/bottom + + + + ) + }} + + + <> + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + 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 - promptRef.set(r) - }} - disabled={permissions().length > 0} - onSubmit={() => { - toBottom() - }} - sessionID={route.sessionID} - /> - - -