feat(app): support current pty transport (#38463)

This commit is contained in:
Brendan Allan 2026-07-24 18:20:54 +08:00 committed by GitHub
commit ad78ef5a4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 640 additions and 156 deletions

View file

@ -127,11 +127,13 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
return (await sdk.api.pty.shells()).data
},
{ initialValue: [] as ShellOption[] },
)

View file

@ -122,11 +122,13 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
return (await sdk.api.pty.shells()).data
},
{ initialValue: [] as ShellOption[] },
)

View file

@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory
const client = sdk().client
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => {
}
}
const pushSize = (cols: number, rows: number) => {
return client.pty
const pushSize = async (cols: number, rows: number) => {
if ((await sdk().protocol) === "v1") {
return sdk().client.pty
.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
return sdk().api.pty
.update({
ptyID: id,
location: { directory },
size: { cols, rows },
})
.catch((err) => {
@ -522,34 +532,60 @@ export const Terminal = (props: TerminalProps) => {
local.onConnectError?.(err)
}
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
const gone = async () => {
if ((await sdk().protocol) === "v1") {
return sdk().client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
}
return sdk().api.pty
.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
}
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
if ((await sdk().protocol) === "v1") {
const result = await sdk().client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
return sdk().api.pty
.connectToken({
ptyID: id,
location: { directory },
"x-opencode-ticket": "1",
})
.then((result) => result.data.ticket)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
}
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
@ -579,11 +615,14 @@ export const Terminal = (props: TerminalProps) => {
fail(err)
return undefined
})
const protocol = await sdk().protocol
if (protocol === "v2" && !ticket) return
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
protocol,
url,
id,
directory,

View file

@ -424,6 +424,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return {
scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory,
client,
api: createCompatibleApi({

View file

@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue,
legacySessionID?: string,
) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
@ -240,47 +241,61 @@ function createWorkspaceTerminalSession(
})
onCleanup(unsub)
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const update = (pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
const doUpdate = async () => {
if ((await sdk.protocol) === "v1") {
await sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
} else {
await sdk.api.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
}
doUpdate().catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
}
const clone = async (client: DirectorySDK["client"], id: string) => {
const clone = async (id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
.create({
const data = await (async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: pty.title })).data
}
return (await sdk.api.pty.create({
location,
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next?.data) return
})).data
})().catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.data.id,
title: next.data.title ?? pty.title,
id: data.id,
title: data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
@ -289,7 +304,7 @@ function createWorkspaceTerminalSession(
cols: undefined,
})
if (active) {
setStore("active", next.data.id)
setStore("active", data.id)
}
})
}
@ -308,17 +323,22 @@ function createWorkspaceTerminalSession(
const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
const doCreate = async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
}
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
}
doCreate()
.then((data) => {
const id = data?.id
if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
title: data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
batch(() => {
@ -335,7 +355,7 @@ function createWorkspaceTerminalSession(
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty)
update(pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@ -350,10 +370,9 @@ function createWorkspaceTerminalSession(
})
},
async clone(id: string) {
await clone(sdk.client, id)
await clone(id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@ -361,10 +380,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty)
update(pty)
},
async clone(id: string) {
await clone(client, id)
await clone(id)
},
}
},
@ -412,7 +431,9 @@ function createWorkspaceTerminalSession(
})
}
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
const removePromise =
(await sdk.protocol) === "v1" ? sdk.client.pty.remove({ ptyID: id }) : sdk.api.pty.remove({ ptyID: id, location })
await removePromise.catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},

View file

@ -1,6 +1,7 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element

View file

@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -56,15 +57,16 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffs: () => ReviewDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean

View file

@ -1,14 +1,15 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}

View file

@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@ -30,7 +31,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@ -9,7 +10,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
} satisfies FileDiffInfo & SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {

View file

@ -1,7 +1,8 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false

View file

@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
test("uses the current ticketed PTY route", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/api/pty/pty_test/connect")
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth without embedding credentials in websocket URL for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => {
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
test("omits query auth for same-origin saved credentials for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
test("uses query auth for same-origin credentials from auth_token for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => {
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View file

@ -1,6 +1,7 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
protocol?: "v1" | "v2"
url: string
id: string
directory: string
@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: {
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
const isV1 = input.protocol === "v1"
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
if (isV1) {
next.searchParams.set("directory", input.directory)
} else {
next.searchParams.set("location[directory]", input.directory)
}
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
}
return next
}