feat(tui): add persistent session terminals (#44971)

This commit is contained in:
James Long 2026-08-26 09:30:03 -04:00 committed by GitHub
parent 91028a690b
commit 9cca8dd6e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1099 additions and 128 deletions

View file

@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@ -365,7 +365,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@ -2157,19 +2157,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.8", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.8", "@opencode-ai/pty-darwin-x64": "0.1.8", "@opencode-ai/pty-linux-arm64-gnu": "0.1.8", "@opencode-ai/pty-linux-arm64-musl": "0.1.8", "@opencode-ai/pty-linux-x64-gnu": "0.1.8", "@opencode-ai/pty-linux-x64-musl": "0.1.8" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-+OHAANB6xgtO4zVMK5Q3l50Iot2fYcHL003rNpvxxCXLkpdO90UnnRCL9jrll1SWkWoDZKaUDlR/Scw6TUTN4A=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5DqVcCfa9P5zpM0QpvXD/QqZXFdXvtIoaBHVHjYnTt8oqlnmMWqG4OVowzPNQg07NudW6apwX1Jop1wuERgWGw=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-keEDxfpy0mFJYmsTqea9IUbpLKpbe9yL4ISU8tYA0daWiKuR8ZlfLTlLGltaFNO7l7q6mURMeI4cZzwySOX/Kw=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-NVJvlVtPbYRyjABwLkm1iboijpSZMXv4lfg4qda7UCzAgiDZ0cWauXeW5wtd9bbnUbHw4rdNVU6YweuwASgZXw=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-GAhnCs7b7B8Hqa7AEN7k5ra4IqybFYePgbCSOHn3Y4mofp/5NjDlkOG/VFM7rHAy05iEmRi5WtOPRv0Mkca8zA=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-vSbty1L0BuOQlK7DmukuhdxNKv2vzqyxOjHmh+TULptMDbDZwE1EybASoXB7dYog9as85adFCz8ZTF7Boyfxjg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.8", "", { "os": "linux", "cpu": "x64" }, "sha512-2GhmJLl+Ly/JjzMS2Eqvk7SgeuHIB0cQ4Wrmnj4WKhr2V7lS+pdUEApN6S0SG/FK0TYJlWeHeOIpNZgDqzEPPg=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],

View file

@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",

View file

@ -1,4 +1,4 @@
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise"
export type PtyClientOptions = {
readonly url: string
@ -11,6 +11,13 @@ export type PtyConnectInput = {
readonly cursor?: number
}
export type PersistentPtyConnectInput = {
readonly ptyID: ExperimentalPersistentPtyConnectTokenInput["ptyID"]
readonly cursor: number
readonly attachmentID: string
readonly takeover?: boolean
}
export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PtyConnectInput) {
@ -32,3 +39,25 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions)
},
}
}
export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClientOptions) {
return {
async connect(input: PersistentPtyConnectInput) {
const token = await api.experimental.persistentPty.connectToken({
ptyID: input.ptyID,
"x-opencode-ticket": "1",
})
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("attachment_id", input.attachmentID)
url.searchParams.set("takeover", String(input.takeover ?? false))
url.searchParams.set("input_protocol", "1")
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
const socket = options.openSocket?.(url) ?? new WebSocket(url)
socket.binaryType = "arraybuffer"
return socket
},
}
}

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "../src/promise"
import { createPtyClient } from "../src/solid"
import { createPersistentPtyClient, createPtyClient } from "../src/solid"
describe("createPtyClient", () => {
test("mints an authenticated ticket before opening the terminal socket", async () => {
@ -66,3 +66,36 @@ describe("createPtyClient", () => {
expect(opened).toBe(false)
})
})
describe("createPersistentPtyClient", () => {
test("mints an authenticated ticket before opening the persistent terminal socket", async () => {
let request: Request | undefined
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { ticket: "persistent-ticket", expires_in: 60 } })
},
})
const pty = createPersistentPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
},
})
expect(await pty.connect({ ptyID: "pty_1", cursor: 42, attachmentID: "attachment_1", takeover: true })).toBe(socket)
expect(request?.method).toBe("POST")
expect(request?.url).toBe("https://server.example/api/experimental/persistent-pty/pty_1/connect-token")
expect(request?.headers.get("authorization")).toBe("Basic credential")
expect(request?.headers.get("x-opencode-ticket")).toBe("1")
expect(socketURL?.toString()).toBe(
"wss://server.example/api/experimental/persistent-pty/pty_1/connect?ticket=persistent-ticket&cursor=42&attachment_id=attachment_1&takeover=true&input_protocol=1",
)
expect(socket.binaryType).toBe("arraybuffer")
})
})

