mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 14:43:37 -04:00
design exploration
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user