Compare commits

...

5 Commits

Author SHA1 Message Date
James Long 77f7d5366c chore(sdk): regenerate persistent pty client 2026-08-14 21:22:15 +00:00
James Long ca1064bf80 fix(tui): stabilize persistent terminal workspaces 2026-08-14 21:16:45 +00:00
James Long bb77765056 wip 2026-08-14 21:16:10 +00:00
James Long 0aab2ed553 feat(core): add persistent PTY backend 2026-08-14 21:14:17 +00:00
James Long 182fe17f12 fix(tui): remove shadow scrim under horizontal tab strip (#42650) 2026-08-14 16:06:32 -04:00
42 changed files with 3918 additions and 275 deletions
@@ -4,11 +4,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
}),
)
@@ -56,12 +56,22 @@ function managedService(options: EnsureOptions) {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
options: EnsureOptions,
) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* Effect.tryPromise(() => client["server.persistentPty"].shutdown())
})
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+266 -59
View File
@@ -32,6 +32,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { Vcs } from "@opencode-ai/schema/vcs"
@@ -1435,44 +1436,249 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E>
}
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_0Output = ReadonlyArray<{
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}>
export type ServerPersistentPtyGroupListOperation<E = never> = () => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
| undefined
}
export type Endpoint21_1Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupCreateOperation<E = never> = (
input?: Endpoint21_1Input,
) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint21_2Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_2Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupGetOperation<E = never> = (
input: Endpoint21_2Input,
) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type Endpoint21_3Output = {
readonly id: string & Brand.Brand<"GroupID">
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
>
}
export type ServerPersistentPtyGroupSetOperation<E = never> = (
input: Endpoint21_3Input,
) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint21_4Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_4Output = void
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
input: Endpoint21_4Input,
) => Effect.Effect<Endpoint21_4Output, E>
export type Endpoint21_5Input = { readonly groupID: string & Brand.Brand<"GroupID"> }
export type Endpoint21_5Output = ReadonlyArray<{
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ServerPersistentPtyListOperation<E = never> = (
input: Endpoint21_5Input,
) => Effect.Effect<Endpoint21_5Output, E>
export type Endpoint21_6Input = {
readonly groupID: string & Brand.Brand<"GroupID">
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
}
export type Endpoint21_6Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyCreateOperation<E = never> = (
input: Endpoint21_6Input,
) => Effect.Effect<Endpoint21_6Output, E>
export type Endpoint21_7Output = void
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<Endpoint21_7Output, E>
export type Endpoint21_8Input = { readonly ptyID: Pty.ID }
export type Endpoint21_8Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyGetOperation<E = never> = (
input: Endpoint21_8Input,
) => Effect.Effect<Endpoint21_8Output, E>
export type Endpoint21_9Input = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type Endpoint21_9Output = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ServerPersistentPtyUpdateOperation<E = never> = (
input: Endpoint21_9Input,
) => Effect.Effect<Endpoint21_9Output, E>
export type Endpoint21_10Input = { readonly ptyID: Pty.ID }
export type Endpoint21_10Output = {
readonly info: {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly groupID: string & Brand.Brand<"GroupID">
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
readonly text: string
readonly checkpoint: globalThis.Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type ServerPersistentPtySnapshotOperation<E = never> = (
input: Endpoint21_10Input,
) => Effect.Effect<Endpoint21_10Output, E>
export type Endpoint21_11Input = { readonly ptyID: Pty.ID }
export type Endpoint21_11Output = void
export type ServerPersistentPtyRemoveOperation<E = never> = (
input: Endpoint21_11Input,
) => Effect.Effect<Endpoint21_11Output, E>
export type Endpoint21_12Input = { readonly ptyID: Pty.ID }
export type Endpoint21_12Output = PtyTicket.ConnectToken
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
input: Endpoint21_12Input,
) => Effect.Effect<Endpoint21_12Output, E>
export type Endpoint21_13Input = { readonly ptyID: Pty.ID }
export type Endpoint21_13Output = boolean
export type ServerPersistentPtyConnectOperation<E = never> = (
input: Endpoint21_13Input,
) => Effect.Effect<Endpoint21_13Output, E>
export interface ServerPersistentPtyApi<E = never> {
readonly group: {
readonly list: ServerPersistentPtyGroupListOperation<E>
readonly create: ServerPersistentPtyGroupCreateOperation<E>
readonly get: ServerPersistentPtyGroupGetOperation<E>
readonly set: ServerPersistentPtyGroupSetOperation<E>
readonly remove: ServerPersistentPtyGroupRemoveOperation<E>
}
readonly list: ServerPersistentPtyListOperation<E>
readonly create: ServerPersistentPtyCreateOperation<E>
readonly shutdown: ServerPersistentPtyShutdownOperation<E>
readonly get: ServerPersistentPtyGetOperation<E>
readonly update: ServerPersistentPtyUpdateOperation<E>
readonly snapshot: ServerPersistentPtySnapshotOperation<E>
readonly remove: ServerPersistentPtyRemoveOperation<E>
readonly connectToken: ServerPersistentPtyConnectTokenOperation<E>
readonly connect: ServerPersistentPtyConnectOperation<E>
}
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint22_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type Endpoint21_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effect.Effect<Endpoint21_1Output, E>
export type Endpoint22_1Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellCreateOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
export type Endpoint21_2Input = {
export type Endpoint22_2Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint22_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
export type Endpoint21_3Input = {
export type Endpoint22_3Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number
}
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export type Endpoint22_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
export type Endpoint21_4Input = {
export type Endpoint22_4Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}
export type Endpoint21_4Output = {
export type Endpoint22_4Output = {
readonly location: Location.Info
readonly data: {
readonly output: string
@@ -1481,14 +1687,14 @@ export type Endpoint21_4Output = {
readonly truncated: boolean
}
}
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
export type ShellOutputOperation<E = never> = (input: Endpoint22_4Input) => Effect.Effect<Endpoint22_4Output, E>
export type Endpoint21_5Input = {
export type Endpoint22_5Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
export type Endpoint22_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint22_5Input) => Effect.Effect<Endpoint22_5Output, E>
export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
@@ -1499,41 +1705,41 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint22_0Input = {
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint23_0Input = { readonly projectID: Project.ID }
export type Endpoint23_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint23_1Input = {
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint23_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint23_2Input = {
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint23_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint23_3Input = { readonly projectID: Project.ID }
export type Endpoint23_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
@@ -1542,25 +1748,25 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type Endpoint24_0Input = {
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint24_1Input = {
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint24_2Input = {
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
@@ -1568,20 +1774,20 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint25_1Input = {
export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint26_0Output =
export type Endpoint27_0Output =
| { readonly status: "required" | "completed" }
| {
readonly status: "running"
@@ -1592,36 +1798,36 @@ export type Endpoint26_0Output =
}
}
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint27_0Input = {
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint27_1Input = {
export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
export interface WebsearchApi<E = never> {
readonly providers: WebsearchProvidersOperation<E>
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint28_0Input = {
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
@@ -1649,6 +1855,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly "server.persistentPty": ServerPersistentPtyApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
+238 -81
View File
@@ -186,7 +186,6 @@ import type {
Endpoint20_3Output,
Endpoint20_4Input,
Endpoint20_4Output,
Endpoint21_0Input,
Endpoint21_0Output,
Endpoint21_1Input,
Endpoint21_1Output,
@@ -198,32 +197,59 @@ import type {
Endpoint21_4Output,
Endpoint21_5Input,
Endpoint21_5Output,
Endpoint21_6Input,
Endpoint21_6Output,
Endpoint21_7Output,
Endpoint21_8Input,
Endpoint21_8Output,
Endpoint21_9Input,
Endpoint21_9Output,
Endpoint21_10Input,
Endpoint21_10Output,
Endpoint21_11Input,
Endpoint21_11Output,
Endpoint21_12Input,
Endpoint21_12Output,
Endpoint21_13Input,
Endpoint21_13Output,
Endpoint22_0Input,
Endpoint22_0Output,
Endpoint22_1Input,
Endpoint22_1Output,
Endpoint22_2Input,
Endpoint22_2Output,
Endpoint22_3Input,
Endpoint22_3Output,
Endpoint22_4Input,
Endpoint22_4Output,
Endpoint22_5Input,
Endpoint22_5Output,
Endpoint23_0Input,
Endpoint23_0Output,
Endpoint23_1Input,
Endpoint23_1Output,
Endpoint23_2Input,
Endpoint23_2Output,
Endpoint23_3Input,
Endpoint23_3Output,
Endpoint24_0Input,
Endpoint24_0Output,
Endpoint24_1Input,
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
Endpoint25_1Output,
Endpoint25_2Input,
Endpoint25_2Output,
Endpoint26_0Output,
Endpoint27_0Input,
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Output,
Endpoint27_1Input,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
Endpoint28_1Output,
Endpoint29_0Input,
Endpoint29_0Output,
} from "../api/api.js"
import { ClientError } from "./client-error.js"
@@ -1097,28 +1123,158 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
remove: Endpoint20_4(raw),
})
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
const Endpoint21_0 = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<Endpoint21_0Output>()(
raw["persistentPty.group.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_1 = (raw: RawClient["server.persistentPty"]) => (input?: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_2 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_3 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
raw["persistentPty.group.set"]({ params: { groupID: input["groupID"] }, payload: { items: input["items"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_4 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_5 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_6 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_6Input) =>
preserveEffect<Endpoint21_6Output>()(
raw["persistentPty.create"]({
params: { groupID: input["groupID"] },
payload: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_7 = (raw: RawClient["server.persistentPty"]) => () =>
preserveEffect<Endpoint21_7Output>()(raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint21_8 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_8Input) =>
preserveEffect<Endpoint21_8Output>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_9 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_9Input) =>
preserveEffect<Endpoint21_9Output>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_10 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_10Input) =>
preserveEffect<Endpoint21_10Output>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_11 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_11Input) =>
preserveEffect<Endpoint21_11Output>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_12 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_12Input) =>
preserveEffect<Endpoint21_12Output>()(
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint21_13 = (raw: RawClient["server.persistentPty"]) => (input: Endpoint21_13Input) =>
preserveEffect<Endpoint21_13Output>()(
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup21 = (raw: RawClient["server.persistentPty"]) => ({
group: {
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
set: Endpoint21_3(raw),
remove: Endpoint21_4(raw),
},
list: Endpoint21_5(raw),
create: Endpoint21_6(raw),
shutdown: Endpoint21_7(raw),
get: Endpoint21_8(raw),
update: Endpoint21_9(raw),
snapshot: Endpoint21_10(raw),
remove: Endpoint21_11(raw),
connectToken: Endpoint21_12(raw),
connect: Endpoint21_13(raw),
})
const Endpoint22_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) =>
preserveEffect<Endpoint21_1Output>()(
const Endpoint22_1 = (raw: RawClient["server.shell"]) => (input: Endpoint22_1Input) =>
preserveEffect<Endpoint22_1Output>()(
raw["shell.create"]({
query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) =>
preserveEffect<Endpoint21_2Output>()(
const Endpoint22_2 = (raw: RawClient["server.shell"]) => (input: Endpoint22_2Input) =>
preserveEffect<Endpoint22_2Output>()(
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) =>
preserveEffect<Endpoint21_3Output>()(
const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Input) =>
preserveEffect<Endpoint22_3Output>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
@@ -1126,134 +1282,134 @@ const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Inp
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
preserveEffect<Endpoint21_4Output>()(
const Endpoint22_4 = (raw: RawClient["server.shell"]) => (input: Endpoint22_4Input) =>
preserveEffect<Endpoint22_4Output>()(
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) =>
preserveEffect<Endpoint21_5Output>()(
const Endpoint22_5 = (raw: RawClient["server.shell"]) => (input: Endpoint22_5Input) =>
preserveEffect<Endpoint22_5Output>()(
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
list: Endpoint21_0(raw),
create: Endpoint21_1(raw),
get: Endpoint21_2(raw),
timeout: Endpoint21_3(raw),
output: Endpoint21_4(raw),
remove: Endpoint21_5(raw),
const adaptGroup22 = (raw: RawClient["server.shell"]) => ({
list: Endpoint22_0(raw),
create: Endpoint22_1(raw),
get: Endpoint22_2(raw),
timeout: Endpoint22_3(raw),
output: Endpoint22_4(raw),
remove: Endpoint22_5(raw),
})
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
preserveEffect<Endpoint22_0Output>()(
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
preserveEffect<Endpoint23_0Output>()(
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
preserveEffect<Endpoint23_1Output>()(
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
preserveEffect<Endpoint23_2Output>()(
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
preserveEffect<Endpoint23_3Output>()(
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint23_0(raw),
create: Endpoint23_1(raw),
remove: Endpoint23_2(raw),
refresh: Endpoint23_3(raw),
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
})
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
preserveEffect<Endpoint25_0Output>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
preserveEffect<Endpoint25_2Output>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint24_0(raw),
status: Endpoint24_1(raw),
diff: Endpoint24_2(raw),
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
get: Endpoint25_0(raw),
status: Endpoint25_1(raw),
diff: Endpoint25_2(raw),
})
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
preserveEffect<Endpoint25_1Output>()(
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
preserveEffect<Endpoint26_1Output>()(
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
})
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
preserveEffect<Endpoint27_0Output>()(
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
preserveEffect<Endpoint27_1Output>()(
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
preserveEffect<Endpoint28_1Output>()(
raw["websearch.query"]({
query: { location: input["location"] },
payload: { query: input["query"], providerID: input["providerID"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint27_0(raw),
query: Endpoint27_1(raw),
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
providers: Endpoint28_0(raw),
query: Endpoint28_1(raw),
})
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
preserveEffect<Endpoint29_0Output>()(
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
@@ -1277,14 +1433,15 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroup18(raw["server.skill"]),
event: adaptGroup19(raw["server.event"]),
pty: adaptGroup20(raw["server.pty"]),
shell: adaptGroup21(raw["server.shell"]),
reference: adaptGroup22(raw["server.reference"]),
worktree: adaptGroup23(raw["server.worktree"]),
vcs: adaptGroup24(raw["server.vcs"]),
debug: adaptGroup25(raw["server.debug"]),
migration: adaptGroup26(raw["server.migration"]),
websearch: adaptGroup27(raw["server.websearch"]),
config: adaptGroup28(raw["server.config"]),
"server.persistentPty": adaptGroup21(raw["server.persistentPty"]),
shell: adaptGroup22(raw["server.shell"]),
reference: adaptGroup23(raw["server.reference"]),
worktree: adaptGroup24(raw["server.worktree"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
websearch: adaptGroup28(raw["server.websearch"]),
config: adaptGroup29(raw["server.config"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
@@ -182,6 +182,32 @@ import type {
PtyUpdateOutput,
PtyRemoveInput,
PtyRemoveOutput,
ServerPersistentPtyGroupListOutput,
ServerPersistentPtyGroupCreateInput,
ServerPersistentPtyGroupCreateOutput,
ServerPersistentPtyGroupGetInput,
ServerPersistentPtyGroupGetOutput,
ServerPersistentPtyGroupSetInput,
ServerPersistentPtyGroupSetOutput,
ServerPersistentPtyGroupRemoveInput,
ServerPersistentPtyGroupRemoveOutput,
ServerPersistentPtyListInput,
ServerPersistentPtyListOutput,
ServerPersistentPtyCreateInput,
ServerPersistentPtyCreateOutput,
ServerPersistentPtyShutdownOutput,
ServerPersistentPtyGetInput,
ServerPersistentPtyGetOutput,
ServerPersistentPtyUpdateInput,
ServerPersistentPtyUpdateOutput,
ServerPersistentPtySnapshotInput,
ServerPersistentPtySnapshotOutput,
ServerPersistentPtyRemoveInput,
ServerPersistentPtyRemoveOutput,
ServerPersistentPtyConnectTokenInput,
ServerPersistentPtyConnectTokenOutput,
ServerPersistentPtyConnectInput,
ServerPersistentPtyConnectOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -1571,6 +1597,175 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
"server.persistentPty": {
group: {
list: (requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupListOutput }>(
{
method: "GET",
path: `/api/pty-group`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input?: ServerPersistentPtyGroupCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupCreateOutput }>(
{
method: "POST",
path: `/api/pty-group`,
body: { items: input?.["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: ServerPersistentPtyGroupGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupGetOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
set: (input: ServerPersistentPtyGroupSetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGroupSetOutput }>(
{
method: "PUT",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
body: { items: input["items"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyGroupRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyGroupRemoveOutput>(
{
method: "DELETE",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
list: (input: ServerPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ServerPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
body: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
shutdown: (requestOptions?: RequestOptions) =>
request<ServerPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ServerPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ServerPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
snapshot: (input: ServerPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ServerPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ServerPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ServerPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
connect: (input: ServerPersistentPtyConnectInput, requestOptions?: RequestOptions) =>
request<ServerPersistentPtyConnectOutput>(
{
method: "GET",
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
),
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
@@ -328,6 +328,8 @@ export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
export type GroupItem = { type: "session"; id: string } | { type: "terminal"; id: string }
export type SessionStatus =
| { type: "idle" }
| {
@@ -339,6 +341,22 @@ export type SessionStatus =
}
| { type: "busy" }
export type PersistentPtyInfo = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
groupID: string
size: { cols: number; rows: number }
output: { head: number; tail: number }
}
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -1475,6 +1493,26 @@ export type FormMultiselectField1 = {
default?: Array<string>
}
export type GroupItemAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "group.item.added"
location?: LocationRef
data: { groupID: string; item: GroupItem }
}
export type GroupItemRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "group.item.removed"
location?: LocationRef
data: { groupID: string; item: GroupItem }
}
export type GroupInfo = { id: string; items: Array<GroupItem> }
export type SessionStatus2 = {
id: string
created: number
@@ -1484,6 +1522,13 @@ export type SessionStatus2 = {
data: { sessionID: string; status: SessionStatus }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
checkpoint: string
cursor: { x: number; y: number }
}
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
@@ -2075,6 +2120,8 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| GroupItemAdded
| GroupItemRemoved
| WebsearchUpdated
| SessionStatus2
| SessionIdle
@@ -2247,6 +2294,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ForbiddenError = { readonly _tag: "ForbiddenError"; readonly message: string }
export const isForbiddenError = (value: unknown): value is ForbiddenError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ForbiddenError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
@@ -5435,6 +5486,133 @@ export type PtyRemoveInput = {
export type PtyRemoveOutput = void
export type ServerPersistentPtyGroupListOutput = { data: Array<GroupInfo> }["data"]
export type ServerPersistentPtyGroupCreateInput = {
readonly items?: {
readonly items?:
| ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
| undefined
}["items"]
}
export type ServerPersistentPtyGroupCreateOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupGetInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupGetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupSetInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly items: {
readonly items: ReadonlyArray<
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
>
}["items"]
}
export type ServerPersistentPtyGroupSetOutput = { data: GroupInfo }["data"]
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyGroupRemoveOutput = void
export type ServerPersistentPtyListInput = { readonly groupID: { readonly groupID: string }["groupID"] }
export type ServerPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
export type ServerPersistentPtyCreateInput = {
readonly groupID: { readonly groupID: string }["groupID"]
readonly command: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["command"]
readonly args: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["args"]
readonly cwd: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["cwd"]
readonly title: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["title"]
readonly env: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["env"]
readonly size?: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ServerPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyShutdownOutput = void
export type ServerPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly attachmentID?: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["attachmentID"]
readonly size: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ServerPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
export type ServerPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
export type ServerPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyRemoveOutput = void
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
export type ServerPersistentPtyConnectInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ServerPersistentPtyConnectOutput = boolean
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+2
View File
@@ -0,0 +1,2 @@
export { PersistentPty } from "./persistent-pty/index.js"
export { Group } from "./persistent-pty/group.js"
+103
View File
@@ -0,0 +1,103 @@
export * as Group from "./group.js"
import { Group } from "@opencode-ai/schema/group"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Bus } from "../bus.js"
import { KV } from "../kv.js"
export const ID = Group.ID
export type ID = Group.ID
export const Item = Group.Item
export type Item = Group.Item
export const Info = Group.Info
export type Info = Group.Info
export const Event = Group.Event
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly create: (items?: ReadonlyArray<Item>) => Effect.Effect<Info>
readonly set: (group: Info) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Group") {}
const key = "group:v1"
const Document = Schema.Array(Info)
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const bus = yield* Bus.Service
const lock = Semaphore.makeUnsafe(1)
const list = Effect.fn("Group.list")(function* () {
const value = yield* kv.get(key)
return Schema.is(Document)(value) ? value : []
})
return Service.of({
list,
get: Effect.fn("Group.get")(function* (id) {
return (yield* list()).find((group) => group.id === id)
}),
create: Effect.fn("Group.create")(function* (items = []) {
return yield* lock.withPermit(
Effect.gen(function* () {
const group = Info.make({ id: ID.create(), items: Array.from(items) })
yield* kv.set(key, (yield* list()).concat(group))
return group
}),
)
}),
set: Effect.fn("Group.set")(function* (group) {
yield* lock.withPermit(
Effect.gen(function* () {
const groups = yield* list()
const index = groups.findIndex((item) => item.id === group.id)
yield* kv.set(
key,
index === -1 ? groups.concat(group) : groups.map((item) => (item.id === group.id ? group : item)),
)
const previous = groups[index]
if (!previous) return
yield* Effect.forEach(
group.items.filter(
(item) => !previous.items.some((current) => current.type === item.type && current.id === item.id),
),
(item) => bus.publish(Event.ItemAdded, { groupID: group.id, item }),
{ discard: true },
)
yield* Effect.forEach(
previous.items.filter(
(item) => !group.items.some((next) => next.type === item.type && next.id === item.id),
),
(item) => bus.publish(Event.ItemRemoved, { groupID: group.id, item }),
{ discard: true },
)
}),
)
}),
remove: Effect.fn("Group.remove")(function* (id) {
yield* lock.withPermit(
Effect.gen(function* () {
const groups = yield* list()
const group = groups.find((group) => group.id === id)
yield* kv.set(key, groups.filter((group) => group.id !== id))
if (!group) return
yield* Effect.forEach(
group.items,
(item) => bus.publish(Event.ItemRemoved, { groupID: id, item }),
{ discard: true },
)
}),
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Bus.node] })
+736
View File
@@ -0,0 +1,736 @@
export * as PersistentPty from "./index.js"
import { spawn } from "node:child_process"
import { createHash } from "node:crypto"
import { readFile } from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { setTimeout } from "node:timers/promises"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Group } from "./group.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
const ProtocolVersion = 4
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
Schema.Struct({ status: Schema.Literal("running") }),
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
])
const WireTerminal = Schema.Struct({
id: Schema.Number,
pid: Schema.NullOr(Schema.Number),
title: Schema.String,
group_id: Schema.String,
command: Schema.Array(Schema.String),
cwd: Schema.String,
cols: Schema.Number,
rows: Schema.Number,
lifecycle: Lifecycle,
output_head: Schema.Number,
output_tail: Schema.Number,
})
const Registration = Schema.Struct({
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
socket: Schema.String,
token: Schema.String,
})
const Response = Schema.Union([
Schema.Struct({
type: Schema.Literal("pong"),
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
text: Schema.String,
checkpoint_base64: Schema.String,
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("attached"),
terminal: WireTerminal,
role: Schema.Literals(["controller", "observer"]),
generation: Schema.Number,
requested_offset: Schema.Number,
available_offset: Schema.Number,
end_offset: Schema.Number,
truncated: Schema.Boolean,
replay_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("resized"),
cols: Schema.Number,
rows: Schema.Number,
generation: Schema.Number,
checkpoint_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("exited"),
exit_code: Schema.NullOr(Schema.Number),
final_offset: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("controller_changed"),
attachment_id: Schema.NullOr(Schema.String),
generation: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
])
type WireTerminal = typeof WireTerminal.Type
type WireResponse = typeof Response.Type
type Registration = typeof Registration.Type
export type Role = "controller" | "observer"
export type Info = Pty.Info & {
readonly groupID: Group.ID
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type StreamEvent =
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
| {
readonly type: "resized"
readonly cols: number
readonly rows: number
readonly generation: number
readonly checkpoint: Uint8Array
}
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
export type Attachment = {
readonly info: Info
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("PersistentPty.UnavailableError", {
message: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PersistentPty.NotFoundError", {
ptyID: Pty.ID,
}) {}
export class GroupNotFoundError extends Schema.TaggedErrorClass<GroupNotFoundError>()(
"PersistentPty.GroupNotFoundError",
{ groupID: Group.ID },
) {}
export interface Interface {
readonly list: (groupID?: Group.ID) => Effect.Effect<Info[], UnavailableError>
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
readonly create: (
groupID: Group.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) => Effect.Effect<Info, GroupNotFoundError | UnavailableError>
readonly write: (
id: Pty.ID,
data: string,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly resize: (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly control: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly input: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const groups = yield* Group.Service
const database = yield* Database.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const client = new Client(runtimeDirectory(databasePath(database.db)))
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (groupID?: Group.ID) {
const response = yield* optionalRequest(client, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => groupID === undefined || terminal.groupID === groupID)
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
const create = Effect.fn("PersistentPty.create")(function* (
groupID: Group.ID,
input: {
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const group = yield* groups.get(groupID)
if (!group) return yield* new GroupNotFoundError({ groupID })
const response = yield* request(client, {
op: "create",
program: input.command,
args: input.args,
cwd: input.cwd,
title: input.title,
group_id: groupID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
}, true)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* groups.set(
Group.Info.make({
id: group.id,
items: group.items.concat({ type: "terminal", id: terminal.id }),
}),
)
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (
id: Pty.ID,
data: string,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(client, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(client, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(client, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(client, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(client, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(client, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
const group = yield* groups.get(terminal.groupID)
if (!group) return undefined
yield* groups.set(
Group.Info.make({
id: group.id,
items: group.items.filter((item) => item.type !== "terminal" || item.id !== id),
}),
)
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* Effect.tryPromise({ try: () => client.shutdown(), catch: unavailable })
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
return yield* Effect.tryPromise({
try: () =>
client.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
},
}),
catch: (error) => unavailable(error),
})
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Group.node, Database.node] })
class Client {
private registration?: Promise<Registration>
constructor(private readonly directory: string) {}
request(value: object, start = false): Promise<WireResponse> {
return this.connect(start)
.then((registration) => oneShot(registration, value))
.catch((error) => {
if (!(error instanceof ConnectError)) throw error
this.registration = undefined
if (!start) throw error
return this.connect(true).then((registration) => oneShot(registration, value))
})
}
requestIfRunning(value: object) {
return this.request(value).catch(() => undefined)
}
async shutdown() {
const response = await this.requestIfRunning({ op: "shutdown" })
this.registration = undefined
if (!response) return
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const running = await discover(this.directory).then(
() => true,
() => false,
)
if (!running) return response
await setTimeout(50)
}
throw new Error("opencode-pty did not stop")
}
async subscribe(
id: number,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
): Promise<Attachment> {
const registration = await this.connect(false)
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
await connected(socket)
socket.write(
encode({
token: registration.token,
request: {
op: "subscribe",
id,
offset: input.cursor,
attachment_id: input.attachmentID,
role: input.role,
takeover: input.takeover ?? false,
},
}),
)
const initial = await frames.next()
if (initial.done) throw new Error("opencode-pty closed before attachment")
const response = decode(initial.value)
if (response.type === "error") throw new Error(response.message)
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
let detached = false
const pump = async () => {
try {
for await (const frame of frames) {
if (frame[0] === 0) {
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
input.onEvent({
type: "output",
start: Number(frame.readBigUInt64BE(1)),
end: Number(frame.readBigUInt64BE(9)),
data: frame.subarray(17),
})
continue
}
const event = decode(frame)
if (event.type === "resized")
input.onEvent({
type: "resized",
cols: event.cols,
rows: event.rows,
generation: event.generation,
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
})
if (event.type === "controller_changed")
input.onEvent({
type: "controller_changed",
attachmentID: event.attachment_id ?? undefined,
generation: event.generation,
})
if (event.type === "exited") {
input.onEvent({
type: "exited",
exitCode: event.exit_code ?? undefined,
finalOffset: event.final_offset,
})
return
}
}
} finally {
if (!detached) input.onEnd()
}
}
let activated = false
return {
info: toInfo(response.terminal),
role: response.role,
generation: response.generation,
replay: {
requestedOffset: response.requested_offset,
availableOffset: response.available_offset,
endOffset: response.end_offset,
truncated: response.truncated,
data: Buffer.from(response.replay_base64, "base64"),
},
activate() {
if (activated || detached) return
activated = true
void pump().catch(() => {})
},
detach() {
if (detached) return
detached = true
socket.destroy()
},
}
}
private connect(start: boolean) {
this.registration ??= start ? ensure(this.directory) : discover(this.directory)
return this.registration.catch((error) => {
this.registration = undefined
throw error
})
}
}
const request = (client: Client, value: object, start = false) =>
Effect.tryPromise({ try: () => client.request(value, start), catch: (error) => unavailable(error) })
const optionalRequest = (client: Client, value: object) =>
Effect.promise(() => client.requestIfRunning(value))
const unexpected = (response: WireResponse) =>
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
: path.join(
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
}
const registrationPath = (directory: string) => path.join(directory, "service.json")
async function ensure(directory: string) {
const found = await discover(directory).catch(() => undefined)
if (found) return found
await new Promise<void>((resolve, reject) => {
const child = spawn(process.env.OPENCODE_PTY_BIN || "opencode-pty", ["daemon"], {
detached: true,
stdio: "ignore",
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
})
const deadline = Date.now() + 5_000
let last: unknown
while (Date.now() < deadline) {
try {
return await discover(directory)
} catch (error) {
last = error
await setTimeout(50)
}
}
throw last instanceof Error ? last : new Error("opencode-pty did not become ready")
}
async function discover(directory: string) {
const registration = Schema.decodeUnknownSync(Registration)(
JSON.parse(await readFile(registrationPath(directory), "utf8")),
)
if (registration.protocol !== ProtocolVersion) throw new Error("opencode-pty protocol mismatch")
const response = await oneShot(registration, { op: "ping" })
if (
response.type !== "pong" ||
response.instance_id !== registration.instance_id ||
response.pid !== registration.pid ||
response.protocol !== ProtocolVersion
)
throw new Error("opencode-pty registration mismatch")
return registration
}
async function oneShot(registration: Registration, request: object) {
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
await connected(socket).catch((cause) => {
socket.destroy()
throw new ConnectError(cause)
})
socket.write(encode({ token: registration.token, request }))
const first = await frames.next()
socket.end()
if (first.done) throw new Error("opencode-pty closed without response")
const response = decode(first.value)
if (response.type === "error") throw new Error(response.message)
return response
}
class ConnectError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause))
}
}
function connected(socket: net.Socket) {
return new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
})
}
function encode(value: unknown) {
const payload = Buffer.from(JSON.stringify(value))
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
const output = Buffer.allocUnsafe(payload.length + 4)
output.writeUInt32BE(payload.length)
payload.copy(output, 4)
return output
}
async function* decoder(socket: net.Socket) {
let pending = Buffer.alloc(0)
for await (const value of socket) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
while (pending.length >= 4) {
const length = pending.readUInt32BE(0)
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
if (pending.length < length + 4) break
yield pending.subarray(4, length + 4)
pending = pending.subarray(length + 4)
}
}
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
}
function decode(payload: Uint8Array) {
return Schema.decodeUnknownSync(Response)(JSON.parse(Buffer.from(payload).toString("utf8")))
}
function toInfo(value: WireTerminal): Info {
const status = value.lifecycle.status
return {
...Pty.Info.make({
id: toID(value.id),
title: value.title,
command: value.command[0] || "",
args: value.command.slice(1),
cwd: value.cwd,
status: status === "running" ? "running" : "exited",
pid: value.pid ?? 0,
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
}),
groupID: Group.ID.make(value.group_id),
size: { cols: value.cols, rows: value.rows },
output: { head: value.output_head, tail: value.output_tail },
}
}
function toID(value: number) {
return Pty.ID.make(`pty_persistent_${value}`)
}
function fromID(value: Pty.ID) {
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
const parsed = Number(value.slice("pty_persistent_".length))
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
return parsed
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect } from "bun:test"
import { Group } from "@opencode-ai/core/persistent-pty"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Pty } from "@opencode-ai/schema/pty"
import { Session } from "@opencode-ai/schema/session"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Fiber, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([Group.node, KV.node, Bus.node])))
describe("Group", () => {
it.effect("persists ordered groups in one versioned KV document", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const kv = yield* KV.Service
const created = yield* groups.create([
{ type: "session", id: Session.ID.make("ses_one") },
{ type: "terminal", id: Pty.ID.make("pty_one") },
])
expect(yield* groups.get(created.id)).toEqual(created)
expect(yield* groups.list()).toEqual([created])
expect(yield* kv.get("group:v1")).toEqual([created])
const updated = Group.Info.make({
id: created.id,
items: [{ type: "terminal", id: Pty.ID.make("pty_two") }],
})
yield* groups.set(updated)
expect(yield* groups.list()).toEqual([updated])
yield* groups.remove(created.id)
expect(yield* groups.get(created.id)).toBeUndefined()
expect(yield* kv.get("group:v1")).toEqual([])
}),
)
it.effect("serializes concurrent document mutations", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
yield* Effect.all(
Array.from({ length: 20 }, (_, index) =>
groups.create([{ type: "session", id: Session.ID.make(`ses_${index}`) }]),
),
{ concurrency: "unbounded" },
)
expect(yield* groups.list()).toHaveLength(20)
}),
)
it.effect("publishes every removed group item", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const bus = yield* Bus.Service
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
const group = yield* groups.create([session, terminal])
const events = yield* bus
.subscribe(Group.Event.ItemRemoved)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* groups.set(Group.Info.make({ id: group.id, items: [session] }))
yield* groups.remove(group.id)
expect(Array.from(yield* Fiber.join(events)).map((event) => event.data)).toEqual([
{ groupID: group.id, item: terminal },
{ groupID: group.id, item: session },
])
}),
)
it.effect("publishes every added group item", () =>
Effect.gen(function* () {
const groups = yield* Group.Service
const bus = yield* Bus.Service
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
const group = yield* groups.create([session])
const event = yield* bus.subscribe(Group.Event.ItemAdded).pipe(Stream.runHead, Effect.forkScoped)
yield* Effect.yieldNow
yield* groups.set(Group.Info.make({ id: group.id, items: [session, terminal] }))
expect((yield* Fiber.join(event)).valueOrUndefined?.data).toEqual({ groupID: group.id, item: terminal })
}),
)
})
+3
View File
@@ -19,6 +19,7 @@ import { HealthGroup } from "./groups/health.js"
import { ServerGroup } from "./groups/server.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
@@ -86,6 +87,7 @@ type ApiGroups<
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof PersistentPtyGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -166,6 +168,7 @@ const makeApiFromGroup = <
.add(SkillGroup.middleware(locationMiddleware))
.add(eventGroup)
.add(PtyGroup.middleware(locationMiddleware))
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
@@ -0,0 +1,137 @@
import { Group } from "@opencode-ai/schema/group"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ForbiddenError,
InvalidRequestError,
PtyNotFoundError,
ServiceUnavailableError,
} from "../errors.js"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "./pty.js"
export { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE }
const CONNECT_PATH = /^\/api\/persistent-pty\/[^/]+\/connect$/
export function hasPersistentPtyConnectTicketURL(url: URL) {
return CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}
const errors = [InvalidRequestError, ServiceUnavailableError] as const
const terminalErrors = [PtyNotFoundError, ServiceUnavailableError] as const
export const PersistentPtyGroup = HttpApiGroup.make("server.persistentPty")
.add(
HttpApiEndpoint.get("persistentPty.group.list", "/api/pty-group", {
success: Schema.Struct({ data: Schema.Array(Group.Info) }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.group.create", "/api/pty-group", {
payload: Schema.Struct({ items: Schema.optional(Schema.Array(Group.Item)) }),
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.group.get", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.group.set", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
payload: Schema.Struct({ items: Schema.Array(Group.Item) }),
success: Schema.Struct({ data: Group.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.group.remove", "/api/pty-group/:groupID", {
params: { groupID: Group.ID },
success: HttpApiSchema.NoContent,
error: errors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.list", "/api/pty-group/:groupID/terminal", {
params: { groupID: Group.ID },
success: Schema.Struct({ data: Schema.Array(PersistentPty.Info) }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.create", "/api/pty-group/:groupID/terminal", {
params: { groupID: Group.ID },
payload: PersistentPty.CreateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: errors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.shutdown", "/api/persistent-pty/shutdown", {
success: HttpApiSchema.NoContent,
error: [ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.get", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.put("persistentPty.update", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
payload: PersistentPty.UpdateInput,
success: Schema.Struct({ data: PersistentPty.Info }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.get("persistentPty.snapshot", "/api/persistent-pty/:ptyID/snapshot", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PersistentPty.Snapshot }),
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.delete("persistentPty.remove", "/api/persistent-pty/:ptyID", {
params: { ptyID: Pty.ID },
success: HttpApiSchema.NoContent,
error: terminalErrors,
}),
)
.add(
HttpApiEndpoint.post("persistentPty.connectToken", "/api/persistent-pty/:ptyID/connect-token", {
params: { ptyID: Pty.ID },
success: Schema.Struct({ data: PtyTicket.ConnectToken }),
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}),
)
.add(
HttpApiEndpoint.get("persistentPty.connect", "/api/persistent-pty/:ptyID/connect", {
params: { ptyID: Pty.ID },
success: Schema.Boolean,
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.persistentPty.connect",
summary: "Connect to a persistent PTY",
description: "Stream persistent PTY output through the OpenCode server.",
transform: (operation) => ({ ...operation, "x-websocket": true }),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "persistentPty", description: "Prototype persistent PTY routes." }))
+2
View File
@@ -10,6 +10,7 @@ import { Event } from "./event.js"
import { FileSystem } from "./filesystem.js"
import { FileSystemV1 } from "./filesystem-v1.js"
import { Form } from "./form.js"
import { Group } from "./group.js"
import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js"
import { LegacyEventV1 } from "./legacy-event.js"
@@ -56,6 +57,7 @@ const featureDefinitions = Event.inventory(
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Form.Event.Definitions,
...Group.Event.Definitions,
...WebSearch.Event.Definitions,
)
+43
View File
@@ -0,0 +1,43 @@
export * as Group from "./group.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { Pty } from "./pty.js"
import { statics } from "./schema.js"
import { Session } from "./session.js"
const IDSchema = Schema.String.check(Schema.isStartsWith("grp_")).pipe(Schema.brand("GroupID"))
export const ID = IDSchema.pipe(
statics((schema: typeof IDSchema) => ({ create: () => schema.make("grp_" + ascending()) })),
)
export type ID = typeof ID.Type
export const SessionItem = Schema.Struct({
type: Schema.tag("session"),
id: Session.ID,
})
export interface SessionItem extends Schema.Schema.Type<typeof SessionItem> {}
export const TerminalItem = Schema.Struct({
type: Schema.tag("terminal"),
id: Pty.ID,
})
export interface TerminalItem extends Schema.Schema.Type<typeof TerminalItem> {}
export const Item = Schema.Union([SessionItem, TerminalItem]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "Group.Item" }),
)
export type Item = typeof Item.Type
export const Info = Schema.Struct({
id: ID,
items: Schema.Array(Item),
}).annotate({ identifier: "Group.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
const ItemAdded = ephemeral({ type: "group.item.added", schema: { groupID: ID, item: Item } })
const ItemRemoved = ephemeral({ type: "group.item.removed", schema: { groupID: ID, item: Item } })
export const Event = { ItemAdded, ItemRemoved, Definitions: inventory(ItemAdded, ItemRemoved) }
+2
View File
@@ -6,6 +6,7 @@ export { Credential } from "./credential.js"
export { Event } from "./event.js"
export { FileSystem } from "./filesystem.js"
export { Form } from "./form.js"
export { Group } from "./group.js"
export { Integration } from "./integration.js"
export { LLM } from "./llm.js"
export { AI } from "./ai.js"
@@ -31,6 +32,7 @@ export { Shell } from "./shell.js"
export { Skill } from "./skill.js"
export { TokenUsage } from "./token-usage.js"
export { Pty } from "./pty.js"
export { PersistentPty } from "./persistent-pty.js"
export { PtyTicket } from "./pty-ticket.js"
export { Question } from "./question.js"
export { Workspace } from "./workspace.js"
+38
View File
@@ -0,0 +1,38 @@
export * as PersistentPty from "./persistent-pty.js"
import { Schema } from "effect"
import { Group } from "./group.js"
import { Pty } from "./pty.js"
import { NonNegativeInt, PositiveInt, optional } from "./schema.js"
export const Info = Schema.Struct({
...Pty.Info.fields,
groupID: Group.ID,
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
output: Schema.Struct({ head: NonNegativeInt, tail: NonNegativeInt }),
}).annotate({ identifier: "PersistentPty.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const CreateInput = Schema.Struct({
command: Schema.String,
args: Schema.Array(Schema.String),
cwd: Schema.String,
title: Schema.String,
env: Schema.Record(Schema.String, Schema.String),
size: optional(Schema.Struct({ cols: PositiveInt, rows: PositiveInt })),
}).annotate({ identifier: "PersistentPty.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const UpdateInput = Schema.Struct({
attachmentID: optional(Schema.String),
size: Schema.Struct({ cols: PositiveInt, rows: PositiveInt }),
}).annotate({ identifier: "PersistentPty.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
export const Snapshot = Schema.Struct({
info: Info,
text: Schema.String,
checkpoint: Schema.Uint8Array,
cursor: Schema.Struct({ x: NonNegativeInt, y: NonNegativeInt }),
}).annotate({ identifier: "PersistentPty.Snapshot" })
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
@@ -4,6 +4,7 @@ import {
Config,
FileSystem,
Form,
Group,
Integration,
Permission,
Project,
@@ -64,6 +65,7 @@ describe("public event manifest", () => {
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Group.Event.Definitions).toEqual([Group.Event.ItemAdded, Group.Event.ItemRemoved])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Group } from "../src/group.js"
import { Pty } from "../src/pty.js"
import { Session } from "../src/session.js"
describe("Group", () => {
test("creates branded group IDs", () => {
expect(Group.ID.create()).toStartWith("grp_")
expect(() => Schema.decodeUnknownSync(Group.ID)("ses_invalid")).toThrow()
})
test("preserves one ordered session and terminal item list", () => {
const group = Schema.decodeUnknownSync(Group.Info)({
id: Group.ID.create(),
items: [
{ type: "session", id: Session.ID.make("ses_one") },
{ type: "terminal", id: Pty.ID.make("pty_one") },
{ type: "session", id: Session.ID.make("ses_two") },
],
})
expect(group.items.map((item) => item.type)).toEqual(["session", "terminal", "session"])
expect(() =>
Schema.decodeUnknownSync(Group.Info)({
id: group.id,
items: [{ type: "other", id: "other_one" }],
}),
).toThrow()
})
})
+2
View File
@@ -16,6 +16,7 @@ import { HealthHandler } from "./handlers/health"
import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { PersistentPtyHandler } from "./handlers/persistent-pty"
import { ShellHandler } from "./handlers/shell"
import { ReferenceHandler } from "./handlers/reference"
import { LocationHandler } from "./handlers/location"
@@ -55,6 +56,7 @@ export const handlers = Layer.mergeAll(
SkillHandler,
EventHandler.pipe(Layer.provide(EventFeed.layer)),
PtyHandler,
PersistentPtyHandler,
ShellHandler,
ReferenceHandler,
WorktreeHandler,
@@ -0,0 +1,290 @@
import { Group, PersistentPty } from "@opencode-ai/core/persistent-pty"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import {
ForbiddenError,
InvalidRequestError,
PtyNotFoundError,
ServiceUnavailableError,
} from "@opencode-ai/protocol/errors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@opencode-ai/protocol/groups/persistent-pty"
import { Effect, Queue } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Socket } from "effect/unstable/socket"
import { Api } from "../api"
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.persistentPty", (handlers) =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const groups = yield* Group.Service
const pty = yield* PersistentPty.Service
return handlers
.handle(
"persistentPty.group.list",
Effect.fn(function* () {
return { data: yield* groups.list() }
}),
)
.handle(
"persistentPty.group.create",
Effect.fn(function* (ctx) {
return { data: yield* groups.create(ctx.payload.items) }
}),
)
.handle(
"persistentPty.group.get",
Effect.fn(function* (ctx) {
const group = yield* groups.get(ctx.params.groupID)
if (!group)
return yield* new InvalidRequestError({
message: `Group not found: ${ctx.params.groupID}`,
field: "groupID",
})
return { data: group }
}),
)
.handle(
"persistentPty.group.set",
Effect.fn(function* (ctx) {
const group = Group.Info.make({ id: ctx.params.groupID, items: ctx.payload.items })
yield* groups.set(group)
return { data: group }
}),
)
.handle(
"persistentPty.group.remove",
Effect.fn(function* (ctx) {
yield* groups.remove(ctx.params.groupID)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.list",
Effect.fn(function* (ctx) {
return { data: yield* pty.list(ctx.params.groupID).pipe(mapUnavailable) }
}),
)
.handle(
"persistentPty.create",
Effect.fn(function* (ctx) {
return {
data: yield* pty
.create(ctx.params.groupID, {
command: ctx.payload.command,
args: ctx.payload.args,
cwd: ctx.payload.cwd,
title: ctx.payload.title,
env: ctx.payload.env,
cols: ctx.payload.size?.cols,
rows: ctx.payload.size?.rows,
})
.pipe(
Effect.catchTags({
"PersistentPty.GroupNotFoundError": () =>
new InvalidRequestError({
message: `Group not found: ${ctx.params.groupID}`,
field: "groupID",
}),
"PersistentPty.UnavailableError": unavailable,
}),
),
}
}),
)
.handle(
"persistentPty.shutdown",
Effect.fn(function* () {
yield* pty.shutdown().pipe(mapUnavailable)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.get",
Effect.fn(function* (ctx) {
return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.update",
Effect.fn(function* (ctx) {
yield* pty
.resize(
ctx.params.ptyID,
ctx.payload.size.cols,
ctx.payload.size.rows,
ctx.payload.attachmentID,
)
.pipe(mapTerminalError)
return { data: yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.snapshot",
Effect.fn(function* (ctx) {
return { data: yield* pty.snapshot(ctx.params.ptyID).pipe(mapTerminalError) }
}),
)
.handle(
"persistentPty.remove",
Effect.fn(function* (ctx) {
yield* pty.remove(ctx.params.ptyID).pipe(mapTerminalError)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"persistentPty.connectToken",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
if (
request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
)
return yield* new ForbiddenError({ message: "Invalid persistent PTY connect token request" })
yield* pty.get(ctx.params.ptyID).pipe(mapTerminalError)
return { data: yield* tickets.issue({ ptyID: ctx.params.ptyID }) }
}),
)
.handleRaw(
"persistentPty.connect",
Effect.fn("PersistentPtyHandler.connect")(function* (ctx) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("PersistentPty.NotFoundError", () => Effect.succeed(false)),
Effect.catchTag("PersistentPty.UnavailableError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const url = new URL(ctx.request.url, "http://localhost")
const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const cursor = Number(url.searchParams.get("cursor") ?? "0")
const role = url.searchParams.get("role") === "observer" ? "observer" : "controller"
const framedInput = url.searchParams.get("input_protocol") === "1"
const attachmentID = url.searchParams.get("attachment_id") ?? crypto.randomUUID()
if (!Number.isSafeInteger(cursor) || cursor < 0) return HttpServerResponse.empty({ status: 400 })
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
const attachment = yield* pty
.attach(ctx.params.ptyID, {
cursor,
attachmentID,
role,
takeover: url.searchParams.get("takeover") === "true",
onEvent: (event) => {
if (event.type === "output") Queue.offerUnsafe(outbox, event.data)
if (event.type === "resized")
Queue.offerUnsafe(
outbox,
JSON.stringify({ ...event, checkpoint: Buffer.from(event.checkpoint).toString("base64") }),
)
if (event.type !== "output" && event.type !== "resized")
Queue.offerUnsafe(outbox, JSON.stringify(event))
},
onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
})
.pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.succeed(undefined),
"PersistentPty.UnavailableError": () => Effect.succeed(undefined),
}),
)
if (!attachment) return HttpServerResponse.empty({ status: 404 })
Queue.offerUnsafe(
outbox,
JSON.stringify({
type: "attached",
attachmentID,
inputProtocol: framedInput ? 1 : 0,
info: attachment.info,
role: attachment.role,
generation: attachment.generation,
replay: {
requestedOffset: attachment.replay.requestedOffset,
availableOffset: attachment.replay.availableOffset,
endOffset: attachment.replay.endOffset,
truncated: attachment.replay.truncated,
},
}),
)
if (attachment.replay.data.length > 0) Queue.offerUnsafe(outbox, attachment.replay.data)
Queue.offerUnsafe(
outbox,
JSON.stringify({ type: "replay_complete", endOffset: attachment.replay.endOffset }),
)
attachment.activate()
const drain = Effect.gen(function* () {
while (true) {
const item = yield* Queue.take(outbox)
yield* write(item)
if (item instanceof Socket.CloseEvent) return
}
})
yield* Effect.race(
drain,
socket.runRaw((message) => {
if (role !== "controller") return Effect.void
const data = typeof message === "string" ? Buffer.from(message) : message
if (!framedInput)
return pty
.input(
ctx.params.ptyID,
attachmentID,
attachment.info.size.cols,
attachment.info.size.rows,
data,
)
.pipe(Effect.ignore)
if (data.byteLength < 5) return Effect.void
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
const type = data[0]
const cols = view.getUint16(1)
const rows = view.getUint16(3)
if ((type !== 0 && type !== 1) || cols === 0 || rows === 0) return Effect.void
if (type === 0) return pty.control(ctx.params.ptyID, attachmentID, cols, rows).pipe(Effect.ignore)
return pty.input(ctx.params.ptyID, attachmentID, cols, rows, data.subarray(5)).pipe(Effect.ignore)
}),
).pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(Effect.sync(() => attachment.detach())),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
)
const mapUnavailable = <A>(effect: Effect.Effect<A, PersistentPty.UnavailableError>) =>
effect.pipe(Effect.catchTag("PersistentPty.UnavailableError", unavailable))
const mapTerminalError = <A>(
effect: Effect.Effect<A, PersistentPty.NotFoundError | PersistentPty.UnavailableError>,
) =>
effect.pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": (error) =>
new PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}` }),
"PersistentPty.UnavailableError": unavailable,
}),
)
const unavailable = (error: PersistentPty.UnavailableError) =>
new ServiceUnavailableError({ message: error.message, service: "opencode-pty" })
@@ -3,6 +3,7 @@ import { UnauthorizedError } from "@opencode-ai/protocol/errors"
import { Authorization } from "@opencode-ai/protocol/middleware/authorization"
export { Authorization } from "@opencode-ai/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -49,7 +50,8 @@ export const authorizationLayer = Layer.effect(
const request = yield* HttpServerRequest.HttpServerRequest
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
// credential checks here; the connect handler consumes and validates the ticket.
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
+6 -1
View File
@@ -4,6 +4,7 @@ import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
@@ -183,7 +184,11 @@ function dispatch(
const state = yield* status.current
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized()
if (
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
if (ready) return yield* app.value
return unavailable(state)
})
+3
View File
@@ -13,6 +13,7 @@ import { Command } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Group, PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
@@ -60,6 +61,8 @@ const applicationServiceNodes = [
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
Group.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
PtyEnvironment.node,
+397
View File
@@ -0,0 +1,397 @@
import { existsSync } from "node:fs"
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import os from "node:os"
import path from "node:path"
import { expect } from "bun:test"
import { Group } from "@opencode-ai/schema/group"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { Effect, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
const binary = process.env.OPENCODE_PTY_BIN ?? "/root/projects/opencode-pty/target/debug/opencode-pty"
const smoke = existsSync(binary) ? it.live : it.live.skip
smoke(
"creates a group with two persistent terminals through the client API",
() =>
Effect.acquireUseRelease(
Effect.promise(async () => {
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const database = path.join(root, "opencode.db")
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
return {
database,
directory: path.join(
runtime,
"opencode-pty",
createHash("sha256").update(database).digest("hex").slice(0, 16),
),
environment,
root,
}
}),
(fixture) =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const group = Schema.decodeUnknownSync(Group.Info)(
(yield* request(base, "POST", "/api/pty-group", { items: [] })).data,
)
expect((yield* request(base, "GET", `/api/pty-group/${group.id}/terminal`)).data).toEqual([])
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeFalse()
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf terminal-one; cat"],
cwd: process.cwd(),
title: "first",
env: {},
})
).data,
)
expect(first.size).toEqual({ cols: 80, rows: 24 })
expect(existsSync(path.join(fixture.directory, "service.json"))).toBeTrue()
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf terminal-two; sleep 30"],
cwd: process.cwd(),
title: "second",
env: {},
})
).data,
)
const updated = Schema.decodeUnknownSync(Group.Info)(
(yield* request(base, "GET", `/api/pty-group/${group.id}`)).data,
)
expect(updated.items).toEqual([
{ type: "terminal", id: first.id },
{ type: "terminal", id: second.id },
])
const terminals = Schema.decodeUnknownSync(Schema.Array(PersistentPty.Info))(
(yield* request(base, "GET", `/api/pty-group/${group.id}/terminal`)).data,
)
expect(terminals.map((terminal) => terminal.id).sort()).toEqual([first.id, second.id].sort())
expect(yield* waitForText(base, first.id, "terminal-one")).toContain("terminal-one")
expect(yield* waitForText(base, second.id, "terminal-two")).toContain("terminal-two")
yield* Effect.promise(() => verifySharedControl(base, first.id))
const snapshot = yield* request(base, "GET", `/api/persistent-pty/${first.id}/snapshot`)
if (
!isRecord(snapshot.data) ||
typeof snapshot.data.checkpoint !== "string" ||
!isRecord(snapshot.data.info) ||
!isRecord(snapshot.data.info.output) ||
typeof snapshot.data.info.output.tail !== "number"
)
throw new Error("Persistent PTY snapshot response was invalid")
expect(Buffer.from(snapshot.data.checkpoint, "base64").byteLength).toBeGreaterThan(0)
expect(snapshot.data.info.output.tail).toBeGreaterThan(0)
yield* request(base, "DELETE", `/api/persistent-pty/${first.id}`)
yield* request(base, "DELETE", `/api/persistent-pty/${second.id}`)
expect((yield* request(base, "GET", `/api/pty-group/${group.id}`)).data).toMatchObject({ items: [] })
yield* request(base, "POST", "/api/persistent-pty/shutdown")
const unattended = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "exit 7"],
cwd: process.cwd(),
title: "unattended",
env: {},
})
).data,
)
yield* waitForStatus(base, unattended.id, "exited")
expect((yield* request(base, "GET", `/api/pty-group/${group.id}`)).data).toMatchObject({
items: [{ type: "terminal", id: unattended.id }],
})
yield* request(base, "DELETE", `/api/persistent-pty/${unattended.id}`)
const visible = Schema.decodeUnknownSync(PersistentPty.Info)(
(
yield* request(base, "POST", `/api/pty-group/${group.id}/terminal`, {
command: "/bin/sh",
args: ["-c", "read value"],
cwd: process.cwd(),
title: "visible",
env: {},
})
).data,
)
yield* attachAndExit(base, visible.id)
yield* waitForGroupItems(base, group.id, [])
yield* request(base, "DELETE", `/api/pty-group/${group.id}`)
}),
(fixture) =>
Effect.promise(async () => {
await Bun.spawn([binary, "stop"], {
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: fixture.directory },
stdout: "ignore",
stderr: "ignore",
}).exited
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
}),
),
20_000,
)
function request(base: string, method: string, pathname: string, body?: unknown, headers?: Record<string, string>) {
return Effect.tryPromise({
try: async () => {
const response = await fetch(new URL(pathname, base), {
method,
headers: {
authorization: `Basic ${btoa("opencode:secret")}`,
...headers,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) throw new Error(`${method} ${pathname} failed (${response.status}): ${await response.text()}`)
if (response.status === 204) return {}
const value: unknown = await response.json()
if (!isRecord(value)) throw new Error(`${method} ${pathname} returned a non-object response`)
return value
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function waitForText(base: string, ptyID: string, expected: string) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/persistent-pty/${ptyID}/snapshot`))
if (isRecord(response.data) && typeof response.data.text === "string" && response.data.text.includes(expected))
return response.data.text
await Bun.sleep(50)
}
throw new Error(`Persistent PTY snapshot did not contain ${expected}`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function waitForStatus(base: string, ptyID: string, status: string) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/persistent-pty/${ptyID}`))
if (isRecord(response.data) && response.data.status === status) return
await Bun.sleep(50)
}
throw new Error(`Persistent PTY ${ptyID} did not reach status ${status}`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function attachAndExit(base: string, ptyID: string) {
return Effect.tryPromise({
try: async () => {
const response = await Effect.runPromise(
request(base, "POST", `/api/persistent-pty/${ptyID}/connect-token`, undefined, {
"x-opencode-ticket": "1",
}),
)
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
throw new Error("Persistent PTY connect token response was invalid")
const url = new URL(`/api/persistent-pty/${ptyID}/connect`, base)
url.protocol = "ws:"
url.searchParams.set("ticket", response.data.ticket)
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(url)
const timeout = setTimeout(() => {
socket.close()
reject(new Error("Persistent PTY did not exit while attached"))
}, 5_000)
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!isRecord(message)) return
if (message.type === "attached") socket.send(new Uint8Array([4]))
if (message.type !== "exited") return
clearTimeout(timeout)
socket.close()
resolve()
})
socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket failed"))
})
})
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
async function verifySharedControl(base: string, ptyID: string) {
const first = await openTerminalSocket(base, ptyID, "first")
const second = await openTerminalSocket(base, ptyID, "second")
try {
first.socket.send(controlFrame(90, 25))
first.socket.send(inputFrame(90, 25, "from-first\n"))
await waitForSocketOutput([first, second], "from-first")
second.socket.send(inputFrame(70, 20, "from-second\n"))
await waitForSocketOutput([first, second], "from-second")
second.socket.send(inputFrame(70, 20, "x".repeat(1024 * 1024)))
second.socket.send(inputFrame(70, 20, "after-burst\n"))
await waitForSocketOutput([first, second], "after-burst")
expect(first.closed).toBeFalse()
expect(second.closed).toBeFalse()
expect(first.resizes).toBeGreaterThan(0)
expect(second.resizes).toBeGreaterThan(0)
expect(first.output).not.toContain("\0")
expect(second.output).not.toContain("\0")
} finally {
first.socket.close()
second.socket.close()
}
}
async function openTerminalSocket(base: string, ptyID: string, attachmentID: string) {
const response = await Effect.runPromise(
request(base, "POST", `/api/persistent-pty/${ptyID}/connect-token`, undefined, {
"x-opencode-ticket": "1",
}),
)
if (!isRecord(response.data) || typeof response.data.ticket !== "string")
throw new Error("Persistent PTY connect token response was invalid")
const url = new URL(`/api/persistent-pty/${ptyID}/connect`, base)
url.protocol = "ws:"
url.searchParams.set("ticket", response.data.ticket)
url.searchParams.set("attachment_id", attachmentID)
url.searchParams.set("takeover", "true")
url.searchParams.set("input_protocol", "1")
const state = { socket: new WebSocket(url), output: "", closed: false, resizes: 0 }
state.socket.binaryType = "arraybuffer"
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Persistent PTY WebSocket did not attach")), 5_000)
let attached = false
state.socket.addEventListener("message", (event) => {
if (event.data instanceof ArrayBuffer) {
state.output += new TextDecoder().decode(event.data)
return
}
if (typeof event.data !== "string") return
const message: unknown = JSON.parse(event.data)
if (!isRecord(message)) return
if (message.type === "resized") {
if (typeof message.checkpoint !== "string") {
clearTimeout(timeout)
reject(new Error("Persistent PTY resize omitted its checkpoint"))
return
}
state.resizes++
return
}
if (message.type === "attached") {
if (message.inputProtocol === 1) {
attached = true
return
}
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket did not negotiate framed input"))
return
}
if (message.type !== "replay_complete" || !attached) return
clearTimeout(timeout)
resolve()
})
state.socket.addEventListener("close", () => {
state.closed = true
})
state.socket.addEventListener("error", () => {
clearTimeout(timeout)
reject(new Error("Persistent PTY WebSocket failed"))
})
})
return state
}
function inputFrame(cols: number, rows: number, input: string) {
const data = new TextEncoder().encode(input)
const frame = new Uint8Array(5 + data.byteLength)
const view = new DataView(frame.buffer)
frame[0] = 1
view.setUint16(1, cols)
view.setUint16(3, rows)
frame.set(data, 5)
return frame
}
function controlFrame(cols: number, rows: number) {
const frame = new Uint8Array(5)
const view = new DataView(frame.buffer)
view.setUint16(1, cols)
view.setUint16(3, rows)
return frame
}
async function waitForSocketOutput(
sockets: Array<{ output: string; closed: boolean }>,
expected: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if (sockets.every((socket) => socket.output.includes(expected))) return
if (sockets.some((socket) => socket.closed)) throw new Error("Persistent PTY observer disconnected")
await Bun.sleep(20)
}
throw new Error(
`Persistent PTY sockets did not both receive ${expected}: ${JSON.stringify(sockets.map((socket) => socket.output))}`,
)
}
function waitForGroupItems(base: string, groupID: string, expected: unknown[]) {
return Effect.tryPromise({
try: async () => {
for (let attempt = 0; attempt < 40; attempt++) {
const response = await Effect.runPromise(request(base, "GET", `/api/pty-group/${groupID}`))
if (isRecord(response.data) && JSON.stringify(response.data.items) === JSON.stringify(expected)) return
await Bun.sleep(50)
}
throw new Error(`Persistent PTY group ${groupID} did not reconcile`)
},
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}
function restore(key: string, value: string | undefined) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+64 -42
View File
@@ -31,7 +31,6 @@ import {
batch,
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -76,7 +75,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 { PaneLayoutProvider } from "./context/pane-layout"
import { PaneWorkspace } from "./component/pane-workspace"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -217,7 +217,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,
}
@@ -372,48 +372,50 @@ 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>
<PaneLayoutProvider>
<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>
</PaneLayoutProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
@@ -606,6 +608,11 @@ function App(props: { pair?: DialogPairCredentials }) {
return
}
if (route.data.type === "workspace") {
renderer.setTerminalTitle("OC | Terminal")
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.name}`)
}
@@ -1302,7 +1309,22 @@ 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) => (
<PaneWorkspace
sessionID={sessionID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "workspace"}>
<Show when={route.data.type === "workspace" ? route.data.groupID : undefined} keyed>
{(groupID) => (
<PaneWorkspace
groupID={groupID}
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
/>
)}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
+42 -1
View File
@@ -17,12 +17,13 @@ import { useDialog } from "../ui/dialog"
import { DialogExperiments } from "./dialog-experiments"
import { usePlugin } from "../plugin/context"
import { errorMessage } from "../util/error"
import { usePaneLayout } from "../context/pane-layout"
const graphWidth = 23
const sampleIntervalMilliseconds = 2_000
const sampleRetentionMilliseconds = 30_000
const statusWindowMilliseconds = 6_000
type Panel = "server" | "theme" | "tools" | "ui"
type Panel = "server" | "theme" | "tools" | "ui" | "layout"
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
export type RuntimeStatus = "normal" | "medium" | "high"
@@ -38,6 +39,7 @@ export function DevToolsBar() {
const keymap = Keymap.use()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panes = usePaneLayout()
const { current: theme, mode, supports, setMode } = themes
const elevatedTheme = useTheme("elevated")
const [panel, setPanel] = createSignal<Panel>()
@@ -46,6 +48,8 @@ export function DevToolsBar() {
const [dumpError, setDumpError] = createSignal<string>()
const [frontendSamples, setFrontendSamples] = createSignal<readonly ProcessSample[]>([])
const [debugOverlay, setDebugOverlay] = createSignal(renderer.debugOverlay.enabled)
const [creatingTerminal, setCreatingTerminal] = createSignal(false)
const [terminalError, setTerminalError] = createSignal<string>()
let focus: Renderable | null
const connected = createMemo(() => client.connection.status() === "connected")
const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt()))
@@ -220,6 +224,18 @@ export function DevToolsBar() {
setDumping(false)
}
async function newTerminal() {
const routeData = route.data
if (routeData.type !== "session") return
setCreatingTerminal(true)
setTerminalError()
await panes.newTerminal(routeData.sessionID).then(
() => close(),
(error) => setTerminalError(errorMessage(error)),
)
setCreatingTerminal(false)
}
return (
<box height={1} flexShrink={0} flexDirection="row" backgroundColor={theme.raise(theme.background.default)}>
<Show when={panel()}>
@@ -418,6 +434,31 @@ export function DevToolsBar() {
</PanelBox>
</Show>
</BarItem>
<BarItem active={panel() === "layout"} onClick={() => toggle("layout")}>
<text fg={panel() === "layout" ? theme.text.action.primary.focused : theme.text.subdued}>Layout</text>
<Show when={panel() === "layout"}>
<PanelBox>
<PanelTitle>Layout</PanelTitle>
<Action
onClick={() => void newTerminal()}
disabled={route.data.type !== "session" || creatingTerminal()}
hoverBackground
>
{creatingTerminal() ? "Creating terminal..." : "New terminal"}
</Action>
<Show when={route.data.type !== "session"}>
<text fg={elevatedTheme.text.subdued}>Open a session first.</text>
</Show>
<Show when={terminalError()}>
{(error) => (
<text fg={elevatedTheme.text.feedback.error.default} wrapMode="word">
{error()}
</text>
)}
</Show>
</PanelBox>
</Show>
</BarItem>
<BarItem
active={false}
onClick={() => {
@@ -0,0 +1,98 @@
import { createResource, Match, Show, Switch } from "solid-js"
import { usePaneLayout } from "../context/pane-layout"
import type { PaneLayoutNode } from "../context/pane-layout-model"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { PersistentTerminalPane } from "./persistent-terminal-pane"
export function PaneWorkspace(props: { sessionID?: string; groupID?: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
createResource(
() => props.groupID ?? props.sessionID,
(key) => (props.groupID ? panes.loadGroup(key) : panes.load(key)).catch(() => undefined),
)
const workspace = () => (props.groupID ? panes.getGroup(props.groupID) : props.sessionID ? panes.get(props.sessionID) : undefined)
return (
<Show
when={workspace()}
fallback={props.sessionID ? <Session verticalTabsWidth={props.verticalTabsWidth} /> : null}
>
{(value) => (
<PaneNode
node={value().layout}
rootSessionID={props.sessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
)}
</Show>
)
}
function PaneNode(props: { node: PaneLayoutNode; rootSessionID?: string; verticalTabsWidth: number }) {
const panes = usePaneLayout()
const theme = useTheme()
return (
<Switch>
<Match when={props.node.type === "item" ? props.node.item : undefined}>
{(item) => (
<Switch>
<Match when={item().type === "session" && item().id === props.rootSessionID}>
<Session verticalTabsWidth={props.verticalTabsWidth} />
</Match>
<Match when={item().type === "session"}>
<UnavailablePane label={`Session ${item().id}`} />
</Match>
<Match when={item().type === "terminal"}>
<PersistentTerminalPane
ptyID={item().id}
autoFocus={!props.rootSessionID || panes.shouldFocus(item().id)}
onAutoFocus={() => panes.clearFocus(item().id)}
/>
</Match>
</Switch>
)}
</Match>
<Match when={props.node.type === "split" ? props.node : undefined}>
{(node) => (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
flexDirection={node().direction === "horizontal" ? "row" : "column"}
>
<box flexGrow={node().ratio} flexBasis={0} minWidth={0} minHeight={0}>
<PaneNode
node={node().first}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
<box
flexGrow={1 - node().ratio}
flexBasis={0}
minWidth={0}
minHeight={0}
border={node().direction === "horizontal" ? ["left"] : ["top"]}
borderColor={theme.border.default}
>
<PaneNode
node={node().second}
rootSessionID={props.rootSessionID}
verticalTabsWidth={props.verticalTabsWidth}
/>
</box>
</box>
)}
</Match>
</Switch>
)
}
function UnavailablePane(props: { label: string }) {
const theme = useTheme()
return (
<box flexGrow={1} alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>{props.label} is unavailable in this prototype.</text>
</box>
)
}
@@ -0,0 +1,302 @@
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { 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 }) {
const client = useClient()
const keymap = Keymap.use()
const theme = useTheme()
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 waitingSize: { size: TerminalSize; resolve: () => void } | undefined
const setCanonicalSize = (value: TerminalSize) => {
canonicalSize = value
if (!terminal) return
terminal.width = value.cols
terminal.height = value.rows
}
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)]))
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
event.preventDefault()
event.stopPropagation()
terminal.handleKeyPress(event)
},
{ priority: 100 },
)
createEffect(() => {
if (!props.autoFocus || !terminal) return
terminal.focus()
props.onAutoFocus?.()
})
onMount(() => {
void connect().catch((error) => setFailure(errorMessage(error)))
})
onCleanup(() => {
disposed = true
waitingSize?.resolve()
socket?.close()
offKeys()
})
async function connect() {
const endpoint = client.endpoint
if (!endpoint) throw new Error("Persistent terminal server endpoint is unavailable")
const snapshot = await client.api["server.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"))
const token = await client.api["server.persistentPty"].connectToken(
{ ptyID: props.ptyID },
{ headers: { "x-opencode-ticket": "1" } },
)
if (disposed) return
const url = new URL(`/api/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 === "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) setFailure("Terminal connection failed")
})
next.addEventListener("close", () => {
if (!disposed) setFailure("Terminal disconnected")
})
socket = next
}
return (
<box
flexGrow={1}
minWidth={0}
minHeight={0}
overflow="hidden"
onSizeChange={function () {
size = { cols: this.width, 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
terminalSize = { cols: 80, rows: 24 }
if (canonicalSize) {
value.width = canonicalSize.cols
value.height = canonicalSize.rows
}
}}
position="absolute"
left={0}
top={0}
width={80}
height={24}
onData={(data, source) => {
if (source === "input") sendInput(data)
}}
onTerminalResize={(cols, rows) => {
terminalSize = { cols, rows }
if (waitingSize && sameSize(waitingSize.size, terminalSize)) {
waitingSize.resolve()
waitingSize = undefined
}
processStream()
}}
/>
</Show>
</box>
)
}
function sameSize(first: TerminalSize | undefined, second: TerminalSize | undefined) {
return !!first && !!second && first.cols === second.cols && first.rows === second.rows
}
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
}
+22 -38
View File
@@ -51,8 +51,7 @@ const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
x: number
y: number
sessionID?: string
title?: string
tab?: SessionTab
}
type ContextController = ReturnType<typeof useSessionTabs>
@@ -189,16 +188,20 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const theme = useTheme("elevated")
const dialog = useDialog()
const actions = createMemo(() => {
const sessionID = props.state.sessionID
const tab = props.state.tab
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
...(tab
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
...(!tab.groupID
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, tab.sessionID, tab.title),
},
]
: []),
{ title: "Close", run: () => props.tabs.close(tab.sessionID) },
]
: []),
]
@@ -420,7 +423,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const session = createMemo(() => data.session.get(tab.sessionID))
const session = createMemo(() => (tab.groupID ? undefined : data.session.get(tab.sessionID)))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -429,7 +432,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => tab.title ?? "Untitled session"
const title = () => tab.title ?? (tab.groupID ? "Terminal" : "Untitled session")
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -448,6 +451,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
if (tab.groupID) return tab.directory ?? ""
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
@@ -573,10 +577,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x,
y: event.y,
sessionID: tab.sessionID,
title: tab.title,
x: event.x - rail.screenX,
y: event.y - rail.screenY,
tab,
})
event.preventDefault()
event.stopPropagation()
@@ -1001,24 +1004,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}}
onMouseDrag={drag}
onMouseDragEnd={release}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
const width = Math.min(this.width, buffer.width - x)
if (y < 0 || y >= buffer.height || width <= 0) return
buffer.fillRect(
x,
y,
width,
1,
RGBA.fromValues(
theme.background.default.r,
theme.background.default.g,
theme.background.default.b,
mode() === "light" ? 0.14 : 0.28,
),
)
}}
>
<Show when={layout().before > 0}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
@@ -1050,7 +1035,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const title = () => tab.title ?? (tab.groupID ? "Terminal" : "Untitled session")
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
@@ -1125,10 +1110,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x,
y: event.y,
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
tab: tab === NEW_SESSION_TAB ? undefined : tab,
})
event.preventDefault()
event.stopPropagation()
+8 -2
View File
@@ -1,4 +1,5 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Endpoint } from "@opencode-ai/client/service"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
@@ -18,7 +19,7 @@ export type ClientConnectionEvent = {
}
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
restart: () => Promise<void>
}
@@ -29,11 +30,12 @@ const eventFlushInterval = 10
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 abort = new AbortController()
const history: ClientConnectionEvent[] = []
let api = props.api
let endpoint = props.endpoint
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
@@ -158,6 +160,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next.api
if (next.endpoint) endpoint = next.endpoint
if (attempt === 1) continue
}
}
@@ -179,6 +182,9 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
get api() {
return api
},
get endpoint() {
return endpoint
},
event: {
on: events.on,
listen: events.listen,
@@ -0,0 +1,70 @@
import type { GroupItem } from "@opencode-ai/client"
export type PaneLayoutNode =
| { type: "item"; item: GroupItem }
| {
type: "split"
direction: "horizontal" | "vertical"
ratio: number
first: PaneLayoutNode
second: PaneLayoutNode
}
export function defaultPaneLayout(items: readonly GroupItem[]): PaneLayoutNode | undefined {
const master = items[0]
if (!master) return undefined
const stack = items.slice(1)
if (stack.length === 0) return { type: "item", item: master }
return {
type: "split",
direction: "horizontal",
ratio: 0.5,
first: { type: "item", item: master },
second: stackLayout(stack),
}
}
function stackLayout(items: readonly GroupItem[]): PaneLayoutNode {
const first = items[0]
if (items.length === 1) return { type: "item", item: first }
return {
type: "split",
direction: "vertical",
ratio: 1 / items.length,
first: { type: "item", item: first },
second: stackLayout(items.slice(1)),
}
}
export function paneLayoutItems(node: PaneLayoutNode): GroupItem[] {
if (node.type === "item") return [node.item]
return paneLayoutItems(node.first).concat(paneLayoutItems(node.second))
}
export function removePaneLayoutItem(node: PaneLayoutNode, item: GroupItem): PaneLayoutNode | undefined {
if (node.type === "item") return itemKey(node.item) === itemKey(item) ? undefined : node
const first = removePaneLayoutItem(node.first, item)
const second = removePaneLayoutItem(node.second, item)
if (!first) return second
if (!second) return first
if (first === node.first && second === node.second) return node
return { ...node, first, second }
}
export function reconcilePaneLayout(node: PaneLayoutNode | undefined, items: readonly GroupItem[]) {
if (!node) return defaultPaneLayout(items)
const wanted = new Map(items.map((item) => [itemKey(item), item]))
const kept = paneLayoutItems(node).filter((item) => wanted.has(itemKey(item)))
if (kept.length !== items.length || kept.some((item, index) => itemKey(item) !== itemKey(items[index])))
return defaultPaneLayout(items)
return replaceItems(node, wanted)
}
function replaceItems(node: PaneLayoutNode, items: ReadonlyMap<string, GroupItem>): PaneLayoutNode {
if (node.type === "item") return { type: "item", item: items.get(itemKey(node.item)) ?? node.item }
return { ...node, first: replaceItems(node.first, items), second: replaceItems(node.second, items) }
}
function itemKey(item: GroupItem) {
return `${item.type}:${item.id}`
}
+158
View File
@@ -0,0 +1,158 @@
import type { GroupInfo, GroupItem, LocationRef, PersistentPtyInfo } from "@opencode-ai/client"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { useStorage } from "./storage"
import { reconcilePaneLayout, removePaneLayoutItem, type PaneLayoutNode } from "./pane-layout-model"
import { useEvent } from "./event"
import { createSignal, onCleanup } from "solid-js"
type PaneWorkspace = {
sessionID?: string
groupID: string
items: GroupItem[]
layout: PaneLayoutNode
}
type PaneLayoutState = {
workspaces: Record<string, PaneWorkspace>
}
export const { use: usePaneLayout, provider: PaneLayoutProvider } = createSimpleContext({
name: "PaneLayout",
init: () => {
const client = useClient()
const data = useData()
const event = useEvent()
const [focus, setFocus] = createSignal<string>()
const [store, update] = useStorage().store<PaneLayoutState>("pane-layout-v1", {
initial: { workspaces: {} },
})
const save = (key: string, group: GroupInfo, sessionID?: string) =>
update((draft) => {
const layout = reconcilePaneLayout(draft.workspaces[key]?.layout, group.items)
if (!layout) {
delete draft.workspaces[key]
return
}
draft.workspaces[key] = {
sessionID,
groupID: group.id,
items: group.items,
layout,
}
})
onCleanup(
event.on("group.item.added", (evt) => {
void update((draft) => {
Object.values(draft.workspaces).forEach((workspace) => {
if (workspace.groupID !== evt.data.groupID) return
if (workspace.items.some((item) => item.type === evt.data.item.type && item.id === evt.data.item.id)) return
workspace.items.push(evt.data.item)
workspace.layout = reconcilePaneLayout(workspace.layout, workspace.items) ?? workspace.layout
})
}).catch((error) => console.error("Failed to add pane layout item", error))
}),
)
onCleanup(
event.on("group.item.removed", (evt) => {
void update((draft) => {
Object.entries(draft.workspaces).forEach(([sessionID, workspace]) => {
if (workspace.groupID !== evt.data.groupID) return
const layout = removePaneLayoutItem(workspace.layout, evt.data.item)
if (!layout) {
delete draft.workspaces[sessionID]
return
}
workspace.items = workspace.items.filter(
(item) => item.type !== evt.data.item.type || item.id !== evt.data.item.id,
)
workspace.layout = layout
})
}).catch((error) => console.error("Failed to remove pane layout item", error))
}),
)
return {
get(sessionID: string) {
return store.workspaces[sessionID]
},
async load(sessionID: string) {
const current = store.workspaces[sessionID]
if (current) {
const group = await client.api["server.persistentPty"].group.get({ groupID: current.groupID })
await save(sessionID, group, sessionID)
return
}
const groups = await client.api["server.persistentPty"].group.list()
const group = groups.find((item) =>
item.items.some((entry) => entry.type === "session" && entry.id === sessionID),
)
if (group) await save(sessionID, group, sessionID)
},
getGroup(groupID: string) {
return store.workspaces[groupID]
},
async loadGroup(groupID: string) {
await save(groupID, await client.api["server.persistentPty"].group.get({ groupID }))
},
async refresh(sessionID: string) {
const current = store.workspaces[sessionID]
if (!current) return
const group = await client.api["server.persistentPty"].group.get({ groupID: current.groupID })
await save(sessionID, group, sessionID)
},
async newTerminal(sessionID: string): Promise<PersistentPtyInfo> {
const api = client.api["server.persistentPty"]
const current = store.workspaces[sessionID]
const existing = current
? await api.group.get({ groupID: current.groupID })
: (await api.group.list()).find((group) =>
group.items.some((item) => item.type === "session" && item.id === sessionID),
)
const group = existing ?? (await api.group.create({ items: [{ type: "session", id: sessionID }] }))
const session = data.session.get(sessionID)
const terminal = await api.create({
groupID: group.id,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: session?.location.directory ?? process.cwd(),
title: "Terminal",
env: {},
})
const next = await api.group.get({ groupID: group.id })
setFocus(terminal.id)
await save(sessionID, next, sessionID)
return terminal
},
async newTerminalWorkspace(location: LocationRef) {
const api = client.api["server.persistentPty"]
const group = await api.group.create({ items: [] })
const terminal = await api
.create({
groupID: group.id,
command: process.env.SHELL || "/bin/sh",
args: [],
cwd: location.directory,
title: "Terminal",
env: {},
})
.catch(async (error) => {
await api.group.remove({ groupID: group.id }).catch(() => undefined)
throw error
})
await save(group.id, await api.group.get({ groupID: group.id }))
return { group, terminal }
},
shouldFocus(ptyID: string) {
return focus() === ptyID
},
clearFocus(ptyID: string) {
setFocus((current) => (current === ptyID ? undefined : current))
},
}
},
})
+9 -1
View File
@@ -17,6 +17,11 @@ export type SessionRoute = {
prompt?: PromptInfo
}
export type WorkspaceRoute = {
type: "workspace"
groupID: string
}
export type PluginRoute = {
type: "plugin"
id: string
@@ -24,7 +29,7 @@ export type PluginRoute = {
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export type Route = HomeRoute | SessionRoute | WorkspaceRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@@ -51,6 +56,9 @@ function initialRoute(value: unknown): Route | undefined {
if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") {
return { type: "session", sessionID: value.sessionID }
}
if (value.type === "workspace" && "groupID" in value && typeof value.groupID === "string") {
return { type: "workspace", groupID: value.groupID }
}
if (
value.type === "plugin" &&
"id" in value &&
@@ -1,6 +1,8 @@
export type SessionTab = {
sessionID: string
title?: string
groupID?: string
directory?: string
}
export type SessionTabUnread = "activity" | "error"
+102 -16
View File
@@ -74,6 +74,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
const closing = new Set<string>()
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
const scrollAnchors = new Map<string, ScrollAnchor>()
@@ -112,6 +113,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
if (tab.groupID) return openSessionTab(tabs, { ...tab, sessionID: tab.groupID })
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
@@ -121,7 +123,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const current = () => {
if (route.data.type === "session") return root(route.data.sessionID)
if (route.data.type === "workspace") return route.data.groupID
return undefined
}
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
if (!open) return false
@@ -129,6 +135,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return sessionID !== undefined && !state().tabs.some((tab) => tab.sessionID === sessionID)
}, false)
const status = (sessionID: string) => {
if (state().tabs.some((tab) => tab.sessionID === sessionID && tab.groupID)) {
return { unread: undefined, promptPulse: 0, attention: false, busy: false }
}
const session = root(sessionID)
const members = data.session.family(session)
const family = members.length > 0 ? members : [session]
@@ -155,6 +164,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (!enabled()) return
if (route.data.type === "workspace") {
history = recordSessionTabHistory(history, route.data.groupID)
return
}
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
@@ -198,7 +211,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// the first connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.sort()
.join("\n"),
)
@@ -226,7 +240,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
.tabs.filter((tab) => !tab.groupID)
.map((tab) => tab.sessionID)
.filter((sessionID) => sessionID !== current())
for (const sessionID of sessions) {
if (stale) return
@@ -258,16 +273,48 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
closedTabs = closedTabs.filter((entry) => entry.tab.groupID || entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
onCleanup(
event.on("group.item.removed", (evt) => {
if (closing.has(evt.data.groupID)) return
if (!state().tabs.some((tab) => tab.groupID === evt.data.groupID)) return
void client.api["server.persistentPty"].group
.get({ groupID: evt.data.groupID })
.then(async (group) => {
if (group.items.length > 0) return
await client.api["server.persistentPty"].group.remove({ groupID: group.id })
remove(group.id, enabled())
})
.catch(() => undefined)
}),
)
function tab(id: string) {
return state().tabs.find((item) => item.sessionID === id)
}
function navigate(id: string | undefined) {
if (!id) {
route.navigate({ type: "home" })
return
}
const target = tab(id)
if (target?.groupID) {
route.navigate({ type: "workspace", groupID: target.groupID })
return
}
route.navigate({ type: "session", sessionID: id })
}
function remove(sessionID: string, shouldNavigate: boolean) {
const target = tab(sessionID)?.groupID ? sessionID : root(sessionID)
scrollAnchors.delete(target)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
const selected = shouldNavigate && current() === target
if (closed.tabs === state().tabs && !selected) return
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
@@ -284,7 +331,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
if (selected) navigate(next)
}
async function closeWorkspace(tab: SessionTab) {
if (!tab.groupID || closing.has(tab.groupID)) return
closing.add(tab.groupID)
try {
const api = client.api["server.persistentPty"]
const group = await api.group.get({ groupID: tab.groupID })
if (!group.items.some((item) => item.type === "session")) {
for (const terminal of await api.list({ groupID: group.id })) await api.remove({ ptyID: terminal.id })
await api.group.remove({ groupID: group.id })
}
remove(tab.sessionID, true)
} catch (error) {
console.error("Failed to close terminal workspace", error)
} finally {
closing.delete(tab.groupID)
}
}
return {
@@ -314,8 +379,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
select(sessionID: string) {
if (!enabled()) return
const target = tab(sessionID)
if (target?.groupID) {
route.navigate({ type: "workspace", groupID: target.groupID })
return
}
route.navigate({ type: "session", sessionID: root(sessionID) })
},
openWorkspace(groupID: string, directory: string) {
if (!enabled()) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID: groupID,
groupID,
directory,
title: "Terminal",
})
})
route.navigate({ type: "workspace", groupID })
},
add() {
if (!enabled()) return
const sessionID = current()
@@ -332,17 +414,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
const target = sessionID ? (tab(sessionID)?.groupID ? sessionID : root(sessionID)) : current()
if (!target) {
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
history = previous.history
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
if (route.data.type === "home" && session) navigate(session)
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
const selected = state().tabs[index]
if (selected?.groupID) {
void closeWorkspace(selected)
return
}
if (selected) closedTabs = recordClosedSessionTab(closedTabs, selected, index)
remove(target, true)
},
reopen() {
@@ -358,7 +444,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
const session = tab(sessionID)?.groupID ? sessionID : root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
@@ -367,19 +453,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
selectIndex(index: number) {
if (!enabled()) return
const tab = state().tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
if (tab) navigate(tab.sessionID)
},
}
},
@@ -1,15 +1,24 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
import { useTerminalDimensions } from "@opentui/solid"
import { usePaneLayout } from "../../context/pane-layout"
import { useSessionTabs } from "../../context/session-tabs"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
export function PromptFooter(props: {
context: Plugin.Context
sessionID?: string
mode: "normal" | "shell"
onNewTerminal?: () => Promise<void>
}) {
const dimensions = useTerminalDimensions()
const [terminalHovered, setTerminalHovered] = createSignal(false)
const [terminalPending, setTerminalPending] = createSignal(false)
const subagents = createMemo(() => {
if (!props.sessionID) return 0
const count = props.context.data.session
@@ -41,33 +50,53 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
})
const live = createMemo(() => Boolean(subagents() || shells()))
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
const newTerminal = async () => {
if (terminalPending() || !props.onNewTerminal) return
setTerminalPending(true)
await props.onNewTerminal().finally(() => setTerminalPending(false))
}
return (
<Switch>
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
<Show
when={props.sessionID}
fallback={
<text
fg={terminalHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
selectable={false}
onMouseOver={() => setTerminalHovered(true)}
onMouseOut={() => setTerminalHovered(false)}
onMouseUp={() => void newTerminal()}
>
{terminalPending() ? "starting terminal" : "new terminal"}
</text>
</Match>
<Match when={dimensions().width >= 44}>
}
>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
</Match>
<Match when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Match>
</Switch>
<Show when={dimensions().width >= 44}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Show>
</Show>
</Match>
<Match when={props.mode === "shell"}>
@@ -82,12 +111,31 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
)
}
function PromptFooterSlot(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const panes = usePaneLayout()
const tabs = useSessionTabs()
const newTerminal = async () => {
const location = props.context.location
if (!location || !tabs.enabled()) return
await panes
.newTerminalWorkspace(location)
.then(({ group }) => tabs.openWorkspace(group.id, location.directory))
.catch((error) => {
props.context.ui.toast.show({
variant: "error",
message: error instanceof Error ? error.message : "Failed to create terminal",
})
})
}
return <PromptFooter {...props} onNewTerminal={newTerminal} />
}
export default Plugin.define({
id: "opencode.prompt-footer",
setup(context) {
context.ui.slot({
append: "prompt.footer",
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
render: (props) => <PromptFooterSlot context={context} sessionID={props.sessionID} mode={props.mode} />,
})
},
})
+12 -7
View File
@@ -168,17 +168,22 @@ export function createPluginContext(input: {
host.route.navigate(destination)
},
current() {
if (host.route.data.type === "workspace") return { type: "home" }
return host.route.data
},
},
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
host.sessionTabs
.tabs()
.filter((tab) => !tab.groupID)
.map((tab) => ({
sessionID: tab.sessionID,
title: tab.title,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)
@@ -186,14 +191,14 @@ export function createPluginContext(input: {
},
focus(sessionID) {
if (!host.sessionTabs.enabled()) return false
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
if (!host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === sessionID)) return false
host.sessionTabs.select(sessionID)
return true
},
close(sessionID) {
if (!host.sessionTabs.enabled()) return false
const target = sessionID ?? host.sessionTabs.current()
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
if (!target || !host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === target)) return false
host.sessionTabs.close(target)
return true
},
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import type { GroupItem } from "@opencode-ai/client"
import {
defaultPaneLayout,
paneLayoutItems,
reconcilePaneLayout,
removePaneLayoutItem,
} from "../../src/context/pane-layout-model"
const session = (id: string): GroupItem => ({ type: "session", id })
const terminal = (id: string): GroupItem => ({ type: "terminal", id })
describe("pane layout model", () => {
test("builds a master pane with an evenly divided right stack", () => {
const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2"), terminal("pty_3")]
const layout = defaultPaneLayout(items)
expect(layout).toEqual({
type: "split",
direction: "horizontal",
ratio: 0.5,
first: { type: "item", item: items[0] },
second: {
type: "split",
direction: "vertical",
ratio: 1 / 3,
first: { type: "item", item: items[1] },
second: {
type: "split",
direction: "vertical",
ratio: 0.5,
first: { type: "item", item: items[2] },
second: { type: "item", item: items[3] },
},
},
})
expect(paneLayoutItems(layout!)).toEqual(items)
})
test("preserves stored split ratios when backend items still match", () => {
const items = [session("ses_1"), terminal("pty_1")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split") throw new Error("Expected a split")
layout.ratio = 0.65
expect(reconcilePaneLayout(layout, items)).toMatchObject({ ratio: 0.65 })
})
test("rebuilds the default layout when backend order changes", () => {
const items = [session("ses_1"), terminal("pty_1")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split") throw new Error("Expected a split")
layout.ratio = 0.65
expect(reconcilePaneLayout(layout, items.toReversed())).toMatchObject({ ratio: 0.5 })
})
test("removes a pane and preserves the remaining BSP layout", () => {
const items = [session("ses_1"), terminal("pty_1"), terminal("pty_2")]
const layout = defaultPaneLayout(items)!
if (layout.type !== "split" || layout.second.type !== "split") throw new Error("Expected nested splits")
layout.ratio = 0.65
layout.second.ratio = 0.3
expect(removePaneLayoutItem(layout, items[1])).toEqual({
type: "split",
direction: "horizontal",
ratio: 0.65,
first: { type: "item", item: items[0] },
second: { type: "item", item: items[2] },
})
})
})
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import type { GroupItem, OpenCodeEvent, PersistentPtyInfo } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import path from "path"
@@ -36,6 +36,8 @@ async function renderSessionTabs(
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
newLocation?: "launch" | "inherit"
groups?: Record<string, GroupItem[]>
terminals?: Record<string, PersistentPtyInfo[]>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -55,7 +57,9 @@ async function renderSessionTabs(
const sessions: string[] = []
const locations: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
const removedGroups: string[] = []
const removedTerminals: string[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") {
const requested = url.searchParams.get("location[directory]") ?? directory
locations.push(requested)
@@ -72,6 +76,19 @@ async function renderSessionTabs(
data: { branch: { current: "main", default: "main" } },
})
}
const terminalGroupID = url.pathname.match(/^\/api\/pty-group\/([^/]+)\/terminal$/)?.[1]
if (terminalGroupID && request.method === "GET") return json({ data: options?.terminals?.[terminalGroupID] ?? [] })
const groupID = url.pathname.match(/^\/api\/pty-group\/([^/]+)$/)?.[1]
if (groupID && request.method === "GET") return json({ data: { id: groupID, items: options?.groups?.[groupID] ?? [] } })
if (groupID && request.method === "DELETE") {
removedGroups.push(groupID)
return new Response(null, { status: 204 })
}
const ptyID = url.pathname.match(/^\/api\/persistent-pty\/([^/]+)$/)?.[1]
if (ptyID && request.method === "DELETE") {
removedTerminals.push(ptyID)
return new Response(null, { status: 204 })
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -140,6 +157,8 @@ async function renderSessionTabs(
sessions,
locations,
vcsLocations,
removedGroups,
removedTerminals,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
focus: () => app.renderer.emit("focus"),
@@ -409,3 +428,57 @@ test("add inherits the current session location when configured", async () => {
await setup.destroy()
}
})
test("closing a terminal-only workspace tab terminates its terminals and removes its group", async () => {
const groupID = "grp_terminal"
const terminal = {
id: "pty_terminal",
title: "Terminal",
command: "/bin/sh",
args: [],
cwd: directory,
status: "running" as const,
pid: 123,
groupID,
size: { cols: 80, rows: 24 },
output: { head: 0, tail: 0 },
}
const setup = await renderSessionTabs("first", {
home: true,
groups: { [groupID]: [{ type: "terminal", id: terminal.id }] },
terminals: { [groupID]: [terminal] },
})
try {
setup.tabs.openWorkspace(groupID, directory)
await wait(() => setup.tabs.current() === groupID && setup.tabs.tabs().some((tab) => tab.groupID === groupID))
setup.tabs.close(groupID)
await wait(() => setup.removedGroups.includes(groupID) && !setup.tabs.tabs().some((tab) => tab.groupID === groupID))
expect(setup.removedTerminals).toEqual([terminal.id])
expect(setup.route.data).toEqual({ type: "home" })
} finally {
await setup.destroy()
}
})
test("closing a workspace tab with a session only detaches it", async () => {
const groupID = "grp_session"
const setup = await renderSessionTabs("first", {
home: true,
groups: { [groupID]: [{ type: "session", id: "ses_one" }] },
})
try {
setup.tabs.openWorkspace(groupID, directory)
await wait(() => setup.tabs.current() === groupID && setup.tabs.tabs().some((tab) => tab.groupID === groupID))
setup.tabs.close(groupID)
await wait(() => !setup.tabs.tabs().some((tab) => tab.groupID === groupID))
expect(setup.removedGroups).toEqual([])
expect(setup.removedTerminals).toEqual([])
expect(setup.route.data).toEqual({ type: "home" })
} finally {
await setup.destroy()
}
})
@@ -43,3 +43,28 @@ test("prompt footer separates simultaneous subagent, shell, and usage status", a
app.renderer.destroy()
}
})
test("home prompt footer offers a new terminal instead of agent and command hints", async () => {
const color = RGBA.fromInts(200, 200, 200)
const context = {
theme: { text: { default: color, subdued: color } },
keymap: { shortcuts: () => [] },
data: {
session: { family: () => [], status: () => "idle" },
shell: { list: () => [] },
},
} as unknown as Context
const app = await testRender(() => <PromptFooter context={context} mode="normal" onNewTerminal={async () => {}} />, {
width: 80,
height: 2,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("new terminal")
expect(app.captureCharFrame()).not.toContain("agents")
expect(app.captureCharFrame()).not.toContain("commands")
} finally {
app.renderer.destroy()
}
})
+1
View File
@@ -157,6 +157,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })
if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })
if (url.pathname === "/api/pty-group") return json([])
throw new Error(`unexpected request: ${url.pathname}`)
}
fetch.preconnect = () => {}