mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 14:16:30 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf112cb49c |
@@ -4,13 +4,11 @@ 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,13 +3,10 @@ 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* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
yield* Service.stop(yield* ServiceConfig.options())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,22 +56,12 @@ 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 })
|
||||
|
||||
@@ -32,7 +32,6 @@ 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"
|
||||
@@ -1436,249 +1435,44 @@ export interface PtyApi<E = never> {
|
||||
readonly remove: PtyRemoveOperation<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 = {
|
||||
export type Endpoint21_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 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 Endpoint22_1Input = {
|
||||
export type Endpoint21_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 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_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_2Input = {
|
||||
export type Endpoint21_2Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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_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_3Input = {
|
||||
export type Endpoint21_3Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly timeout: number
|
||||
}
|
||||
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_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_4Input = {
|
||||
export type Endpoint21_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 Endpoint22_4Output = {
|
||||
export type Endpoint21_4Output = {
|
||||
readonly location: Location.Info
|
||||
readonly data: {
|
||||
readonly output: string
|
||||
@@ -1687,14 +1481,14 @@ export type Endpoint22_4Output = {
|
||||
readonly truncated: boolean
|
||||
}
|
||||
}
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint22_4Input) => Effect.Effect<Endpoint22_4Output, E>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
|
||||
|
||||
export type Endpoint22_5Input = {
|
||||
export type Endpoint21_5Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_5Output = void
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint22_5Input) => Effect.Effect<Endpoint22_5Output, E>
|
||||
export type Endpoint21_5Output = void
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
||||
|
||||
export interface ShellApi<E = never> {
|
||||
readonly list: ShellListOperation<E>
|
||||
@@ -1705,41 +1499,41 @@ export interface ShellApi<E = never> {
|
||||
readonly remove: ShellRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = {
|
||||
export type Endpoint22_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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 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 interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<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_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_1Input = {
|
||||
export type Endpoint23_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint23_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint23_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type Endpoint23_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, 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 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 interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
@@ -1748,25 +1542,25 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
export type Endpoint24_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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_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_1Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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_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_2Input = {
|
||||
export type Endpoint24_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
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 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 interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
@@ -1774,20 +1568,20 @@ export interface VcsApi<E = never> {
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint26_1Input = {
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint26_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||
export type Endpoint25_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Output =
|
||||
export type Endpoint26_0Output =
|
||||
| { readonly status: "required" | "completed" }
|
||||
| {
|
||||
readonly status: "running"
|
||||
@@ -1798,36 +1592,36 @@ export type Endpoint27_0Output =
|
||||
}
|
||||
}
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
export type Endpoint27_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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_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_1Input = {
|
||||
export type Endpoint27_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
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 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 interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = {
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
@@ -1855,7 +1649,6 @@ 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>
|
||||
|
||||
@@ -186,6 +186,7 @@ import type {
|
||||
Endpoint20_3Output,
|
||||
Endpoint20_4Input,
|
||||
Endpoint20_4Output,
|
||||
Endpoint21_0Input,
|
||||
Endpoint21_0Output,
|
||||
Endpoint21_1Input,
|
||||
Endpoint21_1Output,
|
||||
@@ -197,59 +198,32 @@ 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,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Input,
|
||||
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"
|
||||
|
||||
@@ -1123,158 +1097,28 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
|
||||
remove: Endpoint20_4(raw),
|
||||
})
|
||||
|
||||
const Endpoint21_0 = (raw: RawClient["server.persistentPty"]) => () =>
|
||||
const Endpoint21_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint21_0Input) =>
|
||||
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 Endpoint22_1 = (raw: RawClient["server.shell"]) => (input: Endpoint22_1Input) =>
|
||||
preserveEffect<Endpoint22_1Output>()(
|
||||
const Endpoint21_1 = (raw: RawClient["server.shell"]) => (input: Endpoint21_1Input) =>
|
||||
preserveEffect<Endpoint21_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 Endpoint22_2 = (raw: RawClient["server.shell"]) => (input: Endpoint22_2Input) =>
|
||||
preserveEffect<Endpoint22_2Output>()(
|
||||
const Endpoint21_2 = (raw: RawClient["server.shell"]) => (input: Endpoint21_2Input) =>
|
||||
preserveEffect<Endpoint21_2Output>()(
|
||||
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Input) =>
|
||||
preserveEffect<Endpoint22_3Output>()(
|
||||
const Endpoint21_3 = (raw: RawClient["server.shell"]) => (input: Endpoint21_3Input) =>
|
||||
preserveEffect<Endpoint21_3Output>()(
|
||||
raw["shell.timeout"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"] },
|
||||
@@ -1282,134 +1126,134 @@ const Endpoint22_3 = (raw: RawClient["server.shell"]) => (input: Endpoint22_3Inp
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_4 = (raw: RawClient["server.shell"]) => (input: Endpoint22_4Input) =>
|
||||
preserveEffect<Endpoint22_4Output>()(
|
||||
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
|
||||
preserveEffect<Endpoint21_4Output>()(
|
||||
raw["shell.output"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_5 = (raw: RawClient["server.shell"]) => (input: Endpoint22_5Input) =>
|
||||
preserveEffect<Endpoint22_5Output>()(
|
||||
const Endpoint21_5 = (raw: RawClient["server.shell"]) => (input: Endpoint21_5Input) =>
|
||||
preserveEffect<Endpoint21_5Output>()(
|
||||
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
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 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 Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
||||
preserveEffect<Endpoint22_0Output>()(
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
||||
preserveEffect<Endpoint23_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 Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
||||
preserveEffect<Endpoint23_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
||||
preserveEffect<Endpoint23_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint23_0(raw),
|
||||
create: Endpoint23_1(raw),
|
||||
remove: Endpoint23_2(raw),
|
||||
refresh: Endpoint23_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint24_0(raw),
|
||||
status: Endpoint24_1(raw),
|
||||
diff: Endpoint24_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
||||
preserveEffect<Endpoint26_1Output>()(
|
||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
||||
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
@@ -1433,15 +1277,14 @@ const adaptClient = (raw: RawClient) => ({
|
||||
skill: adaptGroup18(raw["server.skill"]),
|
||||
event: adaptGroup19(raw["server.event"]),
|
||||
pty: adaptGroup20(raw["server.pty"]),
|
||||
"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"]),
|
||||
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"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -182,32 +182,6 @@ 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,
|
||||
@@ -1597,175 +1571,6 @@ 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,8 +328,6 @@ 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" }
|
||||
| {
|
||||
@@ -341,22 +339,6 @@ 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"
|
||||
@@ -1493,26 +1475,6 @@ 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
|
||||
@@ -1522,13 +1484,6 @@ 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>
|
||||
@@ -2120,8 +2075,6 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| GroupItemAdded
|
||||
| GroupItemRemoved
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
@@ -2294,10 +2247,6 @@ 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"
|
||||
@@ -5486,133 +5435,6 @@ 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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { PersistentPty } from "./persistent-pty/index.js"
|
||||
export { Group } from "./persistent-pty/group.js"
|
||||
@@ -1,103 +0,0 @@
|
||||
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] })
|
||||
@@ -1,736 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -171,7 +171,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -87,7 +86,6 @@ type ApiGroups<
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| typeof WorktreeGroup
|
||||
| typeof PersistentPtyGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -168,7 +166,6 @@ const makeApiFromGroup = <
|
||||
.add(SkillGroup.middleware(locationMiddleware))
|
||||
.add(eventGroup)
|
||||
.add(PtyGroup.middleware(locationMiddleware))
|
||||
.add(PersistentPtyGroup)
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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." }))
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -57,7 +56,6 @@ const featureDefinitions = Event.inventory(
|
||||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
...Group.Event.Definitions,
|
||||
...WebSearch.Event.Definitions,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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) }
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -32,7 +31,6 @@ 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"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,7 +4,6 @@ import {
|
||||
Config,
|
||||
FileSystem,
|
||||
Form,
|
||||
Group,
|
||||
Integration,
|
||||
Permission,
|
||||
Project,
|
||||
@@ -65,7 +64,6 @@ 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])
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@ 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"
|
||||
@@ -56,7 +55,6 @@ export const handlers = Layer.mergeAll(
|
||||
SkillHandler,
|
||||
EventHandler.pipe(Layer.provide(EventFeed.layer)),
|
||||
PtyHandler,
|
||||
PersistentPtyHandler,
|
||||
ShellHandler,
|
||||
ReferenceHandler,
|
||||
WorktreeHandler,
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
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,7 +3,6 @@ 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"
|
||||
|
||||
@@ -50,8 +49,7 @@ 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.
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) 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)),
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -184,11 +183,7 @@ 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) && !hasPersistentPtyConnectTicketURL(url))) &&
|
||||
!(yield* authorizedRequest(request, auth))
|
||||
)
|
||||
return unauthorized()
|
||||
if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
if (ready) return yield* app.value
|
||||
return unavailable(state)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -61,8 +60,6 @@ const applicationServiceNodes = [
|
||||
SdkPlugins.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
Group.node,
|
||||
PersistentPty.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+50
-65
@@ -31,6 +31,7 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
TuiLifecycleProvider,
|
||||
TuiAppProvider,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
type TuiApp,
|
||||
} from "./context/runtime"
|
||||
import { DialogProvider, useDialog } from "./ui/dialog"
|
||||
import { linkAt } from "./ui/link"
|
||||
import { DialogIntegration } from "./component/dialog-integration"
|
||||
import { ErrorComponent } from "./component/error-component"
|
||||
import { PluginRouteMissing } from "./component/plugin-route-missing"
|
||||
@@ -75,6 +77,7 @@ 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"
|
||||
@@ -98,8 +101,6 @@ 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 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), endpoint }
|
||||
return { api: OpenCode.make(next) }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
@@ -372,50 +373,48 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} endpoint={input.server.endpoint} service={service}>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<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>
|
||||
<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>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
@@ -608,11 +607,6 @@ 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}`)
|
||||
}
|
||||
@@ -1277,7 +1271,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={(event) => {
|
||||
if (copyOnSelectEnabled()) Selection.copy(renderer, toast, clipboard)
|
||||
if (event.defaultPrevented || event.button !== MouseButton.LEFT || event.isDragging) return
|
||||
const href = linkAt(renderer.currentRenderBuffer, event.x, event.y)
|
||||
if (!href) return
|
||||
open(href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
@@ -1309,22 +1309,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
|
||||
{(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}
|
||||
/>
|
||||
)}
|
||||
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={route.data.type === "plugin"}>
|
||||
|
||||
@@ -17,13 +17,12 @@ 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" | "layout"
|
||||
type Panel = "server" | "theme" | "tools" | "ui"
|
||||
type ProcessSample = Readonly<{ cpu: number; memory: number; delay: number; time: number }>
|
||||
export type RuntimeStatus = "normal" | "medium" | "high"
|
||||
|
||||
@@ -39,7 +38,6 @@ 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>()
|
||||
@@ -48,8 +46,6 @@ 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()))
|
||||
@@ -224,18 +220,6 @@ 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()}>
|
||||
@@ -434,31 +418,6 @@ 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={() => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -1540,25 +1539,21 @@ export function Prompt(props: PromptProps) {
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const footerLocation = createMemo(() => {
|
||||
const locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
if (!location) return
|
||||
const directory = abbreviateHome(location.directory, paths.home)
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent = status() === "running" ? local.agent.current() : local.agent.current()
|
||||
@@ -1879,17 +1874,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
onMouseOver={locationActions.onMouseOver}
|
||||
onMouseOut={locationActions.onMouseOut}
|
||||
onMouseUp={locationActions.onMouseUp}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -51,7 +51,8 @@ const RIGHT_MOUSE_BUTTON = 2
|
||||
type TabContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
tab?: SessionTab
|
||||
sessionID?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
@@ -188,20 +189,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
||||
const theme = useTheme("elevated")
|
||||
const dialog = useDialog()
|
||||
const actions = createMemo(() => {
|
||||
const tab = props.state.tab
|
||||
const sessionID = props.state.sessionID
|
||||
return [
|
||||
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
|
||||
...(tab
|
||||
...(sessionID
|
||||
? [
|
||||
...(!tab.groupID
|
||||
? [
|
||||
{
|
||||
title: "Rename",
|
||||
run: () => DialogSessionRename.show(dialog, tab.sessionID, tab.title),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ title: "Close", run: () => props.tabs.close(tab.sessionID) },
|
||||
{
|
||||
title: "Rename",
|
||||
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
|
||||
},
|
||||
{ title: "Close", run: () => props.tabs.close(sessionID) },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
@@ -310,7 +307,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
@@ -347,9 +343,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
@@ -371,29 +364,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}
|
||||
})
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!rail) return
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
didDrag = true
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
|
||||
)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
@@ -405,15 +375,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
@@ -423,7 +384,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(() => (tab.groupID ? undefined : data.session.get(tab.sessionID)))
|
||||
const session = createMemo(() => data.session.get(tab.sessionID))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
@@ -432,7 +393,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 ?? (tab.groupID ? "Terminal" : "Untitled session")
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID
|
||||
const visibleTitleParts = createMemo(() =>
|
||||
scrolling()
|
||||
@@ -451,7 +412,6 @@ 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) ?? ""
|
||||
@@ -562,6 +522,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
: color
|
||||
return separator ? tint(faded, pulseBackground(), 0.55) : faded
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
@@ -573,22 +539,38 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (!rail) return
|
||||
setContextMenu({
|
||||
x: event.x - rail.screenX,
|
||||
y: event.y - rail.screenY,
|
||||
tab,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
sessionID: tab.sessionID,
|
||||
title: tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
top={-1}
|
||||
@@ -695,14 +677,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
@@ -761,8 +737,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
@@ -771,7 +745,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
@@ -801,7 +774,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
@@ -831,7 +803,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createTabMarquee(animations)
|
||||
const hovered = marquee.hovered
|
||||
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
@@ -839,9 +810,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
let didDrag = false
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
@@ -963,29 +931,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
return layout().before + layout().widths.length - 1
|
||||
}
|
||||
|
||||
const release = () => {
|
||||
const source = dragging()
|
||||
if (!source) return
|
||||
if (didDrag) suppressClick = true
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
|
||||
if (source === NEW_SESSION_TAB.sessionID) return
|
||||
tabs.select(source)
|
||||
}
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
const source = dragging()
|
||||
if (!source || source === NEW_SESSION_TAB.sessionID) return
|
||||
didDrag = true
|
||||
const slot = slotAt(event.x)
|
||||
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
|
||||
const sourceIndex = items().findIndex((item) => item.sessionID === source)
|
||||
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
|
||||
setPreview({ sessionID: source, index: target })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (strip = element)}
|
||||
@@ -995,15 +940,24 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
if (!didDrag) return
|
||||
didDrag = false
|
||||
queueMicrotask(() => (suppressClick = false))
|
||||
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,
|
||||
),
|
||||
)
|
||||
}}
|
||||
onMouseDrag={drag}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<Show when={layout().before > 0}>
|
||||
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
|
||||
@@ -1035,7 +989,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 ?? (tab.groupID ? "Terminal" : "Untitled session")
|
||||
const title = () => tab.title ?? "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
|
||||
@@ -1097,6 +1051,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}
|
||||
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
width={width()}
|
||||
@@ -1107,21 +1070,31 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
setContextMenu({
|
||||
x: event.x - (strip?.screenX ?? 0),
|
||||
y: event.y - (strip?.screenY ?? 0),
|
||||
tab: tab === NEW_SESSION_TAB ? undefined : tab,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
|
||||
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
didDrag = false
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
@@ -1167,14 +1140,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
|
||||
didDrag = false
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
@@ -1203,8 +1170,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -1212,7 +1177,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (suppressClick) return
|
||||
tabs.add?.()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
|
||||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -30,12 +29,11 @@ const eventFlushInterval = 10
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; endpoint?: Endpoint; service?: ManagedService }) => {
|
||||
init: (props: { api: OpenCodeClient; 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
|
||||
@@ -160,7 +158,6 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -182,9 +179,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
get api() {
|
||||
return api
|
||||
},
|
||||
get endpoint() {
|
||||
return endpoint
|
||||
},
|
||||
event: {
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
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))
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -17,11 +17,6 @@ export type SessionRoute = {
|
||||
prompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type WorkspaceRoute = {
|
||||
type: "workspace"
|
||||
groupID: string
|
||||
}
|
||||
|
||||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
@@ -29,7 +24,7 @@ export type PluginRoute = {
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | WorkspaceRoute | PluginRoute
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
@@ -56,9 +51,6 @@ 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,8 +1,6 @@
|
||||
export type SessionTab = {
|
||||
sessionID: string
|
||||
title?: string
|
||||
groupID?: string
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
@@ -74,7 +74,6 @@ 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>()
|
||||
@@ -113,7 +112,6 @@ 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) })
|
||||
}, []),
|
||||
@@ -123,11 +121,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
return result
|
||||
}, {}),
|
||||
})
|
||||
const current = () => {
|
||||
if (route.data.type === "session") return root(route.data.sessionID)
|
||||
if (route.data.type === "workspace") return route.data.groupID
|
||||
return undefined
|
||||
}
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
if (route.data.type === "home") return true
|
||||
if (!open) return false
|
||||
@@ -135,9 +129,6 @@ 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]
|
||||
@@ -164,10 +155,6 @@ 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)
|
||||
@@ -211,8 +198,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
// the first connection slots and switches still render from a warm cache.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.filter((tab) => !tab.groupID)
|
||||
.map((tab) => tab.sessionID)
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.sort()
|
||||
.join("\n"),
|
||||
)
|
||||
@@ -240,8 +226,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})()
|
||||
const timer = setTimeout(async () => {
|
||||
const sessions = state()
|
||||
.tabs.filter((tab) => !tab.groupID)
|
||||
.map((tab) => tab.sessionID)
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
.filter((sessionID) => sessionID !== current())
|
||||
for (const sessionID of sessions) {
|
||||
if (stale) return
|
||||
@@ -273,48 +258,16 @@ 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.groupID || entry.tab.sessionID !== target)
|
||||
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
|
||||
remove(evt.data.sessionID, enabled())
|
||||
}),
|
||||
)
|
||||
|
||||
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)
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
scrollAnchors.delete(target)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = shouldNavigate && current() === target
|
||||
const selected = navigate && current() === target
|
||||
if (closed.tabs === state().tabs && !selected) return
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
@@ -331,25 +284,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
delete next[target]
|
||||
return next
|
||||
})
|
||||
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)
|
||||
}
|
||||
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -379,25 +314,8 @@ 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()
|
||||
@@ -414,21 +332,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? (tab(sessionID)?.groupID ? sessionID : root(sessionID)) : current()
|
||||
const target = 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) navigate(session)
|
||||
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
|
||||
return
|
||||
}
|
||||
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
|
||||
const selected = state().tabs[index]
|
||||
if (selected?.groupID) {
|
||||
void closeWorkspace(selected)
|
||||
return
|
||||
}
|
||||
if (selected) closedTabs = recordClosedSessionTab(closedTabs, selected, index)
|
||||
const tab = state().tabs[index]
|
||||
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
|
||||
remove(target, true)
|
||||
},
|
||||
reopen() {
|
||||
@@ -444,7 +358,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
},
|
||||
move(sessionID: string, index: number) {
|
||||
if (!enabled()) return
|
||||
const session = tab(sessionID)?.groupID ? sessionID : root(sessionID)
|
||||
const session = root(sessionID)
|
||||
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
|
||||
update((draft) => {
|
||||
draft.tabs = moveSessionTab(draft.tabs, session, index)
|
||||
@@ -453,19 +367,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
cycle(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(state().tabs, current(), direction)
|
||||
if (tab) navigate(tab.sessionID)
|
||||
if (tab) route.navigate({ type: "session", sessionID: 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) navigate(tab.sessionID)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
selectIndex(index: number) {
|
||||
if (!enabled()) return
|
||||
const tab = state().tabs[index]
|
||||
if (tab) navigate(tab.sessionID)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<Switch>
|
||||
<Match when={failed()}>
|
||||
@@ -56,7 +56,7 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={props.context.theme.text.default}>
|
||||
<span style={{ fg: props.context.theme.text.feedback.error.default }}>⊙ </span>
|
||||
{failed()} plugin{failed() === 1 ? "" : "s"} failed
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, 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"
|
||||
onNewTerminal?: () => Promise<void>
|
||||
}) {
|
||||
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
|
||||
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
|
||||
@@ -50,53 +41,33 @@ export function PromptFooter(props: {
|
||||
})
|
||||
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"}>
|
||||
<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"}
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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}>
|
||||
</Match>
|
||||
<Match 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>
|
||||
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Show>
|
||||
</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>
|
||||
</Match>
|
||||
<Match when={props.mode === "shell"}>
|
||||
@@ -111,31 +82,12 @@ export function PromptFooter(props: {
|
||||
)
|
||||
}
|
||||
|
||||
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) => <PromptFooterSlot context={context} sessionID={props.sessionID} mode={props.mode} />,
|
||||
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const move = usePromptMove({
|
||||
projectID: () => props.context.data.session.get(props.sessionID)?.projectID,
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const actions = useWorkingDirectoryActions({
|
||||
directory: () => props.context.location?.directory,
|
||||
onMove: () => void move.open(),
|
||||
})
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -21,20 +11,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<box
|
||||
id="sidebar.footer.location"
|
||||
onMouseOver={actions.onMouseOver}
|
||||
onMouseOut={actions.onMouseOut}
|
||||
onMouseUp={actions.onMouseUp}
|
||||
>
|
||||
<FilePath
|
||||
value={value()}
|
||||
maxWidth={38}
|
||||
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -44,9 +21,6 @@ export default Plugin.define({
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({
|
||||
append: "sidebar.footer",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -168,22 +168,17 @@ 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()
|
||||
.filter((tab) => !tab.groupID)
|
||||
.map((tab) => ({
|
||||
sessionID: tab.sessionID,
|
||||
title: tab.title,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...host.sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
host.sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...host.sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
open(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
host.sessionTabs.select(sessionID)
|
||||
@@ -191,14 +186,14 @@ export function createPluginContext(input: {
|
||||
},
|
||||
focus(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
if (!host.sessionTabs.tabs().some((tab) => !tab.groupID && tab.sessionID === sessionID)) return false
|
||||
if (!host.sessionTabs.tabs().some((tab) => 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.groupID && tab.sessionID === target)) return false
|
||||
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
|
||||
host.sessionTabs.close(target)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { getLinkId, type OptimizedBuffer, type RGBA } from "@opentui/core"
|
||||
import open from "open"
|
||||
|
||||
export interface LinkProps {
|
||||
@@ -24,7 +24,8 @@ export function Link(props: LinkProps) {
|
||||
bg={props.bg}
|
||||
width={props.width}
|
||||
wrapMode={props.wrapMode}
|
||||
onMouseUp={() => {
|
||||
onMouseUp={(event) => {
|
||||
event.stopPropagation()
|
||||
open(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
@@ -32,3 +33,11 @@ export function Link(props: LinkProps) {
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function linkAt(buffer: OptimizedBuffer, x: number, y: number) {
|
||||
if (x < 0 || x >= buffer.width || y < 0 || y >= buffer.height) return
|
||||
const id = getLinkId(buffer.buffers.attributes[y * buffer.width + x] ?? 0)
|
||||
if (!id) return
|
||||
const lib = buffer.lib as typeof buffer.lib & { linkGetUrl(id: number): string }
|
||||
return lib.linkGetUrl(id) || undefined
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import open from "open"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useDialog } from "./dialog"
|
||||
import { DialogSelect } from "./dialog-select"
|
||||
import { useToast } from "./toast"
|
||||
|
||||
export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) {
|
||||
const clipboard = useClipboard()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const toast = useToast()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
|
||||
function openMenu() {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
const directory = input.directory()
|
||||
if (!directory) return
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Working directory"
|
||||
renderFilter={false}
|
||||
options={[
|
||||
{
|
||||
title: "Copy path",
|
||||
value: "location.copy",
|
||||
description: directory,
|
||||
onSelect: (dialog) => {
|
||||
void clipboard.write(directory).then(() => {
|
||||
dialog.clear()
|
||||
toast.show({ message: "Path copied to clipboard", variant: "info" })
|
||||
}, toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Open folder",
|
||||
value: "location.open",
|
||||
description: "in system file manager",
|
||||
onSelect: (dialog) => {
|
||||
dialog.clear()
|
||||
void open(directory).catch(toast.error)
|
||||
},
|
||||
},
|
||||
...(input.onMove
|
||||
? [
|
||||
{
|
||||
title: "Move session",
|
||||
value: "session.move",
|
||||
description: "to another working directory",
|
||||
onSelect: () => void input.onMove?.(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
hovered,
|
||||
onMouseOver: () => setHovered(true),
|
||||
onMouseOut: () => setHovered(false),
|
||||
onMouseUp: openMenu,
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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 { GroupItem, OpenCodeEvent, PersistentPtyInfo } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdirSync, watch } from "fs"
|
||||
import path from "path"
|
||||
@@ -36,8 +36,6 @@ 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()
|
||||
@@ -57,9 +55,7 @@ async function renderSessionTabs(
|
||||
const sessions: string[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const removedGroups: string[] = []
|
||||
const removedTerminals: string[] = []
|
||||
const calls = createFetch(async (url, request) => {
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
@@ -76,19 +72,6 @@ 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)
|
||||
@@ -157,8 +140,6 @@ async function renderSessionTabs(
|
||||
sessions,
|
||||
locations,
|
||||
vcsLocations,
|
||||
removedGroups,
|
||||
removedTerminals,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
@@ -428,57 +409,3 @@ 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,28 +43,3 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,7 +157,6 @@ 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 = () => {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { StyledText, TextRenderable } from "@opentui/core"
|
||||
import { createTestRenderer, setRendererCapabilities } from "@opentui/core/testing"
|
||||
import { linkAt } from "../../src/ui/link"
|
||||
|
||||
test("resolves terminal hyperlink metadata at the clicked cell", async () => {
|
||||
const app = await createTestRenderer({ width: 60, height: 4 })
|
||||
setRendererCapabilities(app.renderer, { hyperlinks: true })
|
||||
const href = "file:///tmp/example.ts#L12"
|
||||
app.renderer.root.add(
|
||||
new TextRenderable(app.renderer, {
|
||||
content: new StyledText([{ __isChunk: true, text: "example", link: { url: href } }]),
|
||||
width: "100%",
|
||||
height: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("example"))
|
||||
const buffer = app.renderer.currentRenderBuffer
|
||||
const links = Array.from({ length: buffer.width * buffer.height }, (_, index) =>
|
||||
linkAt(buffer, index % buffer.width, Math.floor(index / buffer.width)),
|
||||
)
|
||||
expect(links).toContain(href)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user