mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 17:46:14 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2efc73f12d | ||
|
|
94f9d32040 | ||
|
|
ea3e0dde19 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
|
||||
@@ -751,11 +751,6 @@
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -1739,12 +1734,6 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
|
||||
|
||||
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
@@ -1951,30 +1940,6 @@
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.2", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.2", "@napi-rs/canvas-darwin-arm64": "1.0.2", "@napi-rs/canvas-darwin-x64": "1.0.2", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", "@napi-rs/canvas-linux-arm64-musl": "1.0.2", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-musl": "1.0.2", "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="],
|
||||
@@ -6465,8 +6430,6 @@
|
||||
|
||||
"@opencode-ai/session-ui/vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
||||
|
||||
"@opencode-ai/simulation/@fontsource/commit-mono": ["@fontsource/commit-mono@5.2.5", "", {}, "sha512-htX8yQWtiPt5L1Hzh4sirvfUJT2+KYiquDB/Q2sY2tWQYplpBUOD5zHnIM3k36Hnm4V+JIIqA/wmwupSQ68WjA=="],
|
||||
|
||||
"@opencode-ai/stats-app/vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
|
||||
|
||||
"@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
|
||||
@@ -236,6 +236,7 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
||||
"desktop.menu.window": "Window",
|
||||
"desktop.menu.help": "Help",
|
||||
"desktop.menu.checkForUpdates": "Check for Updates...",
|
||||
"desktop.menu.installCli": "Install CLI...",
|
||||
"desktop.menu.settings": "Settings",
|
||||
"desktop.menu.reloadWebview": "Reload Webview",
|
||||
"desktop.menu.restart": "Restart",
|
||||
@@ -283,6 +284,11 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
||||
"desktop.updater.dialog.restart": "Restart",
|
||||
"desktop.updater.dialog.later": "Later",
|
||||
|
||||
"desktop.cli.installed.title": "CLI Installed",
|
||||
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
|
||||
"desktop.cli.failed.title": "Installation Failed",
|
||||
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
|
||||
|
||||
"desktop.recovery.action.relaunch": "Relaunch",
|
||||
"desktop.recovery.action.exportLogs": "Export Logs",
|
||||
"desktop.recovery.action.keepWaiting": "Keep Waiting",
|
||||
|
||||
@@ -2,6 +2,13 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("installs the CLI from the macOS application menu", () => {
|
||||
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
|
||||
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
|
||||
|
||||
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||
|
||||
@@ -4,6 +4,7 @@ export type DesktopMenuPlatform = "macos" | "windows"
|
||||
|
||||
export type DesktopMenuAction =
|
||||
| "app.checkForUpdates"
|
||||
| "app.installCli"
|
||||
| "app.relaunch"
|
||||
| "edit.undo"
|
||||
| "edit.redo"
|
||||
@@ -84,6 +85,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
action: "app.checkForUpdates",
|
||||
enabled: "updater",
|
||||
},
|
||||
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
|
||||
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
|
||||
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
|
||||
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1449,249 +1449,44 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<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
|
||||
@@ -1700,14 +1495,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>
|
||||
@@ -1718,41 +1513,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>
|
||||
@@ -1761,25 +1556,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>
|
||||
@@ -1787,20 +1582,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"
|
||||
@@ -1811,36 +1606,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>
|
||||
@@ -1868,7 +1663,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>
|
||||
|
||||
@@ -188,6 +188,7 @@ import type {
|
||||
Endpoint20_4Output,
|
||||
Endpoint20_5Input,
|
||||
Endpoint20_5Output,
|
||||
Endpoint21_0Input,
|
||||
Endpoint21_0Output,
|
||||
Endpoint21_1Input,
|
||||
Endpoint21_1Output,
|
||||
@@ -199,59 +200,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"
|
||||
|
||||
@@ -1139,158 +1113,28 @@ const adaptGroup20 = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: Endpoint20_5(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"] },
|
||||
@@ -1298,134 +1142,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"]),
|
||||
@@ -1449,15 +1293,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 }) =>
|
||||
|
||||
@@ -184,32 +184,6 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
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,
|
||||
@@ -1614,175 +1588,6 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
"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>(
|
||||
|
||||
@@ -130,12 +130,21 @@ export type SkillInfo = {
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: Array<string>
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
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" }
|
||||
| {
|
||||
@@ -1060,51 +1069,24 @@ export type PermissionReplied = {
|
||||
data: { sessionID: string; requestID: string; reply: PermissionReply }
|
||||
}
|
||||
|
||||
export type Pty = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: Array<string>
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
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 GroupItemAdded = {
|
||||
export type PtyCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.added"
|
||||
type: "pty.created"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type GroupItemRemoved = {
|
||||
export type PtyUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.removed"
|
||||
type: "pty.updated"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type GroupInfo = { id: string; items: Array<GroupItem> }
|
||||
|
||||
export type SessionStatus2 = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1421,31 +1403,6 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type PtyCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "pty.created"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type PtyUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "pty.updated"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type PersistentPtySnapshot = {
|
||||
info: PersistentPtyInfo
|
||||
text: string
|
||||
checkpoint: string
|
||||
cursor: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -2138,8 +2095,6 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| GroupItemAdded
|
||||
| GroupItemRemoved
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
@@ -5536,133 +5491,6 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+10
-15
@@ -109,16 +109,11 @@ export const layer = Layer.effect(
|
||||
},
|
||||
)
|
||||
|
||||
const find = Effect.fn("Form.find")(function* (id: ID) {
|
||||
return yield* Cache.getSuccess(forms, id).pipe(
|
||||
Effect.flatMap((entry) =>
|
||||
Option.match(entry, {
|
||||
onNone: () => Effect.fail(new NotFoundError({ id })),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const requireEntry = Effect.fn("Form.requireEntry")((id: ID) =>
|
||||
Cache.getSuccess(forms, id).pipe(
|
||||
Effect.flatMap((entry) => Effect.fromOption(entry, () => new NotFoundError({ id }))),
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("Form.create")((input: CreateInput) =>
|
||||
Effect.uninterruptible(
|
||||
@@ -151,7 +146,7 @@ export const layer = Layer.effect(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const form = yield* create(input)
|
||||
const entry = yield* find(form.id).pipe(Effect.orDie)
|
||||
const entry = yield* requireEntry(form.id).pipe(Effect.orDie)
|
||||
return yield* restore(Deferred.await(entry.deferred)).pipe(
|
||||
Effect.onInterrupt(() => Effect.ignore(cancel(form.id))),
|
||||
)
|
||||
@@ -160,7 +155,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const get = Effect.fn("Form.get")(function* (id: ID) {
|
||||
return (yield* find(id)).form
|
||||
return (yield* requireEntry(id)).form
|
||||
})
|
||||
|
||||
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
|
||||
@@ -172,13 +167,13 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const state = Effect.fn("Form.state")(function* (id: ID) {
|
||||
return (yield* find(id)).state
|
||||
return (yield* requireEntry(id)).state
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* find(input.id)
|
||||
const entry = yield* requireEntry(input.id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
|
||||
const invalid = validateAnswer(entry.form.fields, input.answer)
|
||||
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
|
||||
@@ -197,7 +192,7 @@ export const layer = Layer.effect(
|
||||
const cancel = Effect.fn("Form.cancel")((id: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* find(id)
|
||||
const entry = yield* requireEntry(id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
|
||||
const next: TerminalState = { status: "cancelled" }
|
||||
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
|
||||
|
||||
@@ -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.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", {
|
||||
ptyID: Pty.ID,
|
||||
}) {}
|
||||
|
||||
export class GroupNotFoundError extends Schema.TaggedError<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
|
||||
}
|
||||
@@ -37,15 +37,17 @@ const layer = Layer.effect(
|
||||
// root so opening a subdirectory still describes paths from the project root.
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
// Same-step parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier steps after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
// Session/path pair while a load is in flight. The claim is released once the load
|
||||
// settles: the synthetic message metadata scanned below is the only lasting ledger,
|
||||
// so paths whose synthetics drop out of model-visible history (compaction, revert)
|
||||
// are re-discovered and re-injected instead of staying silently lost.
|
||||
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(injected, (map) => {
|
||||
const claimed = yield* Ref.modify(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||
@@ -54,30 +56,43 @@ const layer = Layer.effect(
|
||||
return [newlyClaimed, next]
|
||||
})
|
||||
if (claimed.length === 0) return
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
|
||||
{ concurrency: "unbounded" },
|
||||
yield* Effect.gen(function* () {
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through Session.synthetic: a Location-scoped layer
|
||||
// cannot depend on Session (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish commits the synthetic and its metadata
|
||||
// ledger atomically, so releasing the claim afterwards cannot readmit the paths.
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Ref.update(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID)
|
||||
if (!existing) return map
|
||||
const remaining = new Set([...existing].filter((path) => !claimed.includes(path)))
|
||||
const next = new Map(map)
|
||||
if (remaining.size === 0) next.delete(input.sessionID)
|
||||
else next.set(input.sessionID, remaining)
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through Session.synthetic: a Location-scoped layer
|
||||
// cannot depend on Session (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||
// metadata so it survives across Location layer restarts.
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ load })
|
||||
|
||||
@@ -42,18 +42,18 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
const assistant = yield* adapter.getAssistant(messageID)
|
||||
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||
if (!assistant) return
|
||||
yield* adapter.updateAssistant(produce(assistant, recipe))
|
||||
})
|
||||
|
||||
const clearCurrentRetry = Effect.gen(function* () {
|
||||
const assistant = yield* adapter.getCurrentAssistant()
|
||||
if (assistant?.retry) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(assistant, (draft) => {
|
||||
draft.retry = undefined
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (!assistant?.retry) return
|
||||
yield* adapter.updateAssistant(
|
||||
produce(assistant, (draft) => {
|
||||
draft.retry = undefined
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const project = pipe(
|
||||
|
||||
@@ -89,15 +89,14 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
if (options.finalize) yield* options.finalize(options.draft(next))
|
||||
})
|
||||
|
||||
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
|
||||
Effect.sync(() => {
|
||||
transform(draft)
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) yield* apply(transform.run, api)
|
||||
for (const transform of transforms) {
|
||||
yield* Effect.sync(() => {
|
||||
transform.run(api)
|
||||
})
|
||||
}
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
@@ -135,7 +134,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
return yield* Deferred.await(done)
|
||||
})
|
||||
|
||||
const result: Interface<State, DraftApi> = {
|
||||
return {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
@@ -176,5 +175,4 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
}),
|
||||
reload,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -233,6 +233,37 @@ describe("SessionInstructions", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-injects nested instructions dropped from history by compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(path.resolve(dir, "AGENTS.md"), "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
|
||||
|
||||
const session = yield* Session.Service
|
||||
const registry = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
yield* executeTool(registry, readCall(sessionID, "call-before", "sub/file.txt"))
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
|
||||
// A completed compaction truncates model-visible history at its boundary, dropping
|
||||
// the synthetic that carried sub's instructions.
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", text: "summary", recent: "" })
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(0)
|
||||
|
||||
// The model no longer has the rules, so the next read under the subtree must
|
||||
// re-inject them rather than trusting a stale in-memory claim.
|
||||
yield* executeTool(registry, readCall(sessionID, "call-after", "sub/file.txt"))
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("listing the Location root directory injects no instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc"
|
||||
import { openExternalURL } from "../files"
|
||||
import { checkAppExists, resolveAppPath } from "../files/apps"
|
||||
import { setForceFocus } from "../native/debug"
|
||||
import { showCliInstaller } from "../native/install-cli"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { createMenu, sendMenuCommand } from "../native/menu"
|
||||
import { setNativeTranslations } from "../native/translations"
|
||||
@@ -12,6 +13,7 @@ import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
|
||||
import { BackgroundService } from "../service/background-service"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
|
||||
import { Updater } from "../updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
|
||||
@@ -22,6 +24,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const background = yield* BackgroundService.Service
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const updater = yield* Updater.Service
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
@@ -56,6 +59,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => runFork(updater.show),
|
||||
installCli: () => runFork(showCliInstaller(desktopCli)),
|
||||
createWindow: lifecycle.createWindow,
|
||||
openExternal: (url) => runFork(openExternalURL(url)),
|
||||
relaunch: lifecycle.relaunch,
|
||||
|
||||
@@ -16,7 +16,9 @@ import { windowHandlers } from "./ipc-handlers/window"
|
||||
import { wslHandlers } from "./ipc-handlers/wsl"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { showCliInstaller } from "./native/install-cli"
|
||||
import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { DesktopStorage } from "./storage"
|
||||
import { Updater } from "./updater"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
@@ -42,6 +44,7 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true })
|
||||
export const registerIpcHandlers = Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const updater = yield* Updater.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const menu = {
|
||||
@@ -50,6 +53,7 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => runFork(updater.show),
|
||||
installCli: () => runFork(showCliInstaller(desktopCli)),
|
||||
createWindow: lifecycle.createWindow,
|
||||
openExternal: (url: string) => runFork(openExternalURL(url)),
|
||||
relaunch: lifecycle.relaunch,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { dialog } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { nativeT } from "./translations"
|
||||
|
||||
export function showCliInstaller(desktopCli: DesktopCli.Interface) {
|
||||
return desktopCli.install.pipe(
|
||||
Effect.tap((path) =>
|
||||
Effect.promise(() =>
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: nativeT("desktop.cli.installed.message", { path }),
|
||||
title: nativeT("desktop.cli.installed.title"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.promise(() =>
|
||||
dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: nativeT("desktop.cli.failed.message", { error: error.message }),
|
||||
title: nativeT("desktop.cli.failed.title"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { updateTitlebar } from "../windows"
|
||||
|
||||
export type DesktopMenuActionHandlers = Partial<{
|
||||
checkForUpdates: () => void
|
||||
installCli: () => void
|
||||
createWindow: () => void
|
||||
relaunch: () => void
|
||||
}>
|
||||
@@ -17,6 +18,9 @@ export function runDesktopMenuAction(
|
||||
case "app.checkForUpdates":
|
||||
handlers.checkForUpdates?.()
|
||||
return
|
||||
case "app.installCli":
|
||||
handlers.installCli?.()
|
||||
return
|
||||
case "app.relaunch":
|
||||
handlers.relaunch?.()
|
||||
return
|
||||
|
||||
@@ -16,6 +16,7 @@ import { nativeT } from "./translations"
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
checkForUpdates: () => void
|
||||
installCli: () => void
|
||||
createWindow: () => void
|
||||
openExternal: (url: string) => void
|
||||
relaunch: () => void
|
||||
@@ -60,6 +61,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
|
||||
item.click = () =>
|
||||
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
|
||||
checkForUpdates: deps.checkForUpdates,
|
||||
installCli: deps.installCli,
|
||||
createWindow: deps.createWindow,
|
||||
relaunch: deps.relaunch,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export * as DesktopCli from "./desktop-cli"
|
||||
|
||||
import { execFile } from "node:child_process"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import installer from "../../../../../install?raw"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
@@ -18,6 +19,7 @@ export interface Resolved {
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: Effect.Effect<Resolved>
|
||||
readonly install: Effect.Effect<string, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
|
||||
@@ -25,10 +27,19 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const path = yield* Path.Path
|
||||
const resolve = yield* Effect.cached(
|
||||
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
|
||||
)
|
||||
return Service.of({ resolve })
|
||||
const install = Effect.gen(function* () {
|
||||
if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS"))
|
||||
const cli = yield* resolve
|
||||
if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable"))
|
||||
const home = app.getPath("home")
|
||||
yield* runInstaller(cli.binary, home)
|
||||
return path.join(home, ".opencode", "bin", "opencode2")
|
||||
})
|
||||
return Service.of({ resolve, install })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -134,6 +145,27 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
|
||||
return stdout
|
||||
})
|
||||
|
||||
const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], {
|
||||
env: { ...process.env, HOME: home },
|
||||
stdio: ["pipe", "ignore", "pipe"],
|
||||
})
|
||||
let stderr = ""
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk))
|
||||
child.on("error", reject)
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve()
|
||||
reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`))
|
||||
})
|
||||
child.stdin.end(installer)
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
})
|
||||
|
||||
function executableName() {
|
||||
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
const DesktopMenuAction = Schema.Literals([
|
||||
"app.checkForUpdates",
|
||||
"app.installCli",
|
||||
"app.relaunch",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
|
||||
@@ -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." }))
|
||||
@@ -205,7 +205,6 @@ export namespace Frontend {
|
||||
"ui.click.semantic",
|
||||
"ui.resize",
|
||||
"ui.matches",
|
||||
"ui.screenshot",
|
||||
"ui.state",
|
||||
"ui.snapshot",
|
||||
"ui.capture",
|
||||
@@ -335,9 +334,6 @@ export namespace Frontend {
|
||||
export const RecordingFinish = Schema.String
|
||||
export type RecordingFinish = Schema.Schema.Type<typeof RecordingFinish>
|
||||
|
||||
export const Screenshot = Schema.String
|
||||
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
|
||||
|
||||
export const Matches = Schema.Boolean
|
||||
export type Matches = Schema.Schema.Type<typeof Matches>
|
||||
|
||||
@@ -347,9 +343,6 @@ export namespace Frontend {
|
||||
export const MatchesParams = Schema.Struct({ text: Schema.String })
|
||||
export interface MatchesParams extends Schema.Schema.Type<typeof MatchesParams> {}
|
||||
|
||||
export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) })
|
||||
export interface ScreenshotParams extends Schema.Schema.Type<typeof ScreenshotParams> {}
|
||||
|
||||
export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) })
|
||||
export interface PressParams extends Schema.Schema.Type<typeof PressParams> {}
|
||||
|
||||
@@ -384,11 +377,6 @@ export namespace Frontend {
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.resize"), params: ResizeParams }),
|
||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literal("ui.screenshot"),
|
||||
params: Schema.optional(ScreenshotParams),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...JsonRpc.RequestFields,
|
||||
method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]),
|
||||
@@ -634,7 +622,6 @@ export const UiRpcs = RpcGroup.make(
|
||||
request("ui.state", { success: Frontend.State }),
|
||||
request("ui.snapshot", { success: Frontend.SemanticSnapshot }),
|
||||
request("ui.capture", { success: Frontend.CapturedFrame }),
|
||||
request("ui.screenshot", { payload: Schema.UndefinedOr(Frontend.ScreenshotParams), success: Frontend.Screenshot }),
|
||||
request("ui.matches", { payload: Frontend.MatchesParams, success: Frontend.Matches }),
|
||||
request("ui.recording.finish", { success: Frontend.RecordingFinish }),
|
||||
request("ui.type", { payload: Frontend.TypeParams, success: Frontend.State }),
|
||||
|
||||
@@ -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 { Location } from "./location.js"
|
||||
@@ -31,7 +30,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,295 +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, Semaphore } 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 input = yield* Semaphore.make(1)
|
||||
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) =>
|
||||
input.withPermit(
|
||||
Effect.suspend(() => {
|
||||
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)),
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ServerProcess from "./process"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
@@ -171,11 +170,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)
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
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"
|
||||
@@ -59,8 +58,6 @@ const applicationServiceNodes = [
|
||||
SdkPlugins.node,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
Group.node,
|
||||
PersistentPty.node,
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
|
||||
@@ -1,400 +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")
|
||||
|
||||
for (const character of "printf abc | rev\n") second.socket.send(inputFrame(70, 20, character))
|
||||
await waitForSocketOutput([first, second], "cba")
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -18,11 +18,6 @@
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
declare module "*.woff2" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { tmpdir } from "node:os"
|
||||
import { extname, join, resolve } from "node:path"
|
||||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import {
|
||||
createMockKeys,
|
||||
@@ -9,7 +7,7 @@ import {
|
||||
type MockInput,
|
||||
type MockMouse,
|
||||
} from "@opentui/core/testing"
|
||||
import { Config, Effect, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SimulationProtocol } from "../protocol"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationSemantics } from "./semantics"
|
||||
@@ -167,26 +165,6 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness
|
||||
} satisfies SimulationProtocol.Frontend.CapturedFrame
|
||||
})
|
||||
|
||||
export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
|
||||
const filename = name ?? `screenshot-${crypto.randomUUID()}`
|
||||
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
|
||||
return yield* Effect.fail(new Error("screenshot name must not contain a path or extension"))
|
||||
yield* Effect.tryPromise(() => harness.renderOnce())
|
||||
const { SimulationPng } = yield* Effect.promise(() => import("./png"))
|
||||
const image = SimulationPng.screenshot(harness.renderer)
|
||||
const directory = resolve(
|
||||
yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe(
|
||||
Config.withDefault(join(tmpdir(), "opencode-drive", "output")),
|
||||
),
|
||||
)
|
||||
const path = join(directory, `${filename}.png`)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.$`mkdir -p ${directory}`.quiet()
|
||||
await Bun.write(path, image.data)
|
||||
})
|
||||
return path
|
||||
})
|
||||
|
||||
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
|
||||
switch (action.type) {
|
||||
case "ui.type":
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/// <reference path="../assets.d.ts" />
|
||||
import { GlobalFonts, createCanvas, type SKRSContext2D } from "@napi-rs/canvas"
|
||||
import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core"
|
||||
import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-normal.woff2" with { type: "file" }
|
||||
import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" }
|
||||
import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" }
|
||||
import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" }
|
||||
import symbolFont from "@fontsource/noto-sans-symbols/files/noto-sans-symbols-symbols-400-normal.woff2" with { type: "file" }
|
||||
import symbolFont2 from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-symbols-400-normal.woff2" with { type: "file" }
|
||||
import brailleFont from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-braille-400-normal.woff2" with { type: "file" }
|
||||
import mathFont from "@fontsource/noto-sans-math/files/noto-sans-math-math-400-normal.woff2" with { type: "file" }
|
||||
|
||||
const CellWidth = 10
|
||||
const CellHeight = 20
|
||||
const FontSize = 16
|
||||
const FontFamily = "OpenCode Mono"
|
||||
const SymbolFontFamily = "OpenCode Symbols"
|
||||
const SymbolFontFamily2 = "OpenCode Symbols 2"
|
||||
const MathFontFamily = "OpenCode Math"
|
||||
const FontStack = `"${FontFamily}", "${SymbolFontFamily}", "${SymbolFontFamily2}", "${MathFontFamily}"`
|
||||
|
||||
for (const [file, family] of [
|
||||
...[regularFont, boldFont, italicFont, boldItalicFont].map((file) => [file, FontFamily] as const),
|
||||
[symbolFont, SymbolFontFamily],
|
||||
[symbolFont2, SymbolFontFamily2],
|
||||
[brailleFont, SymbolFontFamily2],
|
||||
[mathFont, MathFontFamily],
|
||||
] as const) {
|
||||
const font = Buffer.from(await Bun.file(file).arrayBuffer())
|
||||
if (!GlobalFonts.register(font, family)) throw new Error(`Failed to register screenshot font: ${file}`)
|
||||
}
|
||||
|
||||
export function screenshot(renderer: CliRenderer) {
|
||||
return screenshotFrame({
|
||||
cols: renderer.currentRenderBuffer.width,
|
||||
rows: renderer.currentRenderBuffer.height,
|
||||
cursor: [0, 0],
|
||||
lines: renderer.currentRenderBuffer.getSpanLines(),
|
||||
})
|
||||
}
|
||||
|
||||
export function screenshotFrame(frame: CapturedFrame) {
|
||||
const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight)
|
||||
const context = canvas.getContext("2d")
|
||||
context.fillStyle = "#080808"
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
context.textBaseline = "top"
|
||||
|
||||
frame.lines.forEach((line, row) => {
|
||||
let column = 0
|
||||
line.spans.forEach((span) => {
|
||||
const attributes = span.attributes & 0xff
|
||||
const inverse = Boolean(attributes & TextAttributes.INVERSE)
|
||||
const hidden = Boolean(attributes & TextAttributes.HIDDEN)
|
||||
const foreground = inverse ? span.bg : span.fg
|
||||
const background = inverse ? span.fg : span.bg
|
||||
const chars = [...span.text]
|
||||
let remaining = span.width
|
||||
|
||||
chars.forEach((char, index) => {
|
||||
const cells = Math.max(1, remaining - (chars.length - index - 1))
|
||||
if (background.a) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight)
|
||||
}
|
||||
if (!hidden && char.codePointAt(0) !== 0x0a00) {
|
||||
context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1)
|
||||
const x = column * CellWidth
|
||||
const y = row * CellHeight
|
||||
if (!drawBlockElement(context, char, x, y, cells)) {
|
||||
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px ${FontStack}`
|
||||
context.fillText(char, x, y + 1)
|
||||
}
|
||||
if (attributes & TextAttributes.UNDERLINE) context.fillRect(x, y + 17, cells * CellWidth, 1)
|
||||
if (attributes & TextAttributes.STRIKETHROUGH) context.fillRect(x, y + 10, cells * CellWidth, 1)
|
||||
}
|
||||
column += cells
|
||||
remaining -= cells
|
||||
})
|
||||
while (remaining-- > 0) {
|
||||
if (background.a) {
|
||||
context.fillStyle = color(background)
|
||||
context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight)
|
||||
}
|
||||
column++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { width: canvas.width, height: canvas.height, data: canvas.toBuffer("image/png") }
|
||||
}
|
||||
|
||||
function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: number, cells: number) {
|
||||
const width = cells * CellWidth
|
||||
if (char === "█") context.fillRect(x, y, width, CellHeight)
|
||||
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
|
||||
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
|
||||
else if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight)
|
||||
else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight / 2)
|
||||
else return false
|
||||
return true
|
||||
}
|
||||
|
||||
function color(value: RGBA, opacity = 1) {
|
||||
const [red, green, blue, alpha] = value.toInts()
|
||||
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`
|
||||
}
|
||||
|
||||
export * as SimulationPng from "./png"
|
||||
@@ -17,8 +17,6 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request,
|
||||
)
|
||||
case "ui.capture":
|
||||
return SimulationActions.capture(harness)
|
||||
case "ui.screenshot":
|
||||
return SimulationActions.screenshot(harness, request.params?.name)
|
||||
case "ui.state":
|
||||
return Effect.sync(() => SimulationActions.state(harness))
|
||||
case "ui.snapshot":
|
||||
|
||||
+42
-64
@@ -31,6 +31,7 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
TuiLifecycleProvider,
|
||||
TuiAppProvider,
|
||||
@@ -76,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 +100,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()
|
||||
@@ -218,7 +218,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), endpoint }
|
||||
return { api: OpenCode.make(next) }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
@@ -373,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>
|
||||
@@ -619,11 +617,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}`)
|
||||
}
|
||||
@@ -1320,22 +1313,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,189 +0,0 @@
|
||||
import { CliRenderEvents, RGBA, TextAttributes, type BoxRenderable, type Renderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createResource, createSignal, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { usePaneLayout } from "../context/pane-layout"
|
||||
import type { PaneLayoutNode } from "../context/pane-layout-model"
|
||||
import { useData } from "../context/data"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
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 prompt = usePromptRef()
|
||||
const theme = useTheme()
|
||||
const data = useData()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.node.type === "item" ? props.node.item : undefined}>
|
||||
{(item) => {
|
||||
let focusTerminal: (() => void) | undefined
|
||||
const focus = () => {
|
||||
if (item().type === "session" && item().id === props.rootSessionID) {
|
||||
prompt.current?.focus()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
return (
|
||||
<PaneSurface
|
||||
focus={focus}
|
||||
title={
|
||||
item().type === "terminal"
|
||||
? "Terminal"
|
||||
: `Session · ${data.session.get(item().id)?.title ?? "Untitled"}`
|
||||
}
|
||||
>
|
||||
<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)}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</PaneSurface>
|
||||
)
|
||||
}}
|
||||
</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}
|
||||
>
|
||||
<PaneNode
|
||||
node={node().second}
|
||||
rootSessionID={props.rootSessionID}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function PaneSurface(props: { focus: () => void; title: string; children: JSX.Element }) {
|
||||
const renderer = useRenderer()
|
||||
const theme = useTheme()
|
||||
const [focused, setFocused] = createSignal(false)
|
||||
let pane: BoxRenderable | undefined
|
||||
const contains = (current: Renderable | null) => {
|
||||
while (current) {
|
||||
if (current === pane) return true
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
const onFocused = (current: Renderable | null) => setFocused(contains(current))
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
|
||||
return (
|
||||
<box
|
||||
ref={(value) => {
|
||||
pane = value
|
||||
setFocused(contains(renderer.currentFocusedRenderable))
|
||||
}}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
>
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
focused()
|
||||
? theme.raise(theme.raise(theme.background.surface.offset))
|
||||
: theme.background.surface.offset
|
||||
}
|
||||
>
|
||||
<text
|
||||
fg={focused() ? theme.text.default : theme.text.subdued}
|
||||
attributes={focused() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{props.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} position="relative">
|
||||
{props.children}
|
||||
</box>
|
||||
<Show when={!focused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={1}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0)}
|
||||
opacity={0.3}
|
||||
onMouseDown={props.focus}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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,370 +0,0 @@
|
||||
import { EmbeddedTerminalRenderable } from "@opentui/core"
|
||||
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
embeddedTerminal: typeof EmbeddedTerminalRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ embeddedTerminal: EmbeddedTerminalRenderable })
|
||||
|
||||
type TerminalSize = { cols: number; rows: number }
|
||||
type StreamItem =
|
||||
| { type: "output"; data: Uint8Array }
|
||||
| { type: "resize"; size: TerminalSize; checkpoint?: Uint8Array }
|
||||
| { type: "ready" }
|
||||
|
||||
export function PersistentTerminalPane(props: {
|
||||
ptyID: string
|
||||
autoFocus?: boolean
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const renderer = useRenderer()
|
||||
const [failure, setFailure] = createSignal<string>()
|
||||
const attachmentID = crypto.randomUUID()
|
||||
const stream: StreamItem[] = []
|
||||
const pendingInput: Uint8Array[] = []
|
||||
let terminal: EmbeddedTerminalRenderable | undefined
|
||||
let socket: WebSocket | undefined
|
||||
let attached = false
|
||||
let controller = false
|
||||
let restored = false
|
||||
let wantsControl = false
|
||||
let disposed = false
|
||||
let size: TerminalSize | undefined
|
||||
let canonicalSize: TerminalSize | undefined
|
||||
let terminalSize: TerminalSize | undefined
|
||||
let lastIntermediateRender = 0
|
||||
let terminalTheme: Uint8Array | undefined
|
||||
let waitingSize: { size: TerminalSize; resolve: () => void } | undefined
|
||||
|
||||
const setCanonicalSize = (value: TerminalSize) => {
|
||||
canonicalSize = value
|
||||
if (!terminal) return
|
||||
terminal.width = value.cols
|
||||
terminal.height = value.rows
|
||||
}
|
||||
|
||||
const applyTerminalTheme = () => {
|
||||
if (terminalTheme) terminal?.write(terminalTheme)
|
||||
}
|
||||
|
||||
const send = (data: Uint8Array) => {
|
||||
if (attached && socket?.readyState === WebSocket.OPEN) socket.send(data)
|
||||
}
|
||||
|
||||
const interact = () => {
|
||||
if (!restored) {
|
||||
wantsControl = true
|
||||
return
|
||||
}
|
||||
if (!size) return
|
||||
send(interactionFrame(size))
|
||||
}
|
||||
|
||||
const sendInput = (data: Uint8Array) => {
|
||||
if (!restored) {
|
||||
pendingInput.push(data)
|
||||
return
|
||||
}
|
||||
if (size) send(interactionFrame(size, data))
|
||||
}
|
||||
|
||||
const processStream = () => {
|
||||
if (disposed || !terminal || !sameSize(canonicalSize, terminalSize)) return
|
||||
while (stream.length > 0) {
|
||||
const item = stream[0]!
|
||||
if (item.type === "output") {
|
||||
stream.shift()
|
||||
const output = [item.data]
|
||||
while (true) {
|
||||
const next = stream[0]
|
||||
if (!next || next.type !== "output") break
|
||||
output.push(next.data)
|
||||
stream.shift()
|
||||
}
|
||||
terminal.write(output.length === 1 ? output[0] : Buffer.concat(output))
|
||||
continue
|
||||
}
|
||||
if (item.type === "resize") {
|
||||
setCanonicalSize(item.size)
|
||||
if (!sameSize(canonicalSize, terminalSize)) return
|
||||
stream.shift()
|
||||
if (item.checkpoint) {
|
||||
terminal.write(Buffer.concat([Buffer.from("\x1bc"), Buffer.from(item.checkpoint)]))
|
||||
applyTerminalTheme()
|
||||
}
|
||||
continue
|
||||
}
|
||||
stream.shift()
|
||||
restored = true
|
||||
const input = pendingInput.splice(0)
|
||||
if (input.length > 0) input.forEach(sendInput)
|
||||
if (input.length === 0 && (controller || wantsControl)) interact()
|
||||
wantsControl = false
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = (item: StreamItem) => {
|
||||
stream.push(item)
|
||||
processStream()
|
||||
}
|
||||
|
||||
const waitForTerminalSize = (value: TerminalSize) => {
|
||||
if (sameSize(value, terminalSize)) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
waitingSize = { size: value, resolve }
|
||||
})
|
||||
}
|
||||
|
||||
const offKeys = keymap.intercept(
|
||||
"key",
|
||||
({ event }) => {
|
||||
if (!terminal?.focused) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
terminal.handleKeyPress(event)
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
terminal.focus()
|
||||
props.onAutoFocus?.()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
terminalTheme = terminalPalette(themes.currentTokens(), themes.mode())
|
||||
applyTerminalTheme()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void connect().catch((error) => setFailure(errorMessage(error)))
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
waitingSize?.resolve()
|
||||
socket?.close()
|
||||
offKeys()
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
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"))
|
||||
applyTerminalTheme()
|
||||
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"
|
||||
backgroundColor={theme.background.default}
|
||||
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
|
||||
props.onFocusRequest?.(() => {
|
||||
value.focus()
|
||||
interact()
|
||||
})
|
||||
terminalSize = { cols: 80, rows: 24 }
|
||||
if (canonicalSize) {
|
||||
value.width = canonicalSize.cols
|
||||
value.height = canonicalSize.rows
|
||||
}
|
||||
applyTerminalTheme()
|
||||
}}
|
||||
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 terminalPalette(theme: ResolvedThemeTokens, mode: "dark" | "light") {
|
||||
const base = mode === "dark" ? 500 : 700
|
||||
const bright = mode === "dark" ? 300 : 500
|
||||
const colors = [
|
||||
theme.background.default,
|
||||
theme.text.feedback.error.default,
|
||||
theme.text.feedback.success.default,
|
||||
theme.text.feedback.warning.default,
|
||||
theme.hue.blue[base],
|
||||
theme.hue.purple[base],
|
||||
theme.text.feedback.info.default,
|
||||
theme.text.default,
|
||||
theme.text.subdued,
|
||||
theme.text.feedback.error.subdued,
|
||||
theme.text.feedback.success.subdued,
|
||||
theme.text.feedback.warning.subdued,
|
||||
theme.hue.blue[bright],
|
||||
theme.hue.purple[bright],
|
||||
theme.hue.cyan[bright],
|
||||
theme.hue.neutral[mode === "dark" ? 100 : 900],
|
||||
]
|
||||
return Buffer.from(
|
||||
colors
|
||||
.map((color, index) => `\x1b]4;${index};${hex(color)}\x1b\\`)
|
||||
.concat(
|
||||
`\x1b]10;${hex(theme.text.default)}\x1b\\`,
|
||||
`\x1b]11;${hex(theme.background.default)}\x1b\\`,
|
||||
)
|
||||
.join(""),
|
||||
)
|
||||
}
|
||||
|
||||
function hex(color: RGBA) {
|
||||
return `#${color
|
||||
.toInts()
|
||||
.slice(0, 3)
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")}`
|
||||
}
|
||||
|
||||
function interactionFrame(size: { cols: number; rows: number }, data?: Uint8Array) {
|
||||
const frame = new Uint8Array(5 + (data?.byteLength ?? 0))
|
||||
const view = new DataView(frame.buffer)
|
||||
frame[0] = data ? 1 : 0
|
||||
view.setUint16(1, size.cols)
|
||||
view.setUint16(3, size.rows)
|
||||
if (data) frame.set(data, 5)
|
||||
return frame
|
||||
}
|
||||
@@ -52,7 +52,8 @@ const RIGHT_MOUSE_BUTTON = 2
|
||||
type TabContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
tab?: SessionTab
|
||||
sessionID?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
@@ -189,20 +190,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) },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
@@ -425,7 +422,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
|
||||
@@ -434,7 +431,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()
|
||||
@@ -453,7 +450,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) ?? ""
|
||||
@@ -587,9 +583,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
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()
|
||||
@@ -1050,7 +1047,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)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
@@ -1132,9 +1129,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
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()
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import type { Endpoint } from "@opencode-ai/client/service"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient; endpoint?: Endpoint }>
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -15,19 +14,16 @@ type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent,
|
||||
|
||||
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 service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let api = props.api
|
||||
let endpoint = props.endpoint
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
reconnect: service
|
||||
? async (signal) => {
|
||||
const next = await service.reconnect(signal)
|
||||
api = next.api
|
||||
if (next.endpoint) endpoint = next.endpoint
|
||||
api = (await service.reconnect(signal)).api
|
||||
return api
|
||||
}
|
||||
: undefined,
|
||||
@@ -45,9 +41,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, options?: { focus?: boolean }): 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 })
|
||||
if (options?.focus !== false) 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"
|
||||
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
type TabsState = {
|
||||
tabs: SessionTab[]
|
||||
unread: Record<string, SessionTabUnread>
|
||||
// TEMPORARY: Persist the selected tab only to restore the pane styling reproduction after Drive restarts.
|
||||
active?: string
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
@@ -76,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>()
|
||||
@@ -108,26 +105,18 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const session = data.session.get(sessionID)
|
||||
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
|
||||
}
|
||||
const normalize = (value: TabsState) => {
|
||||
const tabs = value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
if (tab.groupID) return openSessionTab(tabs, { ...tab, sessionID: tab.groupID })
|
||||
const normalize = (value: TabsState) => ({
|
||||
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, [])
|
||||
const unread = Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
}, []),
|
||||
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {})
|
||||
const selected = value.active ? value.tabs.find((tab) => tab.sessionID === value.active) : undefined
|
||||
const active = selected ? (selected.groupID ? selected.sessionID : root(selected.sessionID)) : undefined
|
||||
return { tabs, unread, ...(active ? { active } : {}) }
|
||||
}
|
||||
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 +124,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]
|
||||
@@ -151,17 +137,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}
|
||||
}
|
||||
|
||||
// TEMPORARY: Remove this startup redirect when the pane styling reproduction no longer needs fixed state.
|
||||
let restored = false
|
||||
createEffect(() => {
|
||||
if (restored || !enabled()) return
|
||||
restored = true
|
||||
if (route.data.type !== "home") return
|
||||
const active = state().active ?? state().tabs.at(-1)?.sessionID
|
||||
if (!active || !state().tabs.some((tab) => tab.sessionID === active)) return
|
||||
navigate(active)
|
||||
})
|
||||
|
||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||
if (!enabled() || !focused()) return
|
||||
const session = root(sessionID)
|
||||
@@ -175,14 +150,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (route.data.type === "workspace") {
|
||||
history = recordSessionTabHistory(history, route.data.groupID)
|
||||
if (state().active !== route.data.groupID)
|
||||
update((draft) => {
|
||||
draft.active = route.data.groupID
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
history = recordSessionTabHistory(history, sessionID)
|
||||
@@ -191,13 +158,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
sessionID,
|
||||
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
if (tabs === state().tabs && state().active === sessionID) return
|
||||
if (tabs === state().tabs) return
|
||||
update((draft) => {
|
||||
draft.tabs = openSessionTab(draft.tabs, {
|
||||
sessionID,
|
||||
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
draft.active = sessionID
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,7 +185,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
draft.unread = next.unread
|
||||
draft.active = next.tabs.some((tab) => tab.sessionID === next.active) ? next.active : undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -228,8 +193,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"),
|
||||
)
|
||||
@@ -257,8 +221,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
|
||||
@@ -296,48 +259,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)
|
||||
@@ -354,25 +285,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 {
|
||||
@@ -402,25 +315,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()
|
||||
@@ -437,21 +333,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() {
|
||||
@@ -467,7 +359,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)
|
||||
@@ -476,19 +368,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 })
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,24 +2,15 @@ import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { usePaneLayout } from "../../context/pane-layout"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
export function PromptFooter(props: {
|
||||
context: Plugin.Context
|
||||
sessionID?: string
|
||||
mode: "normal" | "shell"
|
||||
onNewTerminal?: () => Promise<void>
|
||||
}) {
|
||||
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [liveHovered, setLiveHovered] = createSignal(false)
|
||||
const [terminalHovered, setTerminalHovered] = createSignal(false)
|
||||
const [terminalPending, setTerminalPending] = createSignal(false)
|
||||
const subagents = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
const count = props.context.data.session
|
||||
@@ -51,71 +42,51 @@ 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"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={live() || status().length > 0}>
|
||||
<box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||
<Show when={live()}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
onMouseOver={() => setLiveHovered(true)}
|
||||
onMouseOut={() => setLiveHovered(false)}
|
||||
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
|
||||
<Switch>
|
||||
<Match when={live() || status().length > 0}>
|
||||
<box flexDirection="row" flexShrink={1} minWidth={0}>
|
||||
<Show when={live()}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
onMouseOver={() => setLiveHovered(true)}
|
||||
onMouseOut={() => setLiveHovered(false)}
|
||||
onMouseUp={() => props.context.keymap.dispatch("session.child.first")}
|
||||
>
|
||||
<text
|
||||
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
<text
|
||||
fg={liveHovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
|
||||
wrapMode="none"
|
||||
>
|
||||
<Show when={shortcut("session.child.first")}>
|
||||
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
|
||||
</Show>
|
||||
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
|
||||
<Show when={subagents() && shells()}> · </Show>
|
||||
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={status().length > 0}>
|
||||
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={live()}> · </Show>
|
||||
{status().join(" · ")}
|
||||
<Show when={shortcut("session.child.first")}>
|
||||
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
|
||||
</Show>
|
||||
<Show when={subagents()}>{(value) => <>{value()}</>}</Show>
|
||||
<Show when={subagents() && shells()}> · </Show>
|
||||
<Show when={shells()}>{(value) => <>{value()}</>}</Show>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</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}>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={status().length > 0}>
|
||||
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<Show when={live()}> · </Show>
|
||||
{status().join(" · ")}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</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"}>
|
||||
@@ -130,31 +101,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} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -108,7 +108,6 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { usePaneLayout } from "../../context/pane-layout"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -281,7 +280,6 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panes = usePaneLayout()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
createEffect(() => {
|
||||
@@ -848,16 +846,6 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New terminal",
|
||||
id: "session.terminal",
|
||||
group: "Session",
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await panes.newTerminal(route.sessionID, { focus: false }).catch(toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
|
||||
@@ -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"
|
||||
@@ -33,12 +33,9 @@ async function renderSessionTabs(
|
||||
title?: string
|
||||
home?: boolean
|
||||
persisted?: string[]
|
||||
active?: string
|
||||
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()
|
||||
@@ -50,13 +47,7 @@ async function renderSessionTabs(
|
||||
file,
|
||||
JSON.stringify({
|
||||
global: { tabs: [], unread: {} },
|
||||
cwd: {
|
||||
[directory]: {
|
||||
tabs: options.persisted.map((sessionID) => ({ sessionID })),
|
||||
unread: {},
|
||||
active: options.active,
|
||||
},
|
||||
},
|
||||
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -64,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)
|
||||
@@ -83,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)
|
||||
@@ -164,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"),
|
||||
@@ -207,35 +181,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("restores the last active tab from home", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
active: "second",
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.route.data.type === "session" && setup.route.data.sessionID === "second")
|
||||
expect(setup.route.data).toEqual({ type: "session", sessionID: "second" })
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("restores the last persisted tab when active state is missing", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.route.data.type === "session" && setup.route.data.sessionID === "second")
|
||||
expect(setup.route.data).toEqual({ type: "session", sessionID: "second" })
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads VCS metadata for each persisted tab location", async () => {
|
||||
const other = `${directory}/other-worktree`
|
||||
const setup = await renderSessionTabs("first", {
|
||||
@@ -289,7 +234,6 @@ test("stores session tabs for the current working directory by default", async (
|
||||
expect(Object.keys(stored.cwd)).toEqual([directory])
|
||||
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
|
||||
expect(stored.cwd[directory].unread).toEqual({})
|
||||
expect(stored.cwd[directory].active).toBe("first")
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -509,57 +453,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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,28 +59,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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -161,7 +161,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 = () => {}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# TEMPORARY: Recreates the fixed split-pane UI used for styling. Remove after that work is complete.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
name="${1:-pane-style2}"
|
||||
|
||||
send() {
|
||||
opencode-drive send --name "$name" "$@"
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
local text="$1"
|
||||
for _ in {1..100}; do
|
||||
if [[ "$(send --command.ui.matches "{\"text\":\"$text\"}")" == "true" ]]; then
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo "error: timed out waiting for $text" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
send --command.ui.resize '{"cols":166,"rows":42}'
|
||||
send --command.ui.type '{"text":"Create a styling session"}' --command.ui.enter
|
||||
wait_for "shift+tab agents"
|
||||
|
||||
# Hide the session sidebar with the default <leader>b binding.
|
||||
send \
|
||||
--command.ui.press '{"key":"x","modifiers":{"ctrl":true}}' \
|
||||
--command.ui.press '{"key":"b"}'
|
||||
|
||||
# Add two terminal panes through the session slash command.
|
||||
send --command.ui.type '{"text":"/terminal"}' --command.ui.enter
|
||||
send --command.ui.type '{"text":"/terminal"}' --command.ui.enter
|
||||
|
||||
for _ in {1..100}; do
|
||||
state="$(send --command.ui.state)"
|
||||
if [[ "$(grep -c 'embeddedTerminal-' <<<"$state")" -ge 2 ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$(grep -c 'embeddedTerminal-' <<<"$state")" -lt 2 ]]; then
|
||||
echo "error: two terminal panes did not open" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Focus the newest right-hand pane and send keys separately so the visual interaction is explicit.
|
||||
send --command.ui.click '{"target":2,"x":145,"y":20}'
|
||||
send --command.ui.press '{"key":"l"}'
|
||||
send --command.ui.press '{"key":"s"}'
|
||||
send --command.ui.enter
|
||||
|
||||
echo "pane styling workspace ready in $name"
|
||||
Reference in New Issue
Block a user