From ce91429e3b98f57448f697d2e120b69e5863be4e Mon Sep 17 00:00:00 2001 From: James Long Date: Sat, 15 Aug 2026 14:31:39 +0000 Subject: [PATCH] design exploration --- packages/tui/src/component/pane-workspace.tsx | 131 +++++++++++++++--- .../component/persistent-terminal-pane.tsx | 126 +++++++++++++---- 2 files changed, 208 insertions(+), 49 deletions(-) diff --git a/packages/tui/src/component/pane-workspace.tsx b/packages/tui/src/component/pane-workspace.tsx index e4af2d4caab..573d2a126a1 100644 --- a/packages/tui/src/component/pane-workspace.tsx +++ b/packages/tui/src/component/pane-workspace.tsx @@ -1,6 +1,10 @@ -import { createResource, Match, Show, Switch } from "solid-js" +import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core" +import { useRenderer } from "@opentui/solid" +import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js" import { usePaneLayout } from "../context/pane-layout" import type { PaneLayoutNode } from "../context/pane-layout-model" +import { useData } from "../context/data" +import { usePromptRef } from "../context/prompt" import { useTheme } from "../context/theme" import { Session } from "../routes/session" import { PersistentTerminalPane } from "./persistent-terminal-pane" @@ -30,27 +34,49 @@ export function PaneWorkspace(props: { sessionID?: string; groupID?: string; ver function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) { const panes = usePaneLayout() + const prompt = usePromptRef() const theme = useTheme() + const data = useData() return ( - {(item) => ( - - - - - - - - - panes.clearFocus(item().id)} - /> - - - )} + {(item) => { + let focusTerminal: (() => void) | undefined + const focus = () => { + if (item().type === "session" && item().id === props.rootSessionID) { + prompt.current?.focus() + return + } + focusTerminal?.() + } + return ( + + + + + + + + + + panes.clearFocus(item().id)} + onFocusRequest={(value) => (focusTerminal = value)} + /> + + + + ) + }} {(node) => ( @@ -72,8 +98,6 @@ function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; vertica flexBasis={0} minWidth={0} minHeight={0} - border={node().direction === "horizontal" ? ["left"] : ["top"]} - borderColor={theme.border.default} > void; title: string; children: JSX.Element }) { + const renderer = useRenderer() + const theme = useTheme() + const [focused, setFocused] = createSignal(false) + let pane: BoxRenderable | undefined + const contains = (current: Renderable | null) => { + while (current) { + if (current === pane) return true + current = current.parent + } + return false + } + const onFocused = (current: Renderable | null) => setFocused(contains(current)) + renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) + onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)) + return ( + { + pane = value + setFocused(contains(renderer.currentFocusedRenderable)) + }} + flexGrow={1} + minWidth={0} + minHeight={0} + position="relative" + flexDirection="column" + > + + + {props.title} + + + + {props.children} + + + + + + ) +} + function UnavailablePane(props: { label: string }) { const theme = useTheme() return ( diff --git a/packages/tui/src/component/persistent-terminal-pane.tsx b/packages/tui/src/component/persistent-terminal-pane.tsx index 580960b6073..2ebb04c98df 100644 --- a/packages/tui/src/component/persistent-terminal-pane.tsx +++ b/packages/tui/src/component/persistent-terminal-pane.tsx @@ -1,9 +1,10 @@ import { EmbeddedTerminalRenderable } from "@opentui/core" +import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui" import { extend, useRenderer } from "@opentui/solid" import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" -import { useTheme } from "../context/theme" +import { useTheme, useThemes } from "../context/theme" import { errorMessage } from "../util/error" declare module "@opentui/solid" { @@ -20,10 +21,16 @@ type StreamItem = | { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array } | { type: "ready" } -export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boolean; onAutoFocus?: () => void }) { +export function PersistentTerminalPane(props: { + ptyID: string + autoFocus?: boolean + onAutoFocus?: () => void + onFocusRequest?: (focus: (() => void) | undefined) => void +}) { const client = useClient() const keymap = Keymap.use() const theme = useTheme() + const themes = useThemes() const renderer = useRenderer() const [failure, setFailure] = createSignal() const attachmentID = crypto.randomUUID() @@ -40,6 +47,7 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole let canonicalSize: TerminalSize | undefined let terminalSize: TerminalSize | undefined let lastIntermediateRender = 0 + let terminalTheme: Uint8Array | undefined let waitingSize: { size: TerminalSize; resolve: () => void } | undefined const setCanonicalSize = (value: TerminalSize) => { @@ -49,6 +57,10 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole terminal.height = value.rows } + const applyTerminalTheme = () => { + if (terminalTheme) terminal?.write(terminalTheme) + } + const send = (data: Uint8Array) => { if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data) } @@ -90,8 +102,10 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole setCanonicalSize(item.size) if (!sameSize(canonicalSize, terminalSize)) return stream.shift() - if (item.checkpoint) + if (item.checkpoint) { terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)])) + applyTerminalTheme() + } continue } stream.shift() @@ -125,13 +139,17 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole }, { priority: 100 }, ) - createEffect(() => { if (!props.autoFocus || !terminal) return terminal.focus() props.onAutoFocus?.() }) + createEffect(() => { + terminalTheme = terminalPalette(themes.currentTokens(), themes.mode()) + applyTerminalTheme() + }) + onMount(() => { void connect().catch((error) => setFailure(errorMessage(error))) }) @@ -141,6 +159,7 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole waitingSize?.resolve() socket?.close() offKeys() + props.onFocusRequest?.(undefined) }) async function connect() { @@ -152,6 +171,7 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole await waitForTerminalSize(snapshot.info.size) if (disposed) return terminal?.write(Buffer.from(snapshot.checkpoint, "base64")) + applyTerminalTheme() const token = await client.api["server.persistentPty"].connectToken( { ptyID: props.ptyID }, { headers: { "x-opencode-ticket": "1" } }, @@ -248,6 +268,7 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole minWidth={0} minHeight={0} overflow="hidden" + backgroundColor={theme.background.default} onSizeChange={function () { size = { cols: this.width, rows: this.height } if (controller && restored) interact() @@ -255,33 +276,40 @@ export function PersistentTerminalPane(props: { ptyID: string; autoFocus?: boole // TODO: Revisit when embedded terminal mouse handlers can compose without replacing its internal focus handler. onMouseDown={() => interact()} > - {failure()}}> - {failure()}}> + <> + { terminal = value - terminalSize = { cols: 80, rows: 24 } - if (canonicalSize) { - value.width = canonicalSize.cols - value.height = canonicalSize.rows - } - }} - position="absolute" - left={0} - top={0} - width={80} - height={24} - onData={(data, source) => { - if (source === "input") sendInput(data) - }} - onTerminalResize={(cols, rows) => { - terminalSize = { cols, rows } - if (waitingSize && sameSize(waitingSize.size, terminalSize)) { - waitingSize.resolve() - waitingSize = undefined - } - processStream() - }} - /> + props.onFocusRequest?.(() => { + value.focus() + interact() + }) + terminalSize = { cols: 80, rows: 24 } + if (canonicalSize) { + value.width = canonicalSize.cols + value.height = canonicalSize.rows + } + applyTerminalTheme() + }} + position="absolute" + left={0} + top={0} + width={80} + height={24} + onData={(data, source) => { + if (source === "input") sendInput(data) + }} + onTerminalResize={(cols, rows) => { + terminalSize = { cols, rows } + if (waitingSize && sameSize(waitingSize.size, terminalSize)) { + waitingSize.resolve() + waitingSize = undefined + } + processStream() + }} + /> + ) @@ -291,6 +319,46 @@ function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefi return !!first && !!second && first.cols === second.cols && first.rows === second.rows } +function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light") { + const base = mode === "dark" ? 500 : 700 + const bright = mode === "dark" ? 300 : 500 + const colors = [ + theme.background.default, + theme.text.feedback.error.default, + theme.text.feedback.success.default, + theme.text.feedback.warning.default, + theme.hue.blue[base], + theme.hue.purple[base], + theme.text.feedback.info.default, + theme.text.default, + theme.text.subdued, + theme.text.feedback.error.subdued, + theme.text.feedback.success.subdued, + theme.text.feedback.warning.subdued, + theme.hue.blue[bright], + theme.hue.purple[bright], + theme.hue.cyan[bright], + theme.hue.neutral[mode === "dark" ? 100 : 900], + ] + return Buffer.from( + colors + .map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`) + .concat( + `\x1b]10;${hex(theme.text.default)}\x1b\\`, + `\x1b]11;${hex(theme.background.default)}\x1b\\`, + ) + .join(""), + ) +} + +function hex(color: RGBA) { + return `#${color + .toInts() + .slice(0, 3) + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}` +} + function interactionFrame(size: { cols: number; rows: number }, data?: Uint8Array) { const frame = new Uint8Array(5 + (data?.byteLength ?? 0)) const view = new DataView(frame.buffer)