View file

@ -119,7 +119,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.8",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",

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 { SessionTerminalsProvider } from "./context/session-terminals"
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), url: endpoint.url }
},
restart: managed.restart,
}
@ -373,48 +373,50 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
: undefined
}
>
<ClientProvider api={api} service={service}>
<ClientProvider api={api} url={input.server.endpoint.url} 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>
<SessionTerminalsProvider>
<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>
</SessionTerminalsProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
@ -1318,7 +1320,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"}>

View file

@ -274,21 +274,13 @@ export const settings: Setting[] = [
keywords: ["sound volume", "audio volume"],
},
{
title: "Window title",
title: "Enabled",
category: "Terminal",
path: ["terminal", "title"],
default: true,
path: ["terminal", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["terminal title", "tab title"],
},
{
title: "Copy behavior",
category: "Terminal",
path: ["terminal", "copy"],
default: process.platform === "win32" ? "manual" : "select",
values: ["manual", "select"],
keywords: ["selection", "clipboard"],
keywords: ["pty", "shell", "terminal pane"],
},
{
title: "Developer tools",

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
@ -196,6 +197,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()
@ -1614,7 +1616,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
})
@ -1793,8 +1795,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}
@ -1891,7 +1893,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>

View file

@ -0,0 +1,178 @@
import { RGBA } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { useToast } from "../ui/toast"
import { TerminalPane } from "./terminal-pane"
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
const sessions = useSessionTerminals()
const prompt = usePromptRef()
const config = useConfig()
const data = useData()
const toast = useToast()
const dimensions = useTerminalDimensions()
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
createResource(
() => (config.data.terminal?.enabled ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
)
const session = () => sessions.get(props.sessionID)
const terminals = () => session()?.terminals ?? []
const selectedTerminal = () => {
if (!config.data.terminal?.enabled) return
const value = session()
if (value?.hidden) return
return value?.terminals.find((terminal) => terminal.id === value.selectedTerminalID) ?? value?.terminals.at(-1)
}
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (id) setSidebarOpen(false)
},
{ defer: true },
),
)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible() && !session()?.hidden) return "sidebar"
})
const toggleSidebar = () => {
batch(() => {
const visible = rightPane() === "sidebar"
void config
.update((draft) => {
draft.session = { ...draft.session, sidebar: visible ? "hide" : "auto" }
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && selectedTerminal()) void sessions.hideTerminal(props.sessionID).catch(toast.error)
})
}
createEffect(() => {
if (!restoreTerminalFocus() || terminals().length > 0) return
setRestoreTerminalFocus(false)
prompt.current?.focus()
})
Keymap.createLayer(() => ({
enabled: () => config.data.terminal?.enabled === true,
commands: [
{
id: "pane.focus.left",
title: "Focus session pane",
run: () => {
prompt.current?.focus()
},
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
run: () => {
focusTerminal?.()
},
},
],
}))
return (
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row" position="relative">
<box
flexGrow={1}
flexBasis={0}
minWidth={0}
minHeight={0}
position="relative"
onSizeChange={function () {
setSessionWidth(this.width)
}}
>
<Session
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<box
position="absolute"
left={0}
top={0}
width="100%"
height="100%"
zIndex={1}
onMouseDown={() => prompt.current?.focus()}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={
rightPane() === "terminal"
? Math.max(1, Math.floor((dimensions().width - props.verticalTabsWidth) / 2))
: SESSION_SIDEBAR_WIDTH
}
minWidth={0}
minHeight={0}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()}>
{(terminal) => (
<TerminalPane
ptyID={terminal.id}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(terminal.id)}
onAutoFocus={() => {
sessions.clearFocus(terminal.id)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
<Sidebar sessionID={props.sessionID} />
</Show>
</box>
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={props.sessionID} />
</box>
</Show>
</box>
)
}

View file

@ -0,0 +1,377 @@
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 TerminalPane(props: {
ptyID: string
autoFocus?: boolean
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | 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 snapshot = await client.api.experimental.persistentPty.snapshot({ ptyID: props.ptyID })
if (disposed) return
setCanonicalSize(snapshot.info.size)
await waitForTerminalSize(snapshot.info.size)
if (disposed) return
terminal?.write(Buffer.from(snapshot.checkpoint, "base64"))
applyTerminalTheme()
const next = await client.persistentPty.connect({
ptyID: props.ptyID,
cursor: snapshot.info.output.tail,
attachmentID,
takeover: true,
})
if (disposed) {
next.close()
return
}
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 === "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
}

View file

@ -102,6 +102,7 @@ export const Info = Schema.Struct({
).annotate({ description: "Diff presentation settings" }),
terminal: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable persistent terminal panes" }),
title: Schema.optional(Schema.Boolean).annotate({ description: "Update the terminal window title" }),
copy: Schema.optional(Schema.Literals(["manual", "select"])).annotate({
description: "Copy text manually or immediately after selecting it",
@ -235,6 +236,12 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (input.terminal?.enabled) {
if (input.keybinds?.["terminal.toggle"] === undefined && input.keybinds?.["theme.switch"] === undefined) {
keybinds["terminal.toggle"] = "<leader>t"
keybinds["theme.switch"] = "none"
}
}
if (!options.terminalSuspend) {
keybinds["terminal.suspend"] = "none"
if (keybinds["input.undo"] === undefined) {

View file

@ -87,6 +87,11 @@ 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"),
"terminal.toggle": keybind("none", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
"opencode.status": keybind("<leader>s", "View status"),
"opencode.debug": keybind("none", "View debug info"),
@ -234,6 +239,9 @@ export const Definitions = {
"composer.shell.up": keybind("up", "Previous shell"),
"composer.shell.down": keybind("down", "Next shell"),
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
"composer.terminal.up": keybind("up,k", "Previous terminal"),
"composer.terminal.down": keybind("down,j", "Next terminal"),
"composer.terminal.select": keybind("return", "Select terminal"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),

View file

@ -1,12 +1,12 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createClientConnection } from "@opencode-ai/client/solid"
import { createClientConnection, createPersistentPtyClient } from "@opencode-ai/client/solid"
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; url?: string }>
restart: () => Promise<void>
}
@ -14,16 +14,21 @@ 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; url?: string; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
let api = props.api
let url = props.url
let persistentPty = url ? createPersistentPtyClient(api, { url }) : undefined
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.url) url = next.url
if (url) persistentPty = createPersistentPtyClient(api, { url })
return api
}
: undefined,
@ -41,6 +46,10 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get persistentPty() {
if (!persistentPty) throw new Error("Persistent terminal server endpoint is unavailable")
return persistentPty
},
event: {
on: events.on,
listen: events.listen,

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,
}
}

View file

@ -0,0 +1,100 @@
import type { PersistentPtyInfo } from "@opencode-ai/client"
import { createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useConfig } from "../config"
import { useData } from "./data"
import { useEvent } from "./event"
import { useStorage } from "./storage"
type SessionTerminals = {
terminals: PersistentPtyInfo[]
selectedTerminalID?: string
hidden?: boolean
}
type SessionTerminalsState = {
sessions: Record<string, SessionTerminals>
}
export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({
name: "SessionTerminals",
init: () => {
const client = useClient()
const config = useConfig().data
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<SessionTerminalsState>("session-terminals-v1", {
initial: { sessions: {} },
})
const save = (sessionID: string, terminals: PersistentPtyInfo[], selectedTerminalID?: string) =>
update((draft) => {
const current = draft.sessions[sessionID]?.selectedTerminalID
const selected = selectedTerminalID ?? current
draft.sessions[sessionID] = {
terminals,
selectedTerminalID: terminals.some((terminal) => terminal.id === selected) ? selected : terminals.at(-1)?.id,
...(selectedTerminalID === undefined && draft.sessions[sessionID]?.hidden ? { hidden: true } : {}),
}
})
const refresh = async (sessionID: string) => {
await save(sessionID, await client.api.experimental.persistentPty.list({ sessionID }))
}
for (const type of ["persistent-pty.added", "persistent-pty.removed"] as const) {
onCleanup(
event.on(type, (evt) => {
if (!config.terminal?.enabled || !store.sessions[evt.data.sessionID]) return
void refresh(evt.data.sessionID).catch((error) =>
console.error("Failed to refresh persistent terminal panes", error),
)
}),
)
}
return {
get(sessionID: string) {
return store.sessions[sessionID]
},
refresh,
selectTerminal(sessionID: string, ptyID: string) {
setFocus(ptyID)
return update((draft) => {
const session = draft.sessions[sessionID]
if (!session?.terminals.some((terminal) => terminal.id === ptyID)) return
session.selectedTerminalID = ptyID
delete session.hidden
})
},
hideTerminal(sessionID: string) {
return update((draft) => {
const session = draft.sessions[sessionID]
if (session) session.hidden = true
})
},
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))
},
}
},
})

View file

@ -6,6 +6,8 @@ import { SplitBorder } from "../../../ui/border"
import { Keymap } from "../../../context/keymap"
import { SubagentsTab } from "./subagents-tab"
import { ShellTab } from "./shell-tab"
import { TerminalsTab } from "./terminals-tab"
import { useConfig } from "../../../config"
export interface ComposerHint {
label: string
@ -36,10 +38,12 @@ export type ComposerProps = {
open: boolean
defaultTab?: string
onClose?: () => void
visibleTerminalID?: string
}
export function Composer(props: ComposerProps) {
const theme = useTheme("elevated")
const config = useConfig().data
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,
@ -148,6 +152,9 @@ export function Composer(props: ComposerProps) {
</box>
<SubagentsTab sessionID={props.sessionID} />
<ShellTab sessionID={props.sessionID} />
<Show when={config.terminal?.enabled}>
<TerminalsTab sessionID={props.sessionID} visibleTerminalID={props.visibleTerminalID} />
</Show>
<box flexDirection="row" gap={2} paddingLeft={1} flexShrink={0}>
<For each={footerHints()}>
{(hint) => (

View file

@ -0,0 +1,114 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../../../context/keymap"
import { useSessionTerminals } from "../../../context/session-terminals"
import { useTheme } from "../../../context/theme"
import { useToast } from "../../../ui/toast"
import { useComposerTab } from "./index"
export function TerminalsTab(props: { sessionID: string; visibleTerminalID?: string }) {
const composer = useComposerTab()
const terminals = useSessionTerminals()
const theme = useTheme()
const toast = useToast()
const failure = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const [selected, setSelected] = createSignal<number>()
const session = () => terminals.get(props.sessionID)
const entries = () => session()?.terminals ?? []
onMount(() => {
const cleanup = composer.register({ id: "terminals", label: "Terminals" })
onCleanup(cleanup)
})
createEffect(() => {
if (!composer.active("terminals")) return
const index = entries().findIndex((terminal) => terminal.id === props.visibleTerminalID)
setSelected(index < 0 ? undefined : index)
})
const select = () => {
const index = selected()
if (index === undefined) return
const terminal = entries()[index]
composer.close()
if (terminal) {
void terminals.selectTerminal(props.sessionID, terminal.id).catch(failure)
return
}
void terminals.newTerminal(props.sessionID).catch(failure)
}
Keymap.createLayer(() => ({
mode: "composer",
enabled: () => composer.active("terminals"),
priority: 1,
commands: [
{
id: "composer.terminal.up",
title: "Previous terminal",
group: "Composer",
run: () => setSelected((index) => ((index ?? 0) + entries().length) % (entries().length + 1)),
},
{
id: "composer.terminal.down",
title: "Next terminal",
group: "Composer",
run: () => setSelected((index) => ((index ?? -1) + 1) % (entries().length + 1)),
},
{
id: "composer.terminal.select",
title: "Select terminal",
group: "Composer",
run: select,
},
],
}))
return (
<Show when={composer.active("terminals")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5}>
<For each={[...entries(), undefined]}>
{(terminal, index) => {
const active = createMemo(() => index() === selected())
const current = () => terminal !== undefined && terminal.id === props.visibleTerminalID
return (
<box
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={
active()
? theme.background.action.primary.focused
: current()
? theme.background.action.primary.selected
: theme.background.action.primary.default
}
onMouseOver={() => setSelected(index())}
onMouseUp={() => {
setSelected(index())
select()
}}
>
<text
fg={
active()
? theme.text.action.primary.focused
: current()
? theme.text.action.primary.selected
: theme.text.action.primary.default
}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{terminal?.foregroundProcess ?? terminal?.title ?? "+ New terminal"}
</text>
</box>
)
}}
</For>
</scrollbox>
</Show>
)
}

View file

@ -1,5 +1,4 @@
import {
batch,
createContext,
createEffect,
createMemo,
@ -60,7 +59,6 @@ import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
import { Sidebar } from "./sidebar"
import { Composer } from "./composer"
import { filetype } from "../../util/filetype"
import parsers from "../../parsers-config"
@ -111,6 +109,7 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useSessionTerminals } from "../../context/session-terminals"
addDefaultParsers(parsers.parsers)
@ -149,7 +148,14 @@ function use() {
return ctx
}
export function Session(props: { verticalTabsWidth: number }) {
export function Session(props: {
verticalTabsWidth: number
promptMuted?: boolean
sidebarVisible: boolean
onToggleSidebar: () => void
visibleTerminalID?: string
width?: number
}) {
const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
@ -220,6 +226,9 @@ export function Session(props: { verticalTabsWidth: number }) {
open: false,
tab: undefined as string | undefined,
})
createEffect(() => {
if (props.promptMuted && composer.open) setComposer("open", false)
})
const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0)
const lastAssistant = createMemo(() => {
@ -227,32 +236,23 @@ export function Session(props: { verticalTabsWidth: number }) {
})
const dimensions = useTerminalDimensions()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const availableWidth = createMemo(() => dimensions().width - props.verticalTabsWidth)
const wide = createMemo(() => availableWidth() > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
Keymap.createLayer(() => ({
priority: 10,
enabled: () => sidebarOpen() && !wide() && !disabled(),
commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: () => setSidebarOpen(false) }],
enabled: () => props.sidebarVisible && dimensions().width - props.verticalTabsWidth <= 120 && !disabled(),
commands: [{ bind: "escape,ctrl+c", title: "Close sidebar", group: "Session", run: props.onToggleSidebar }],
}))
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => (props.width ?? dimensions().width - props.verticalTabsWidth) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const toast = useToast()
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
const client = useClient()
const autoApproved = new Set<string>()
createEffect(() => {
@ -286,6 +286,7 @@ export function Session(props: { verticalTabsWidth: number }) {
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
@ -886,22 +887,68 @@ export function Session(props: { verticalTabsWidth: number }) {
},
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
id: "session.sidebar.toggle",
group: "Session",
run: () => {
batch(() => {
const isVisible = sidebarVisible()
void configState
.update((draft) => {
draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" }
})
.catch(toast.error)
setSidebarOpen(!isVisible)
})
props.onToggleSidebar()
dialog.clear()
},
},
...(config.terminal?.enabled
? [
{
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
id: "terminal.toggle",
group: "Session",
run: () => {
if (props.visibleTerminalID) {
promptRef.current?.focus()
void terminals.hideTerminal(route.sessionID).catch(toast.error)
} else {
const state = terminals.get(route.sessionID)
const terminal =
state?.terminals.find((item) => item.id === state.selectedTerminalID) ?? state?.terminals.at(-1)
if (terminal) void terminals.selectTerminal(route.sessionID, terminal.id).catch(terminalError)
else void terminals.newTerminal(route.sessionID).catch(terminalError)
}
dialog.clear()
},
},
{
title: "Select terminal",
id: "terminal.select",
group: "Session",
run: () => {
promptRef.current?.focus()
setComposer({ open: true, tab: "terminals" })
void terminals.refresh(route.sessionID).catch(terminalError)
dialog.clear()
},
},
{
title: "Close terminal pane",
id: "terminal.close",
group: "Session",
enabled: props.visibleTerminalID !== undefined,
run: () => {
promptRef.current?.focus()
void terminals.hideTerminal(route.sessionID).catch(toast.error)
dialog.clear()
},
},
{
title: "New terminal",
id: "session.terminal",
group: "Session",
slash: { name: "terminal" },
run: async () => {
dialog.clear()
await terminals.newTerminal(route.sessionID).catch(terminalError)
},
},
]
: []),
{
title: (() => {
const next = nextThinkingMode(thinkingMode())
@ -1121,7 +1168,7 @@ export function Session(props: { verticalTabsWidth: number }) {
group: "Session",
run: () => {
if (composer.open || session()?.parentID) setComposer("open", false)
else setComposer("open", true)
else setComposer({ open: true, tab: "subagents" })
dialog.clear()
},
},
@ -1308,6 +1355,7 @@ export function Session(props: { verticalTabsWidth: number }) {
}
setComposer("open", false)
}}
visibleTerminalID={props.visibleTerminalID}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
@ -1347,6 +1395,7 @@ export function Session(props: { verticalTabsWidth: number }) {
visible={true}
ref={bind}
disabled={false}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
}}
@ -1362,26 +1411,6 @@ export function Session(props: { verticalTabsWidth: number }) {
</box>
</Show>
</box>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
)

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 =

View file

@ -74,10 +74,28 @@ test("validates terminal copy behavior", () => {
expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } })
expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } })
expect(() => decodeInfo({ terminal: { copy: "always" } })).toThrow()
})
const setting = settings.find((setting) => setting.path.join(".") === "terminal.copy")
expect(setting?.values).toEqual(["manual", "select"])
expect(setting?.default).toBe(process.platform === "win32" ? "manual" : "select")
test("keeps persistent terminals disabled until explicitly enabled", () => {
const disabled = resolve({}, { terminalSuspend: true })
expect(disabled.terminal?.enabled ?? false).toBe(false)
expect(disabled.keybinds.get("theme.switch")).toMatchObject([{ key: "<leader>t" }])
expect(disabled.keybinds.get("terminal.toggle")).toEqual([])
expect(settings.find((setting) => setting.path.join(".") === "terminal.enabled")?.default).toBe(false)
expect(settings.filter((setting) => setting.category === "Terminal").map((setting) => setting.title)).toEqual([
"Enabled",
])
const enabled = resolve({ terminal: { enabled: true } }, { terminalSuspend: true })
expect(enabled.keybinds.get("terminal.toggle")).toMatchObject([{ key: "<leader>t" }])
expect(enabled.keybinds.get("theme.switch")).toEqual([])
const customized = resolve(
{ terminal: { enabled: true }, keybinds: { "theme.switch": "<leader>t", "terminal.toggle": "<leader>p" } },
{ terminalSuspend: true },
)
expect(customized.keybinds.get("theme.switch")).toMatchObject([{ key: "<leader>t" }])
expect(customized.keybinds.get("terminal.toggle")).toMatchObject([{ key: "<leader>p" }])
})
test("uses command IDs as keybind keys", () => {

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"])
})