design exploration

This commit is contained in:
James Long
2026-08-15 14:31:39 +00:00
parent dd38250f73
commit ce91429e3b
2 changed files with 208 additions and 49 deletions
+111 -20
View File
@@ -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 (
<Switch>
<Match when={props.node.type === "item" ? props.node.item : undefined}>
{(item) => (
<Switch>
<Match when={item().type === "session" && item().id === props.rootSessionID}>
<Session verticalTabsWidth={props.verticalTabsWidth} />
</Match>
<Match when={item().type === "session"}>
<UnavailablePane label={`Session ${item().id}`} />
</Match>
<Match when={item().type === "terminal"}>
<PersistentTerminalPane
ptyID={item().id}
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
onAutoFocus={() => panes.clearFocus(item().id)}
/>
</Match>
</Switch>
)}
{(item) => {
let focusTerminal: (() => void) | undefined
const focus = () => {
if (item().type === "session" && item().id === props.rootSessionID) {
prompt.current?.focus()
return
}
focusTerminal?.()
}
return (
<PaneSurface
focus={focus}
title={
item().type === "terminal"
? "Terminal"
: `Session · ${data.session.get(item().id)?.title ?? "Untitled"}`
}
>
<Switch>
<Match when={item().type === "session" && item().id === props.rootSessionID}>
<Session verticalTabsWidth={props.verticalTabsWidth} />
</Match>
<Match when={item().type === "session"}>
<UnavailablePane label={`Session ${item().id}`} />
</Match>
<Match when={item().type === "terminal"}>
<PersistentTerminalPane
ptyID={item().id}
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
onAutoFocus={() => panes.clearFocus(item().id)}
onFocusRequest={(value) => (focusTerminal = value)}
/>
</Match>
</Switch>
</PaneSurface>
)
}}
</Match>
<Match when={props.node.type === "split" ? props.node : undefined}>
{(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}
>
<PaneNode
node={node().second}
@@ -88,6 +112,73 @@ function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; vertica
)
}
function PaneSurface(props: { focus: () => 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 (
<box
ref={(value) => {
pane = value
setFocused(contains(renderer.currentFocusedRenderable))
}}
flexGrow={1}
minWidth={0}
minHeight={0}
position="relative"
flexDirection="column"
>
<box
height={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
focused()
? theme.raise(theme.raise(theme.background.surface.offset))
: theme.background.surface.offset
}
>
<text
fg={focused() ? theme.text.default : theme.text.subdued}
attributes={focused() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{props.title}
</text>
</box>
<box flexGrow={1} minWidth={0} minHeight={0} position="relative">
{props.children}
</box>
<Show when={!focused()}>
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1}
backgroundColor={RGBA.fromInts(0, 0, 0)}
opacity={0.3}
onMouseDown={props.focus}
/>
</Show>
</box>
)
}
function UnavailablePane(props: { label: string }) {
const theme = useTheme()
return (
@@ -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<string>()
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()}
>
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
<embeddedTerminal
<Show when={!failure()} fallback={<text fg={theme.text.feedback.error.default}>{failure()}</text>}>
<>
<embeddedTerminal
ref={(value) => {
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()
}}
/>
</>
</Show>
</box>
)
@@ -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)