Compare commits

...

2 Commits

Author SHA1 Message Date
James Long b7fc2a9ac7 feat(tui): refine persistent terminal panes 2026-08-25 17:29:29 +00:00
James Long 6484f1af52 feat(tui): add persistent terminal workspaces 2026-08-25 17:28:52 +00:00
11 changed files with 983 additions and 53 deletions
+53 -42
View File
@@ -31,7 +31,6 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -77,7 +76,6 @@ import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH
import { ThemeErrorToast } from "./component/theme-error-toast"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
@@ -100,6 +98,8 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { TerminalWorkspaceProvider } from "./context/terminal-workspace"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
return { api: OpenCode.make(next), endpoint }
},
restart: managed.restart,
}
@@ -373,48 +373,54 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} service={service}>
<ClientProvider
api={api}
endpoint={input.server.endpoint}
service={service}
>
<PermissionProvider>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
<TerminalWorkspaceProvider>
<ThemeProvider
mode={mode}
source={createThemeSource(global.config)}
>
<ThemeErrorToast />
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</TerminalWorkspaceProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
@@ -1318,7 +1324,12 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
{(sessionID) => (
<SessionFrame
sessionID={sessionID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
@@ -0,0 +1,404 @@
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } 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, useThemes } from "../context/theme"
import { errorMessage } from "../util/error"
declare module "@opentui/solid" {
interface OpenTUIComponents {
embeddedTerminal: typeof EmbeddedTerminalRenderable
}
}
extend({ embeddedTerminal: EmbeddedTerminalRenderable })
type TerminalSize = { cols: number; rows: number }
type StreamItem =
| { type: "output"; data: Uint8Array }
| { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array }
| { type: "ready" }
export function PersistentTerminalPane(props: {
ptyID: string
autoFocus?: boolean
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onInfo?: (info: { cwd: string; title: string; foregroundProcess?: string }) => void
onTitleChange?: (title: string) => void
onForegroundProcessChange?: (process: string | undefined) => void
onDisconnect?: () => void
onFocusChange?: (focused: boolean) => void
}) {
const client = useClient()
const keymap = Keymap.use()
const leader = Keymap.useLeaderActive()
const theme = useTheme("elevated")
const themes = useThemes()
const renderer = useRenderer()
const [failure, setFailure] = createSignal<string>()
const attachmentID = crypto.randomUUID()
const stream: StreamItem[] = []
const pendingInput: Uint8Array[] = []
let terminal: EmbeddedTerminalRenderable | undefined
let socket: WebSocket | undefined
let attached = false
let controller = false
let restored = false
let wantsControl = false
let disposed = false
let size: TerminalSize | undefined
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) => {
canonicalSize = value
if (!terminal) return
terminal.width = value.cols
terminal.height = value.rows
}
const applyTerminalTheme = () => {
if (terminalTheme) terminal?.write(terminalTheme)
}
const send = (data: Uint8Array) => {
if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data)
}
const interact = () => {
if (!restored) {
wantsControl = true
return
}
if (!size) return
send(interactionFrame(size))
}
const sendInput = (data: Uint8Array) => {
if (!restored) {
pendingInput.push(data)
return
}
if (size) send(interactionFrame(size, data))
}
const processStream = () => {
if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return
while (stream.length > 0) {
const item = stream[0]!
if (item.type === "output") {
stream.shift()
const output = [item.data]
while (true) {
const next = stream[0]
if (!next || next.type !== "output") break
output.push(next.data)
stream.shift()
}
terminal.write(output.length === 1 ? output[0] : Buffer.concat(output))
continue
}
if (item.type === "resize") {
setCanonicalSize(item.size)
if (!sameSize(canonicalSize, terminalSize)) return
stream.shift()
if (item.checkpoint) {
terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)]))
applyTerminalTheme()
}
continue
}
stream.shift()
restored = true
const input = pendingInput.splice(0)
if (input.length > 0) input.forEach(sendInput)
if (input.length === 0 && (controller || wantsControl)) interact()
wantsControl = false
}
}
const enqueue = (item: StreamItem) => {
stream.push(item)
processStream()
}
const waitForTerminalSize = (value: TerminalSize) => {
if (sameSize(value, terminalSize)) return Promise.resolve()
return new Promise<void>((resolve) => {
waitingSize = { size: value, resolve }
})
}
const offKeys = keymap.intercept(
"key",
({ event }) => {
if (!terminal?.focused) return
if (keymap.isLeader(event) || leader()) return
event.preventDefault()
event.stopPropagation()
terminal.handleKeyPress(event)
},
{ priority: 100 },
)
const onFocused = () => props.onFocusChange?.(terminal?.focused === true)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
createEffect(() => {
if (!props.autoFocus || !terminal) return
terminal.focus()
props.onAutoFocus?.()
})
createEffect(() => {
const tokens = themes.currentTokens().contextual.elevated
terminalTheme = terminalPalette(tokens, themes.mode(), tokens.background.default)
applyTerminalTheme()
})
onMount(() => {
void connect().catch((error) => setFailure(errorMessage(error)))
})
onCleanup(() => {
disposed = true
waitingSize?.resolve()
socket?.close()
offKeys()
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
async function connect() {
const endpoint = client.endpoint
if (!endpoint) throw new Error("Persistent terminal server endpoint is unavailable")
const snapshot = await client.api.experimental.persistentPty.snapshot({ ptyID: props.ptyID })
if (disposed) return
props.onInfo?.({
cwd: snapshot.info.cwd,
title: snapshot.info.title,
foregroundProcess: snapshot.info.foregroundProcess ?? undefined,
})
setCanonicalSize(snapshot.info.size)
await waitForTerminalSize(snapshot.info.size)
if (disposed) return
terminal?.write(Buffer.from(snapshot.checkpoint, "base64"))
applyTerminalTheme()
const token = await client.api.experimental.persistentPty.connectToken({
ptyID: props.ptyID,
"x-opencode-ticket": "1",
})
if (disposed) return
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(props.ptyID)}/connect`, endpoint.url)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(snapshot.info.output.tail))
url.searchParams.set("attachment_id", attachmentID)
url.searchParams.set("takeover", "true")
url.searchParams.set("input_protocol", "1")
const next = new WebSocket(url)
next.binaryType = "arraybuffer"
next.addEventListener("message", (event) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
enqueue({ type: "output", data: new Uint8Array(event.data) })
const now = performance.now()
if (now - lastIntermediateRender >= 16) {
lastIntermediateRender = now
renderer.intermediateRender()
}
return
}
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!message || typeof message !== "object" || !("type" in message)) return
if (message.type === "title_changed" && "title" in message && typeof message.title === "string") {
props.onTitleChange?.(message.title)
return
}
if (
message.type === "foreground_process_changed" &&
"process" in message &&
(typeof message.process === "string" || message.process === null)
) {
props.onForegroundProcessChange?.(message.process ?? undefined)
return
}
if (
message.type === "resized" &&
"cols" in message &&
typeof message.cols === "number" &&
"rows" in message &&
typeof message.rows === "number" &&
"checkpoint" in message &&
typeof message.checkpoint === "string"
) {
enqueue({
type: "resize",
size: { cols: message.cols, rows: message.rows },
checkpoint: Buffer.from(message.checkpoint, "base64"),
})
return
}
if (message.type === "replay_complete") {
enqueue({ type: "ready" })
return
}
if (
message.type === "controller_changed" &&
"attachmentID" in message &&
(typeof message.attachmentID === "string" || message.attachmentID === undefined)
) {
const previous = controller
controller = message.attachmentID === attachmentID
if (controller && !previous && restored) interact()
return
}
if (message.type !== "attached") return
if (!("inputProtocol" in message) || message.inputProtocol !== 1) {
setFailure("Persistent terminal server is out of date; restart OpenCode")
next.close()
return
}
if (
"info" in message &&
message.info &&
typeof message.info === "object" &&
"size" in message.info &&
message.info.size &&
typeof message.info.size === "object" &&
"cols" in message.info.size &&
typeof message.info.size.cols === "number" &&
"rows" in message.info.size &&
typeof message.info.size.rows === "number"
)
enqueue({ type: "resize", size: { cols: message.info.size.cols, rows: message.info.size.rows } })
controller = "role" in message && message.role === "controller"
attached = true
})
next.addEventListener("error", () => {
if (disposed) return
const focused = terminal?.focused
terminal = undefined
setFailure("Terminal connection failed")
if (focused) props.onDisconnect?.()
})
next.addEventListener("close", () => {
if (disposed) return
const focused = terminal?.focused
terminal = undefined
setFailure("Terminal disconnected")
if (focused) props.onDisconnect?.()
})
socket = next
}
return (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
overflow="hidden"
backgroundColor={themes.currentTokens().contextual.elevated.background.default}
onSizeChange={function () {
size = { cols: Math.max(1, this.width - 2), rows: this.height }
if (controller && restored) interact()
}}
// 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
ref={(value) => {
terminal = value
props.onFocusRequest?.(() => {
value.focus()
interact()
})
terminalSize = { cols: 80, rows: 24 }
if (canonicalSize) {
value.width = canonicalSize.cols
value.height = canonicalSize.rows
}
applyTerminalTheme()
}}
position="absolute"
left={1}
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>
)
}
function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefined) {
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
}
function terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light", background: RGBA) {
const base = mode === "dark" ? 500 : 700
const bright = mode === "dark" ? 300 : 500
const colors = [
background,
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(background)}\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)
frame[0] = data ? 1 : 0
view.setUint16(1, size.cols)
view.setUint16(3, size.rows)
if (data) frame.set(data, 5)
return frame
}
+6 -4
View File
@@ -79,6 +79,7 @@ export type PromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
muted?: boolean
onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
@@ -195,6 +196,7 @@ export function Prompt(props: PromptProps) {
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
@@ -1601,7 +1603,7 @@ export function Prompt(props: PromptProps) {
},
)
const highlight = createMemo(() => {
if (leader()) return theme.border.default
if (muted()) return theme.border.default
if (store.mode === "shell") return theme.text.action.primary.selected
return promptDisplay().agentColor ?? theme.border.default
})
@@ -1772,8 +1774,8 @@ export function Prompt(props: PromptProps) {
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.text.subdued}
textColor={leader() ? theme.text.subdued : theme.text.default}
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
textColor={muted() ? theme.text.subdued : theme.text.default}
focusedTextColor={muted() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
cursorStyle={config.cursor}
@@ -1870,7 +1872,7 @@ export function Prompt(props: PromptProps) {
minWidth={0}
wrapMode="none"
truncate
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
fg={fadeColor(muted() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
>
{promptDisplay().modelLabel}
</text>
@@ -0,0 +1,330 @@
import type { PersistentPtyInfo } from "@opencode-ai/client"
import { TextAttributes } from "@opentui/core"
import { createEffect, createResource, createSignal, For, onCleanup, Show, type JSX } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTerminalWorkspace } from "../context/terminal-workspace"
import { usePromptRef } from "../context/prompt"
import { useTheme, useThemes } from "../context/theme"
import { Session } from "../routes/session"
import { SplitBorder } from "../ui/border"
import { PersistentTerminalPane } from "./persistent-terminal-pane"
type TerminalPickerState = {
entries: { id: string; title: string }[]
selected: number
onMove: (index: number) => void
onSelect: () => void
onClose: () => void
}
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
const panes = useTerminalWorkspace()
const keymap = Keymap.use()
const prompt = usePromptRef()
const [terminalTitles, setTerminalTitles] = createSignal<Record<string, string>>({})
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
const [pickerOpen, setPickerOpen] = createSignal(false)
const [pickerSelected, setPickerSelected] = createSignal(0)
let focusTerminal: (() => void) | undefined
createResource(
() => props.sessionID,
(sessionID) => panes.load(sessionID).catch(() => undefined),
)
const workspace = () => panes.get(props.sessionID)
const terminals = () => workspace()?.terminals ?? []
const selectedTerminal = () => {
const value = workspace()
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
}
createEffect(() => {
if (!restoreTerminalFocus() || terminals().length > 0) return
setRestoreTerminalFocus(false)
prompt.current?.focus()
})
const picker = (): TerminalPickerState | undefined => {
if (!pickerOpen()) return
return {
entries: terminals().map((terminal) => ({
id: terminal.id,
title: terminalTitles()[terminal.id] ?? terminal.foregroundProcess ?? terminal.title,
})),
selected: pickerSelected(),
onMove: setPickerSelected,
onSelect: selectTerminal,
onClose: () => setPickerOpen(false),
}
}
const selectTerminal = () => {
const list = terminals()
const selected = pickerSelected()
setPickerOpen(false)
if (selected < list.length) {
void panes.selectTerminal(props.sessionID, list[selected]!.id)
return
}
void panes.newTerminal(props.sessionID)
}
Keymap.createLayer(() => ({
commands: [
{
id: "pane.focus.left",
title: "Focus session pane",
run: () => {
setPickerOpen(false)
prompt.current?.focus()
},
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
run: () => {
setPickerOpen(false)
focusTerminal?.()
},
},
{
id: "terminal.select",
title: "Select terminal",
run: () => {
if (pickerOpen()) {
setPickerOpen(false)
return
}
const current = selectedTerminal()
const index = terminals().findIndex((terminal) => terminal.id === current?.id)
setPickerSelected(index < 0 ? 0 : index)
setPickerOpen(true)
void panes.refresh(props.sessionID)
},
},
],
}))
const offPickerKeys = keymap.intercept(
"key",
({ event }) => {
if (!pickerOpen()) return
event.preventDefault()
event.stopPropagation()
const count = terminals().length + 1
if (event.name === "escape") {
setPickerOpen(false)
return
}
if (event.name === "up" || event.name === "k") {
setPickerSelected((index) => (index + count - 1) % count)
return
}
if (event.name === "down" || event.name === "j") {
setPickerSelected((index) => (index + 1) % count)
return
}
if (event.name === "enter" || event.name === "return") selectTerminal()
},
{ priority: 200 },
)
onCleanup(offPickerKeys)
return (
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row">
<box flexGrow={selectedTerminal() ? 0.5 : 1} flexBasis={0} minWidth={0} minHeight={0} position="relative">
<Session verticalTabsWidth={props.verticalTabsWidth} promptMuted={terminalFocused()} />
<Show when={terminalFocused()}>
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1}
onMouseDown={() => prompt.current?.focus()}
/>
</Show>
</box>
<Show keyed when={selectedTerminal()}>
{(terminal) => (
<box flexGrow={0.5} flexBasis={0} minWidth={0} minHeight={0}>
<TerminalPane
info={terminal}
picker={picker()}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
restoreFocus={restoreTerminalFocus()}
onAutoFocus={() => setRestoreTerminalFocus(false)}
onDisconnect={() => setRestoreTerminalFocus(true)}
onTitle={(title) => setTerminalTitles((titles) => ({ ...titles, [terminal.id]: title }))}
/>
</box>
)}
</Show>
</box>
)
}
function TerminalPane(props: {
info: PersistentPtyInfo
picker?: TerminalPickerState
onFocusChange: (focused: boolean) => void
onFocusRequest: (focus: (() => void) | undefined) => void
restoreFocus: boolean
onAutoFocus: () => void
onDisconnect: () => void
onTitle: (title: string) => void
}) {
const panes = useTerminalWorkspace()
const [terminalTitle, setTerminalTitle] = createSignal(props.info.title)
const [foregroundProcess, setForegroundProcess] = createSignal(props.info.foregroundProcess ?? undefined)
const [focused, setFocused] = createSignal(false)
let focusTerminal: (() => void) | undefined
return (
<PaneSurface
focus={() => focusTerminal?.()}
title={foregroundProcess() ?? terminalTitle()}
focused={focused()}
picker={props.picker}
>
<PersistentTerminalPane
ptyID={props.info.id}
autoFocus={props.restoreFocus || panes.shouldFocus(props.info.id)}
onAutoFocus={() => {
panes.clearFocus(props.info.id)
props.onAutoFocus()
}}
onFocusRequest={(value) => {
focusTerminal = value
props.onFocusRequest(value)
}}
onDisconnect={props.onDisconnect}
onFocusChange={(value) => {
setFocused(value)
props.onFocusChange(value)
}}
onInfo={(info) => {
setTerminalTitle(info.title)
setForegroundProcess(info.foregroundProcess)
props.onTitle(info.foregroundProcess ?? info.title)
}}
onTitleChange={(title) => {
setTerminalTitle(title)
if (!foregroundProcess()) props.onTitle(title)
}}
onForegroundProcessChange={(process) => {
setForegroundProcess(process)
props.onTitle(process ?? terminalTitle())
}}
/>
</PaneSurface>
)
}
function PaneSurface(props: {
focus: () => void
title: string
focused: boolean
picker?: TerminalPickerState
children: JSX.Element
}) {
const theme = useTheme()
const themes = useThemes()
const shortcut = Keymap.useShortcut("terminal.select")
const background = () => themes.currentTokens().contextual.elevated.background.default
return (
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="column" backgroundColor={background()}>
<box
height={1}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
backgroundColor={background()}
onMouseDown={props.focus}
>
<text
fg={props.focused ? theme.text.formfield.selected : theme.text.subdued}
bg={background()}
wrapMode="none"
truncate
flexGrow={1}
minWidth={0}
>
Terminal: {props.title}
</text>
<Show when={shortcut()}>
{(value) => (
<>
<text fg={theme.text.default} bg={background()} wrapMode="none" flexShrink={0}>
{value()}
</text>
<text fg={theme.text.subdued} bg={background()} wrapMode="none" flexShrink={0}>
{" "}
terminals
</text>
</>
)}
</Show>
</box>
<box flexGrow={1} minWidth={0} minHeight={0} position="relative" backgroundColor={background()}>
{props.children}
</box>
<Show when={props.picker}>{(picker) => <TerminalPicker {...picker()} />}</Show>
</box>
)
}
function TerminalPicker(props: TerminalPickerState) {
const theme = useTheme("elevated")
const options = () => [...props.entries, { id: "", title: "+ New terminal" }]
const background = () => theme.raise(theme.background.default)
return (
<box
{...SplitBorder}
flexShrink={0}
flexDirection="column"
marginLeft={2}
marginRight={2}
marginBottom={1}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
border={["left"]}
borderColor={theme.border.default}
backgroundColor={background()}
>
<box flexDirection="row" marginBottom={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD} flexGrow={1}>
Terminals
</text>
<text fg={theme.text.subdued}>esc</text>
</box>
<For each={options()}>
{(option, index) => {
const selected = () => index() === props.selected
return (
<box
height={1}
backgroundColor={
selected() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
onMouseOver={() => props.onMove(index())}
onMouseUp={() => {
props.onMove(index())
props.onSelect()
}}
>
<text
fg={selected() ? theme.text.action.primary.focused : theme.text.action.primary.default}
wrapMode="none"
truncate
>
{option.title}
</text>
</box>
)
}}
</For>
</box>
)
}
+3
View File
@@ -87,6 +87,9 @@ export const Definitions = {
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
"opencode.status": keybind("<leader>s", "View status"),
"opencode.debug": keybind("none", "View debug info"),
+10 -3
View File
@@ -1,12 +1,13 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createClientConnection } from "@opencode-ai/client/solid"
import type { Endpoint } from "@opencode-ai/client/service"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
restart: () => Promise<void>
}
@@ -14,16 +15,19 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
init: (props: { api: OpenCodeClient; endpoint?: Endpoint; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
let api = props.api
let endpoint = props.endpoint
const connection = createClientConnection(api, {
reconnect: service
? async (signal) => {
api = (await service.reconnect(signal)).api
const next = await service.reconnect(signal)
api = next.api
if (next.endpoint) endpoint = next.endpoint
return api
}
: undefined,
@@ -41,6 +45,9 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get endpoint() {
return endpoint
},
event: {
on: events.on,
listen: events.listen,
+5
View File
@@ -169,16 +169,21 @@ export interface Keymap {
}
/** Registers a low-level keymap interceptor. */
intercept: OpenTuiKeymap["intercept"]
/** Returns whether an event matches the configured leader key. */
isLeader(event: KeyEvent): boolean
}
function use(): Keymap {
const value = useValue()
const leader = value.config.keybinds.get("leader")?.[0]?.key
const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false
return {
dispatch(id, input) {
value.dispatch(id, input)
},
mode: value.mode,
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}
}
@@ -0,0 +1,99 @@
import type { PersistentPtyInfo } from "@opencode-ai/client"
import { createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { useEvent } from "./event"
import { useStorage } from "./storage"
type TerminalWorkspace = {
sessionID: string
terminals: PersistentPtyInfo[]
selectedTerminalID?: string
}
type TerminalWorkspaceState = {
workspaces: Record<string, TerminalWorkspace>
}
export const { use: useTerminalWorkspace, provider: TerminalWorkspaceProvider } = createSimpleContext({
name: "TerminalWorkspace",
init: () => {
const client = useClient()
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<TerminalWorkspaceState>("terminal-workspace-v1", {
initial: { workspaces: {} },
})
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
update((draft) => {
const current = draft.workspaces[sessionID]?.selectedTerminalID
const selected = selectedTerminalID ?? current
draft.workspaces[sessionID] = {
sessionID,
terminals,
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
}
})
const refresh = async (sessionID: string) => {
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
}
onCleanup(
event.on("persistent-pty.added", (evt) => {
if (!store.workspaces[evt.data.sessionID]) return
void refresh(evt.data.sessionID).catch((error) =>
console.error("Failed to add persistent terminal pane", error),
)
}),
)
onCleanup(
event.on("persistent-pty.removed", (evt) => {
if (!store.workspaces[evt.data.sessionID]) return
void refresh(evt.data.sessionID).catch((error) =>
console.error("Failed to remove persistent terminal pane", error),
)
}),
)
return {
get(sessionID: string) {
return store.workspaces[sessionID]
},
load: refresh,
refresh,
selectTerminal(sessionID: string, ptyID: string) {
setFocus(ptyID)
return update((draft) => {
const workspace = draft.workspaces[sessionID]
if (!workspace?.terminals.some((terminal) => terminal.id === ptyID)) return
workspace.selectedTerminalID = ptyID
})
},
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
const session = data.session.get(sessionID)
const terminal = await client.api.experimental.persistentPty.create({
sessionID,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: session?.location.directory ?? process.cwd(),
title: "Terminal",
env: {},
})
setFocus(terminal.id)
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }), terminal.id)
return terminal
},
shouldFocus(ptyID: string) {
return focus() === ptyID
},
clearFocus(ptyID: string) {
setFocus((current) => (current === ptyID ? undefined : current))
},
}
},
})
+14 -1
View File
@@ -110,6 +110,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useTerminalWorkspace } from "../../context/terminal-workspace"
addDefaultParsers(parsers.parsers)
@@ -148,7 +149,7 @@ function use() {
return ctx
}
export function Session(props: { verticalTabsWidth: number }) {
export function Session(props: { verticalTabsWidth: number; promptMuted?: boolean }) {
const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
@@ -280,6 +281,7 @@ export function Session(props: { verticalTabsWidth: number }) {
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const panes = useTerminalWorkspace()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
@@ -896,6 +898,16 @@ export function Session(props: { verticalTabsWidth: number }) {
dialog.clear()
},
},
{
title: "New terminal",
id: "session.terminal",
group: "Session",
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await panes.newTerminal(route.sessionID).catch(toast.error)
},
},
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
@@ -1334,6 +1346,7 @@ export function Session(props: { verticalTabsWidth: number }) {
visible={true}
ref={bind}
disabled={false}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
}}
+13 -2
View File
@@ -11,7 +11,11 @@ type FocusableSelectionTarget = {
}
type Renderer = {
getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
getSelection: () => {
getSelectedText: () => string
selectedRenderables: FocusableSelectionTarget[]
isStart: boolean
} | null
clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
}
@@ -36,9 +40,16 @@ export function copyOnSelectRelease(
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
const selection = renderer.getSelection()
if (!selection) return false
if (selection.isStart) {
renderer.clearSelection()
return false
}
const text = selection.getSelectedText()
if (!text) return false
if (!text) {
renderer.clearSelection()
return false
}
const focus = renderer.currentFocusedRenderable
const clipboardText =
+46 -1
View File
@@ -1,16 +1,38 @@
import { expect, test } from "bun:test"
import { copy, copyOnSelectRelease } from "../../src/util/selection"
import type { ClipboardService } from "../../src/context/clipboard"
import { Selection, copy, copyOnSelectRelease } from "../../src/util/selection"
function renderer() {
return {
getSelection: () => ({
getSelectedText: () => "beta",
selectedRenderables: [],
isStart: false,
}),
clearSelection: () => {},
}
}
function setup(text: string, isStart: boolean) {
const writes: string[] = []
let clears = 0
const clipboard: ClipboardService = {
read: async () => undefined,
write: async (value) => {
writes.push(value)
},
}
const renderer = {
getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart }),
clearSelection: () => {
clears++
},
currentFocusedRenderable: null,
}
const toast = { show: () => {}, error: () => {} }
return { clipboard, renderer, toast, writes, clears: () => clears }
}
test("copy writes selected text without clearing the highlight", () => {
let cleared = false
const copied = copy(
@@ -18,6 +40,7 @@ test("copy writes selected text without clearing the highlight", () => {
getSelection: () => ({
getSelectedText: () => "beta",
selectedRenderables: [],
isStart: false,
}),
clearSelection: () => {
cleared = true
@@ -51,3 +74,25 @@ test("copy-on-select ignores a later non-drag release", () => {
expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true)
expect(writes).toEqual(["beta"])
})
test("clears a click-only selection without copying", () => {
const value = setup("x", true)
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse()
expect(value.clears()).toBe(1)
expect(value.writes).toEqual([])
})
test("clears an empty dragged selection without copying", () => {
const value = setup("", false)
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeFalse()
expect(value.clears()).toBe(1)
expect(value.writes).toEqual([])
})
test("copies a non-empty dragged selection without clearing its highlight", async () => {
const value = setup("selected", false)
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeTrue()
await Promise.resolve()
expect(value.clears()).toBe(0)
expect(value.writes).toEqual(["selected"])
})