Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
35e51b5
feat(tui): add persistent terminal workspaces
jlongster Aug 25, 2026
2d32f64
feat(tui): move terminal selection into composer tabs
jlongster Aug 25, 2026
e86a458
fix(tui): close composer when terminal gains focus
jlongster Aug 25, 2026
e27c531
refactor(tui): remove terminal pane title overlay
jlongster Aug 25, 2026
50c3ead
fix(tui): reset composer to subagents for prompt picker
jlongster Aug 25, 2026
5879f91
fix(tui): highlight only the visible terminal
jlongster Aug 25, 2026
4848f9e
feat(tui): fade the first two terminal rows
jlongster Aug 25, 2026
232c272
refactor(tui): remove terminal row fade overlays
jlongster Aug 26, 2026
bc15a09
feat(tui): fade terminal rows into default background
jlongster Aug 26, 2026
b8a32ec
refactor(tui): remove terminal row fade
jlongster Aug 26, 2026
e8b01e5
feat(tui): make persistent terminals opt-in
jlongster Aug 26, 2026
6fa9c62
fix(tui): independently toggle sidebar and terminal panes
jlongster Aug 26, 2026
4295baf
fix(tui): animate right pane closed
jlongster Aug 26, 2026
fe5f1a7
fix(tui): report terminal loading failures clearly
jlongster Aug 26, 2026
260de36
fix(tui): preserve preview tabs when restoring selected sessions
jlongster Aug 26, 2026
da1628c
refactor(tui): remove pane width animation
jlongster Aug 26, 2026
b3d684f
refactor(tui): simplify terminal settings
jlongster Aug 26, 2026
1aa18f4
refactor(tui): simplify right pane selection
jlongster Aug 26, 2026
a631349
refactor(tui): remove session tab restoration changes
jlongster Aug 26, 2026
fec340f
chore: update persistent PTY daemon to 0.1.9
jlongster Aug 26, 2026
7d6c792
fix(tui): avoid highlighting new terminal without an active pane
jlongster Aug 26, 2026
bf5fec4
refactor(tui): keep sidebar sizing unchanged
jlongster Aug 26, 2026
833d7fe
refactor(tui): simplify terminal pane state and composition
jlongster Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
31 changes: 30 additions & 1 deletion packages/client/src/solid/pty.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OpenCodeClient, PtyConnectTokenInput } from "../promise"
import type { ExperimentalPersistentPtyConnectTokenInput, OpenCodeClient, PtyConnectTokenInput } from "../promise"

export type PtyClientOptions = {
readonly url: string
Expand All @@ -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) {
Expand All @@ -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
},
}
}
35 changes: 34 additions & 1 deletion packages/client/test/solid-pty.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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")
})
})
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
91 changes: 49 additions & 42 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
Expand Down Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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"}>
Expand Down
16 changes: 4 additions & 12 deletions packages/tui/src/component/dialog-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,21 +265,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",
Expand Down
Loading
Loading