From 5dac8bf23937ba907472c81cd59c2e303fcf4b7e Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:26:08 -0600
Subject: [PATCH 01/10] wip: pty
---
bun.lock | 13 +-
package.json | 2 +-
packages/desktop/package.json | 2 +
packages/desktop/src/components/terminal.tsx | 96 +++++++
packages/desktop/src/context/sdk.tsx | 2 +-
packages/desktop/src/context/session.tsx | 2 +-
packages/desktop/src/pages/session.tsx | 26 ++
packages/opencode/package.json | 1 +
packages/opencode/src/id/id.ts | 1 +
packages/opencode/src/pty/index.ts | 195 +++++++++++++++
packages/opencode/src/server/error.ts | 36 +++
packages/opencode/src/server/server.ts | 197 ++++++++++++---
packages/sdk/js/src/gen/sdk.gen.ts | 88 +++++++
packages/sdk/js/src/gen/types.gen.ts | 248 +++++++++++++++++--
packages/util/src/shell.ts | 13 +
15 files changed, 869 insertions(+), 53 deletions(-)
create mode 100644 packages/desktop/src/components/terminal.tsx
create mode 100644 packages/opencode/src/pty/index.ts
create mode 100644 packages/opencode/src/server/error.ts
create mode 100644 packages/util/src/shell.ts
diff --git a/bun.lock b/bun.lock
index 714384ceb172..cefd9154362c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -135,11 +135,13 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
+ "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
+ "ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
@@ -246,6 +248,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+ "bun-pty": "0.4.2",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -457,7 +460,7 @@
"ai": "5.0.97",
"diff": "8.0.2",
"fuzzysort": "3.1.0",
- "hono": "4.7.10",
+ "hono": "4.10.7",
"hono-openapi": "1.1.1",
"luxon": "3.6.1",
"remeda": "2.26.0",
@@ -1506,6 +1509,8 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
+ "@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="],
+
"@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="],
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
@@ -1890,6 +1895,8 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
+ "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
+
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -2334,6 +2341,8 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
+ "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
+
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="],
@@ -2428,7 +2437,7 @@
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
- "hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
+ "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
diff --git a/package.json b/package.json
index a5e7c14621b3..a962be926058 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
- "hono": "4.7.10",
+ "hono": "4.10.7",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 4b797f62af64..a6eb4dd3db2b 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -33,11 +33,13 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
+ "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
+ "ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
new file mode 100644
index 000000000000..3f6977041d53
--- /dev/null
+++ b/packages/desktop/src/components/terminal.tsx
@@ -0,0 +1,96 @@
+import { init, Terminal as Term, FitAddon } from "ghostty-web"
+import { ComponentProps, onMount, splitProps } from "solid-js"
+import { createReconnectingWS } from "@solid-primitives/websocket"
+import { useSDK } from "@/context/sdk"
+
+await init()
+
+export interface TerminalProps extends ComponentProps<"div"> {
+ id: string
+}
+
+export const Terminal = (props: TerminalProps) => {
+ const sdk = useSDK()
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["id", "class", "classList"])
+
+ onMount(async () => {
+ const ws = createReconnectingWS(sdk.url + `/pty/${local.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ const term = new Term({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: "TX-02, monospace",
+ allowTransparency: true,
+ theme: {
+ background: "#191515",
+ foreground: "#d4d4d4",
+ },
+ scrollback: 10_000,
+ })
+
+ const fitAddon = new FitAddon()
+ term.loadAddon(fitAddon)
+ term.open(container)
+
+ container.focus()
+
+ fitAddon.fit()
+ fitAddon.observeResize()
+ window.addEventListener("resize", () => fitAddon.fit())
+ term.onResize(async (size) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ await sdk.client.pty.update({
+ path: { id: local.id },
+ body: {
+ size: {
+ cols: size.cols,
+ rows: size.rows,
+ },
+ },
+ })
+ }
+ })
+ term.onData((data) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(data)
+ }
+ })
+ // term.onScroll((ydisp) => {
+ // console.log("Scroll position:", ydisp)
+ // })
+ ws.addEventListener("open", () => {
+ console.log("WebSocket connected")
+ sdk.client.pty.update({
+ path: { id: local.id },
+ body: {
+ size: {
+ cols: term.cols,
+ rows: term.rows,
+ },
+ },
+ })
+ })
+ ws.addEventListener("message", (event) => {
+ term.write(event.data)
+ })
+ ws.addEventListener("error", (error) => {
+ console.error("WebSocket error:", error)
+ })
+ ws.addEventListener("close", () => {
+ console.log("WebSocket disconnected")
+ })
+ })
+
+ return (
+
+ )
+}
diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx
index 81b32035a0b1..144202ee2096 100644
--- a/packages/desktop/src/context/sdk.tsx
+++ b/packages/desktop/src/context/sdk.tsx
@@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
abort.abort()
})
- return { directory: props.directory, client: sdk, event: emitter }
+ return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
})
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 72098a939512..8efb354e11d8 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -145,7 +145,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", undefined)
return
}
- if (tab !== "review") {
+ if (tab !== "review" && tab !== "terminal") {
if (!store.tabs.opened.includes(tab)) {
setStore("tabs", "opened", [...store.tabs.opened, tab])
}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d6ce62b70309..18a385e39ec3 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -31,16 +31,20 @@ import { useSession } from "@/context/session"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Diff } from "@opencode-ai/ui/diff"
+import { Terminal } from "@/components/terminal"
+import { useSDK } from "@/context/sdk"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
const session = useSession()
+ const sdk = useSDK()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
fileSelectOpen: false,
activeDraggable: undefined as string | undefined,
+ ptyId: undefined as string | undefined,
})
let inputRef!: HTMLDivElement
@@ -48,6 +52,8 @@ export default function Page() {
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
+
+ sdk.client.pty.create().then((pty) => setStore("ptyId", pty.data?.id))
})
onCleanup(() => {
@@ -74,6 +80,12 @@ export default function Page() {
return
}
+ // check if active element has `data-component="terminal"`
+ // @ts-expect-error
+ if (document.activeElement?.dataset?.component === "terminal") {
+ return
+ }
+
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") {
@@ -286,6 +298,9 @@ export default function Page() {
+
+ Terminal
+
+
+
+
+
+
+
+
+
+ export const CreateInput = z.object({
+ command: z.string().optional(),
+ args: z.array(z.string()).optional(),
+ cwd: z.string().optional(),
+ title: z.string().optional(),
+ env: z.record(z.string(), z.string()).optional(),
+ })
+
+ export type CreateInput = z.infer
+
+ export const UpdateInput = z.object({
+ title: z.string().optional(),
+ size: z
+ .object({
+ rows: z.number(),
+ cols: z.number(),
+ })
+ .optional(),
+ })
+
+ export type UpdateInput = z.infer
+
+ export const Event = {
+ Created: Bus.event("pty.created", z.object({ info: Info })),
+ Updated: Bus.event("pty.updated", z.object({ info: Info })),
+ Exited: Bus.event("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })),
+ Deleted: Bus.event("pty.deleted", z.object({ id: Identifier.schema("pty") })),
+ }
+
+ interface ActiveSession {
+ info: Info
+ process: IPty
+ history: string
+ subscribers: Set
+ }
+
+ const state = Instance.state(
+ () => new Map(),
+ async (sessions) => {
+ for (const session of sessions.values()) {
+ try {
+ session.process.kill()
+ } catch {}
+ for (const ws of session.subscribers) {
+ ws.close()
+ }
+ }
+ sessions.clear()
+ },
+ )
+
+ export function list() {
+ return Array.from(state().values()).map((s) => s.info)
+ }
+
+ export function get(id: string) {
+ return state().get(id)?.info
+ }
+
+ export async function create(input: CreateInput) {
+ const id = Identifier.create("pty", false)
+ const command = input.command || shell()
+ const args = input.args || []
+ const cwd = input.cwd || Instance.directory
+ const env = { ...process.env, ...input.env } as Record
+ log.info("creating session", { id, cmd: command, args, cwd })
+
+ const ptyProcess = spawn(command, args, {
+ name: "xterm-256color",
+ cwd,
+ env,
+ })
+ const info = {
+ id,
+ title: input.title || `Terminal ${id.slice(-4)}`,
+ command,
+ args,
+ cwd,
+ status: "running",
+ pid: ptyProcess.pid,
+ } as const
+ const session: ActiveSession = {
+ info,
+ process: ptyProcess,
+ history: "",
+ subscribers: new Set(),
+ }
+ state().set(id, session)
+ ptyProcess.onData((data) => {
+ session.history += data
+ for (const ws of session.subscribers) {
+ if (ws.readyState === 1) {
+ ws.send(data)
+ }
+ }
+ })
+ ptyProcess.onExit(({ exitCode }) => {
+ log.info("session exited", { id, exitCode })
+ session.info.status = "exited"
+ Bus.publish(Event.Exited, { id, exitCode })
+ state().delete(id)
+ })
+ Bus.publish(Event.Created, { info })
+ return info
+ }
+
+ export async function update(id: string, input: UpdateInput) {
+ const session = state().get(id)
+ if (!session) return
+ if (input.title) {
+ session.info.title = input.title
+ }
+ if (input.size) {
+ session.process.resize(input.size.cols, input.size.rows)
+ }
+ Bus.publish(Event.Updated, { info: session.info })
+ return session.info
+ }
+
+ export async function remove(id: string) {
+ const session = state().get(id)
+ if (!session) return
+ log.info("removing session", { id })
+ try {
+ session.process.kill()
+ } catch {}
+ for (const ws of session.subscribers) {
+ ws.close()
+ }
+ state().delete(id)
+ Bus.publish(Event.Deleted, { id })
+ }
+
+ export function resize(id: string, cols: number, rows: number) {
+ const session = state().get(id)
+ if (session && session.info.status === "running") {
+ session.process.resize(cols, rows)
+ }
+ }
+
+ export function write(id: string, data: string) {
+ const session = state().get(id)
+ if (session && session.info.status === "running") {
+ session.process.write(data)
+ }
+ }
+
+ export function connect(id: string, ws: WSContext) {
+ const session = state().get(id)
+ if (!session) {
+ ws.close()
+ return
+ }
+ log.info("client connected to session", { id })
+ session.subscribers.add(ws)
+ if (session.history) {
+ ws.send(session.history)
+ }
+ return {
+ onMessage: (message: string | ArrayBuffer) => {
+ session.process.write(String(message))
+ },
+ onClose: () => {
+ log.info("client disconnected from session", { id })
+ session.subscribers.delete(ws)
+ },
+ }
+ }
+}
diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts
new file mode 100644
index 000000000000..26e2dfcb1217
--- /dev/null
+++ b/packages/opencode/src/server/error.ts
@@ -0,0 +1,36 @@
+import { resolver } from "hono-openapi"
+import z from "zod"
+import { Storage } from "../storage/storage"
+
+export const ERRORS = {
+ 400: {
+ description: "Bad request",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .object({
+ data: z.any(),
+ errors: z.array(z.record(z.string(), z.any())),
+ success: z.literal(false),
+ })
+ .meta({
+ ref: "BadRequestError",
+ }),
+ ),
+ },
+ },
+ },
+ 404: {
+ description: "Not found",
+ content: {
+ "application/json": {
+ schema: resolver(Storage.NotFoundError.Schema),
+ },
+ },
+ },
+} as const
+
+export function errors(...codes: number[]) {
+ return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 31d0822762b6..a74b7876f1c6 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -43,43 +43,13 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status"
+import { upgradeWebSocket, websocket } from "hono/bun"
+import { errors } from "./error"
+import { Pty } from "@/pty"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
-const ERRORS = {
- 400: {
- description: "Bad request",
- content: {
- "application/json": {
- schema: resolver(
- z
- .object({
- data: z.any(),
- errors: z.array(z.record(z.string(), z.any())),
- success: z.literal(false),
- })
- .meta({
- ref: "BadRequestError",
- }),
- ),
- },
- },
- },
- 404: {
- description: "Not found",
- content: {
- "application/json": {
- schema: resolver(Storage.NotFoundError.Schema),
- },
- },
- },
-} as const
-
-function errors(...codes: number[]) {
- return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
-}
-
export namespace Server {
const log = Log.create({ service: "server" })
@@ -192,7 +162,167 @@ export namespace Server {
}),
)
.use(validator("query", z.object({ directory: z.string().optional() })))
+
.route("/project", ProjectRoute)
+
+ .get(
+ "/pty",
+ describeRoute({
+ description: "List all PTY sessions",
+ operationId: "pty.list",
+ responses: {
+ 200: {
+ description: "List of sessions",
+ content: {
+ "application/json": {
+ schema: resolver(Pty.Info.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(Pty.list())
+ },
+ )
+ .post(
+ "/pty",
+ describeRoute({
+ description: "Create a new PTY session",
+ operationId: "pty.create",
+ responses: {
+ 200: {
+ description: "Created session",
+ content: {
+ "application/json": {
+ schema: resolver(Pty.Info),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", Pty.CreateInput),
+ async (c) => {
+ const info = await Pty.create(c.req.valid("json"))
+ return c.json(info)
+ },
+ )
+ .put(
+ "/pty/:id",
+ describeRoute({
+ description: "Update PTY session",
+ operationId: "pty.update",
+ responses: {
+ 200: {
+ description: "Updated session",
+ content: {
+ "application/json": {
+ schema: resolver(Pty.Info),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("param", z.object({ id: z.string() })),
+ validator("json", Pty.UpdateInput),
+ async (c) => {
+ const info = await Pty.update(c.req.valid("param").id, c.req.valid("json"))
+ return c.json(info)
+ },
+ )
+ .get(
+ "/pty/:id",
+ describeRoute({
+ description: "Get PTY session info",
+ operationId: "pty.get",
+ responses: {
+ 200: {
+ description: "Session info",
+ content: {
+ "application/json": {
+ schema: resolver(Pty.Info),
+ },
+ },
+ },
+ ...errors(404),
+ },
+ }),
+ validator("param", z.object({ id: z.string() })),
+ async (c) => {
+ const info = Pty.get(c.req.valid("param").id)
+ if (!info) {
+ throw new Storage.NotFoundError({ message: "Session not found" })
+ }
+ return c.json(info)
+ },
+ )
+ .delete(
+ "/pty/:id",
+ describeRoute({
+ description: "Remove a PTY session",
+ operationId: "pty.remove",
+ responses: {
+ 200: {
+ description: "Session removed",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(404),
+ },
+ }),
+ validator("param", z.object({ id: z.string() })),
+ async (c) => {
+ await Pty.remove(c.req.valid("param").id)
+ return c.json(true)
+ },
+ )
+ .get(
+ "/pty/:id/connect",
+ describeRoute({
+ description: "Connect to a PTY session",
+ operationId: "pty.connect",
+ responses: {
+ 200: {
+ description: "Connected session",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ 404: {
+ description: "Session not found",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ },
+ }),
+ validator("param", z.object({ id: z.string() })),
+ upgradeWebSocket((c) => {
+ const id = c.req.param("id")
+ let handler: ReturnType
+ return {
+ onOpen(_event, ws) {
+ handler = Pty.connect(id, ws)
+ },
+ onMessage(event) {
+ handler?.onMessage(String(event.data))
+ },
+ onClose() {
+ handler?.onClose()
+ },
+ }
+ }),
+ )
+
.get(
"/config",
describeRoute({
@@ -2083,6 +2213,7 @@ export namespace Server {
hostname: opts.hostname,
idleTimeout: 0,
fetch: App().fetch,
+ websocket: websocket,
})
return server
}
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index 0dc470566eea..d04277cbc819 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -8,6 +8,23 @@ import type {
ProjectListResponses,
ProjectCurrentData,
ProjectCurrentResponses,
+ PtyListData,
+ PtyListResponses,
+ PtyCreateData,
+ PtyCreateResponses,
+ PtyCreateErrors,
+ PtyRemoveData,
+ PtyRemoveResponses,
+ PtyRemoveErrors,
+ PtyGetData,
+ PtyGetResponses,
+ PtyGetErrors,
+ PtyUpdateData,
+ PtyUpdateResponses,
+ PtyUpdateErrors,
+ PtyConnectData,
+ PtyConnectResponses,
+ PtyConnectErrors,
ConfigGetData,
ConfigGetResponses,
ConfigUpdateData,
@@ -231,6 +248,76 @@ class Project extends _HeyApiClient {
}
}
+class Pty extends _HeyApiClient {
+ /**
+ * List all PTY sessions
+ */
+ public list(options?: Options) {
+ return (options?.client ?? this._client).get({
+ url: "/pty",
+ ...options,
+ })
+ }
+
+ /**
+ * Create a new PTY session
+ */
+ public create(options?: Options) {
+ return (options?.client ?? this._client).post({
+ url: "/pty",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ },
+ })
+ }
+
+ /**
+ * Remove a PTY session
+ */
+ public remove(options: Options) {
+ return (options.client ?? this._client).delete({
+ url: "/pty/{id}",
+ ...options,
+ })
+ }
+
+ /**
+ * Get PTY session info
+ */
+ public get(options: Options) {
+ return (options.client ?? this._client).get({
+ url: "/pty/{id}",
+ ...options,
+ })
+ }
+
+ /**
+ * Update PTY session
+ */
+ public update(options: Options) {
+ return (options.client ?? this._client).put({
+ url: "/pty/{id}",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ })
+ }
+
+ /**
+ * Connect to a PTY session
+ */
+ public connect(options: Options) {
+ return (options.client ?? this._client).get({
+ url: "/pty/{id}/connect",
+ ...options,
+ })
+ }
+}
+
class Config extends _HeyApiClient {
/**
* Get config info
@@ -1005,6 +1092,7 @@ export class OpencodeClient extends _HeyApiClient {
}
global = new Global({ client: this._client })
project = new Project({ client: this._client })
+ pty = new Pty({ client: this._client })
config = new Config({ client: this._client })
tool = new Tool({ client: this._client })
instance = new Instance({ client: this._client })
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 6c80f0b7c52f..58ba58d359cd 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -655,6 +655,45 @@ export type EventTuiToastShow = {
}
}
+export type Pty = {
+ id: string
+ title: string
+ command: string
+ args: Array
+ cwd: string
+ status: "running" | "exited"
+ pid: number
+}
+
+export type EventPtyCreated = {
+ type: "pty.created"
+ properties: {
+ info: Pty
+ }
+}
+
+export type EventPtyUpdated = {
+ type: "pty.updated"
+ properties: {
+ info: Pty
+ }
+}
+
+export type EventPtyExited = {
+ type: "pty.exited"
+ properties: {
+ id: string
+ exitCode: number
+ }
+}
+
+export type EventPtyDeleted = {
+ type: "pty.deleted"
+ properties: {
+ id: string
+ }
+}
+
export type EventServerConnected = {
type: "server.connected"
properties: {
@@ -690,6 +729,10 @@ export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
+ | EventPtyCreated
+ | EventPtyUpdated
+ | EventPtyExited
+ | EventPtyDeleted
| EventServerConnected
export type GlobalEvent = {
@@ -708,6 +751,21 @@ export type Project = {
}
}
+export type BadRequestError = {
+ data: unknown
+ errors: Array<{
+ [key: string]: unknown
+ }>
+ success: false
+}
+
+export type NotFoundError = {
+ name: "NotFoundError"
+ data: {
+ message: string
+ }
+}
+
/**
* Custom keybind configurations
*/
@@ -1266,14 +1324,6 @@ export type Config = {
}
}
-export type BadRequestError = {
- data: unknown
- errors: Array<{
- [key: string]: unknown
- }>
- success: false
-}
-
export type ToolIds = Array
export type ToolListItem = {
@@ -1295,13 +1345,6 @@ export type VcsInfo = {
branch: string
}
-export type NotFoundError = {
- name: "NotFoundError"
- data: {
- message: string
- }
-}
-
export type TextPartInput = {
id?: string
type: "text"
@@ -1614,6 +1657,181 @@ export type ProjectCurrentResponses = {
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]
+export type PtyListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/pty"
+}
+
+export type PtyListResponses = {
+ /**
+ * List of sessions
+ */
+ 200: Array
+}
+
+export type PtyListResponse = PtyListResponses[keyof PtyListResponses]
+
+export type PtyCreateData = {
+ body?: {
+ command?: string
+ args?: Array
+ cwd?: string
+ title?: string
+ env?: {
+ [key: string]: string
+ }
+ }
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/pty"
+}
+
+export type PtyCreateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors]
+
+export type PtyCreateResponses = {
+ /**
+ * Created session
+ */
+ 200: Pty
+}
+
+export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses]
+
+export type PtyRemoveData = {
+ body?: never
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/pty/{id}"
+}
+
+export type PtyRemoveErrors = {
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]
+
+export type PtyRemoveResponses = {
+ /**
+ * Session removed
+ */
+ 200: boolean
+}
+
+export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses]
+
+export type PtyGetData = {
+ body?: never
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/pty/{id}"
+}
+
+export type PtyGetErrors = {
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]
+
+export type PtyGetResponses = {
+ /**
+ * Session info
+ */
+ 200: Pty
+}
+
+export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses]
+
+export type PtyUpdateData = {
+ body?: {
+ title?: string
+ size?: {
+ rows: number
+ cols: number
+ }
+ }
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/pty/{id}"
+}
+
+export type PtyUpdateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]
+
+export type PtyUpdateResponses = {
+ /**
+ * Updated session
+ */
+ 200: Pty
+}
+
+export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]
+
+export type PtyConnectData = {
+ body?: never
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/pty/{id}/connect"
+}
+
+export type PtyConnectErrors = {
+ /**
+ * Session not found
+ */
+ 404: boolean
+}
+
+export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]
+
+export type PtyConnectResponses = {
+ /**
+ * Connected session
+ */
+ 200: boolean
+}
+
+export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]
+
export type ConfigGetData = {
body?: never
path?: never
diff --git a/packages/util/src/shell.ts b/packages/util/src/shell.ts
new file mode 100644
index 000000000000..e23ba0199d33
--- /dev/null
+++ b/packages/util/src/shell.ts
@@ -0,0 +1,13 @@
+export function shell() {
+ const s = process.env.SHELL
+ if (s) return s
+ if (process.platform === "darwin") {
+ return "/bin/zsh"
+ }
+ if (process.platform === "win32") {
+ return process.env.COMSPEC || "cmd.exe"
+ }
+ const bash = Bun.which("bash")
+ if (bash) return bash
+ return "bash"
+}
From c1ebcdc956a81f202888e215ac2923e7d7d7f92a Mon Sep 17 00:00:00 2001
From: Github Action
Date: Wed, 3 Dec 2025 21:28:10 +0000
Subject: [PATCH 02/10] Update Nix flake.lock and hashes
---
nix/hashes.json | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/nix/hashes.json b/nix/hashes.json
index 7c7fc45f63ee..c66aa3723ba6 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,7 @@
{
+<<<<<<< HEAD
"nodeModules": "sha256-QhqAa47P3Y2aoMGnr8l1nLq0EQb4qEm75dGfNjyzbpU="
+=======
+ "nodeModules": "sha256-dPceu/boVp7YuQq8XMeLPd48hZIqJRG1CZS4O8qrfNw="
+>>>>>>> 16ec34c64 (Update Nix flake.lock and hashes)
}
From 3b7f8bbd96dea06e107450a6a95d2eafa7120107 Mon Sep 17 00:00:00 2001
From: Dax Raad
Date: Wed, 3 Dec 2025 21:13:11 -0500
Subject: [PATCH 03/10] fix
---
nix/hashes.json | 4 ----
1 file changed, 4 deletions(-)
diff --git a/nix/hashes.json b/nix/hashes.json
index c66aa3723ba6..7c7fc45f63ee 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,7 +1,3 @@
{
-<<<<<<< HEAD
"nodeModules": "sha256-QhqAa47P3Y2aoMGnr8l1nLq0EQb4qEm75dGfNjyzbpU="
-=======
- "nodeModules": "sha256-dPceu/boVp7YuQq8XMeLPd48hZIqJRG1CZS4O8qrfNw="
->>>>>>> 16ec34c64 (Update Nix flake.lock and hashes)
}
From f6416318524ddb5a1160783a963d5f50d4954423 Mon Sep 17 00:00:00 2001
From: Dax Raad
Date: Wed, 3 Dec 2025 21:15:59 -0500
Subject: [PATCH 04/10] fix
---
packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 50274f442002..a34b74bba5e9 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -4,6 +4,7 @@ import { Log } from "@/util/log"
import { Instance } from "@/project/instance"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
+import type { BunWebSocketData } from "hono/bun"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -26,7 +27,7 @@ process.on("uncaughtException", (e) => {
})
})
-let server: Bun.Server
+let server: Bun.Server
export const rpc = {
async server(input: { port: number; hostname: string }) {
if (server) await server.stop(true)
From a2b6972ba11f8609c0d86b39df0fa89094d42bfd Mon Sep 17 00:00:00 2001
From: Github Action
Date: Thu, 4 Dec 2025 02:17:30 +0000
Subject: [PATCH 05/10] Update Nix flake.lock and hashes
---
flake.lock | 6 +++---
nix/hashes.json | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/flake.lock b/flake.lock
index 45c31d9ccf28..4e7cf41e1b73 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1764642553,
- "narHash": "sha256-mvbFFzVBhVK1FjyPHZGMAKpNiqkr7k++xIwy+p/NQvA=",
+ "lastModified": 1764733908,
+ "narHash": "sha256-QJiih52NU+nm7XQWCj+K8SwUdIEayDQ1FQgjkYISt4I=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "f720de59066162ee879adcc8c79e15c51fe6bfb4",
+ "rev": "cadcc8de247676e4751c9d4a935acb2c0b059113",
"type": "github"
},
"original": {
diff --git a/nix/hashes.json b/nix/hashes.json
index 7c7fc45f63ee..ad5bc7c7bfc4 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,3 @@
{
- "nodeModules": "sha256-QhqAa47P3Y2aoMGnr8l1nLq0EQb4qEm75dGfNjyzbpU="
+ "nodeModules": "sha256-Y8hmlo7WkNLqym65p/xHg1jzF/76Jb3EXNFt//qW6FU="
}
From d94d81eed528644c581a7a6baf7618b0569d4752 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Thu, 4 Dec 2025 09:00:31 -0600
Subject: [PATCH 06/10] temp: don't await server.stop
---
packages/opencode/src/cli/cmd/tui/worker.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index a34b74bba5e9..2fc8d864e610 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -52,7 +52,9 @@ export const rpc = {
async shutdown() {
Log.Default.info("worker shutting down")
await Instance.disposeAll()
- await server.stop(true)
+ // TODO: this should be awaited, but ws connections are
+ // causing this to hang, need to revisit this
+ server.stop(true)
},
}
From 1d8206f83fbdeadb5eaa1230451c02ed60006a98 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Thu, 4 Dec 2025 14:43:37 -0600
Subject: [PATCH 07/10] feat(desktop): terminal pane
---
packages/desktop/src/addons/serialize.ts | 649 +++++++++++++++++++
packages/desktop/src/components/terminal.tsx | 73 ++-
packages/desktop/src/context/layout.tsx | 22 +-
packages/desktop/src/context/session.tsx | 102 ++-
packages/desktop/src/pages/layout.tsx | 74 ++-
packages/desktop/src/pages/session.tsx | 627 +++++++++---------
packages/opencode/src/pty/index.ts | 14 +-
packages/ui/src/components/icon.tsx | 7 +-
packages/ui/src/components/select.css | 2 +
packages/ui/src/components/select.tsx | 56 +-
packages/ui/src/components/tabs.css | 96 ++-
packages/ui/src/components/tabs.tsx | 17 +-
packages/ui/src/components/tooltip.css | 7 +-
13 files changed, 1375 insertions(+), 371 deletions(-)
create mode 100644 packages/desktop/src/addons/serialize.ts
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts
new file mode 100644
index 000000000000..03899ff109b3
--- /dev/null
+++ b/packages/desktop/src/addons/serialize.ts
@@ -0,0 +1,649 @@
+/**
+ * SerializeAddon - Serialize terminal buffer contents
+ *
+ * Port of xterm.js addon-serialize for ghostty-web.
+ * Enables serialization of terminal contents to a string that can
+ * be written back to restore terminal state.
+ *
+ * Usage:
+ * ```typescript
+ * const serializeAddon = new SerializeAddon();
+ * term.loadAddon(serializeAddon);
+ * const content = serializeAddon.serialize();
+ * ```
+ */
+
+import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web"
+
+// ============================================================================
+// Buffer Types (matching ghostty-web internal interfaces)
+// ============================================================================
+
+interface IBuffer {
+ readonly type: "normal" | "alternate"
+ readonly cursorX: number
+ readonly cursorY: number
+ readonly viewportY: number
+ readonly baseY: number
+ readonly length: number
+ getLine(y: number): IBufferLine | undefined
+ getNullCell(): IBufferCell
+}
+
+interface IBufferLine {
+ readonly length: number
+ readonly isWrapped: boolean
+ getCell(x: number): IBufferCell | undefined
+ translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
+}
+
+interface IBufferCell {
+ getChars(): string
+ getCode(): number
+ getWidth(): number
+ getFgColorMode(): number
+ getBgColorMode(): number
+ getFgColor(): number
+ getBgColor(): number
+ isBold(): number
+ isItalic(): number
+ isUnderline(): number
+ isStrikethrough(): number
+ isBlink(): number
+ isInverse(): number
+ isInvisible(): number
+ isFaint(): number
+ isDim(): boolean
+}
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ISerializeOptions {
+ /**
+ * The row range to serialize. When an explicit range is specified, the cursor
+ * will get its final repositioning.
+ */
+ range?: ISerializeRange
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer. When not specified, all available
+ * rows in the scrollback buffer will be serialized.
+ */
+ scrollback?: number
+ /**
+ * Whether to exclude the terminal modes from the serialization.
+ * Default: false
+ */
+ excludeModes?: boolean
+ /**
+ * Whether to exclude the alt buffer from the serialization.
+ * Default: false
+ */
+ excludeAltBuffer?: boolean
+}
+
+export interface ISerializeRange {
+ /**
+ * The line to start serializing (inclusive).
+ */
+ start: number
+ /**
+ * The line to end serializing (inclusive).
+ */
+ end: number
+}
+
+export interface IHTMLSerializeOptions {
+ /**
+ * The number of rows in the scrollback buffer to serialize, starting from
+ * the bottom of the scrollback buffer.
+ */
+ scrollback?: number
+ /**
+ * Whether to only serialize the selection.
+ * Default: false
+ */
+ onlySelection?: boolean
+ /**
+ * Whether to include the global background of the terminal.
+ * Default: false
+ */
+ includeGlobalBackground?: boolean
+ /**
+ * The range to serialize. This is prioritized over onlySelection.
+ */
+ range?: {
+ startLine: number
+ endLine: number
+ startCol: number
+ }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function constrain(value: number, low: number, high: number): number {
+ return Math.max(low, Math.min(value, high))
+}
+
+function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor()
+}
+
+function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor()
+}
+
+function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
+ return (
+ !!cell1.isInverse() === !!cell2.isInverse() &&
+ !!cell1.isBold() === !!cell2.isBold() &&
+ !!cell1.isUnderline() === !!cell2.isUnderline() &&
+ !!cell1.isBlink() === !!cell2.isBlink() &&
+ !!cell1.isInvisible() === !!cell2.isInvisible() &&
+ !!cell1.isItalic() === !!cell2.isItalic() &&
+ !!cell1.isDim() === !!cell2.isDim() &&
+ !!cell1.isStrikethrough() === !!cell2.isStrikethrough()
+ )
+}
+
+// ============================================================================
+// Base Serialize Handler
+// ============================================================================
+
+abstract class BaseSerializeHandler {
+ constructor(protected readonly _buffer: IBuffer) {}
+
+ public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
+ let oldCell = this._buffer.getNullCell()
+
+ const startRow = range.start.y
+ const endRow = range.end.y
+ const startColumn = range.start.x
+ const endColumn = range.end.x
+
+ this._beforeSerialize(endRow - startRow, startRow, endRow)
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = this._buffer.getLine(row)
+ if (line) {
+ const startLineColumn = row === range.start.y ? startColumn : 0
+ const endLineColumn = row === range.end.y ? endColumn : line.length
+ for (let col = startLineColumn; col < endLineColumn; col++) {
+ const c = line.getCell(col)
+ if (!c) {
+ continue
+ }
+ this._nextCell(c, oldCell, row, col)
+ oldCell = c
+ }
+ }
+ this._rowEnd(row, row === endRow)
+ }
+
+ this._afterSerialize()
+
+ return this._serializeString(excludeFinalCursorPosition)
+ }
+
+ protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
+ protected _rowEnd(_row: number, _isLastRow: boolean): void {}
+ protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
+ protected _afterSerialize(): void {}
+ protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
+ return ""
+ }
+}
+
+// ============================================================================
+// String Serialize Handler
+// ============================================================================
+
+class StringSerializeHandler extends BaseSerializeHandler {
+ private _rowIndex: number = 0
+ private _allRows: string[] = []
+ private _allRowSeparators: string[] = []
+ private _currentRow: string = ""
+ private _nullCellCount: number = 0
+ private _cursorStyle: IBufferCell
+ private _cursorStyleRow: number = 0
+ private _cursorStyleCol: number = 0
+ private _backgroundCell: IBufferCell
+ private _firstRow: number = 0
+ private _lastCursorRow: number = 0
+ private _lastCursorCol: number = 0
+ private _lastContentCursorRow: number = 0
+ private _lastContentCursorCol: number = 0
+ private _thisRowLastChar: IBufferCell
+ private _thisRowLastSecondChar: IBufferCell
+ private _nextRowFirstChar: IBufferCell
+
+ constructor(
+ buffer: IBuffer,
+ private readonly _terminal: ITerminalCore,
+ ) {
+ super(buffer)
+ this._cursorStyle = this._buffer.getNullCell()
+ this._backgroundCell = this._buffer.getNullCell()
+ this._thisRowLastChar = this._buffer.getNullCell()
+ this._thisRowLastSecondChar = this._buffer.getNullCell()
+ this._nextRowFirstChar = this._buffer.getNullCell()
+ }
+
+ protected _beforeSerialize(rows: number, start: number, _end: number): void {
+ this._allRows = new Array(rows)
+ this._lastContentCursorRow = start
+ this._lastCursorRow = start
+ this._firstRow = start
+ }
+
+ protected _rowEnd(row: number, isLastRow: boolean): void {
+ // if there is colorful empty cell at line end, we must pad it back
+ if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ }
+
+ let rowSeparator = ""
+
+ if (!isLastRow) {
+ // Enable BCE
+ if (row - this._firstRow >= this._terminal.rows) {
+ const line = this._buffer.getLine(this._cursorStyleRow)
+ const cell = line?.getCell(this._cursorStyleCol)
+ if (cell) {
+ this._backgroundCell = cell
+ }
+ }
+
+ const currentLine = this._buffer.getLine(row)!
+ const nextLine = this._buffer.getLine(row + 1)!
+
+ if (!nextLine.isWrapped) {
+ rowSeparator = "\r\n"
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
+ } else {
+ rowSeparator = ""
+ const thisRowLastChar = currentLine.getCell(currentLine.length - 1)
+ const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2)
+ const nextRowFirstChar = nextLine.getCell(0)
+
+ if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar
+ if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar
+ if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar
+
+ const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1
+
+ let isValid = false
+
+ if (
+ this._nextRowFirstChar.getChars() &&
+ (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0)
+ ) {
+ if (
+ (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) &&
+ equalBg(this._thisRowLastChar, this._nextRowFirstChar)
+ ) {
+ isValid = true
+ }
+
+ if (
+ isNextRowFirstCharDoubleWidth &&
+ (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) &&
+ equalBg(this._thisRowLastChar, this._nextRowFirstChar) &&
+ equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar)
+ ) {
+ isValid = true
+ }
+ }
+
+ if (!isValid) {
+ rowSeparator = "-".repeat(this._nullCellCount + 1)
+ rowSeparator += "\u001b[1D\u001b[1X"
+
+ if (this._nullCellCount > 0) {
+ rowSeparator += "\u001b[A"
+ rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`
+ rowSeparator += `\u001b[${this._nullCellCount}X`
+ rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`
+ rowSeparator += "\u001b[B"
+ }
+
+ this._lastContentCursorRow = row + 1
+ this._lastContentCursorCol = 0
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
+ }
+ }
+ }
+
+ this._allRows[this._rowIndex] = this._currentRow
+ this._allRowSeparators[this._rowIndex++] = rowSeparator
+ this._currentRow = ""
+ this._nullCellCount = 0
+ }
+
+ private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
+ const sgrSeq: number[] = []
+ const fgChanged = !equalFg(cell, oldCell)
+ const bgChanged = !equalBg(cell, oldCell)
+ const flagsChanged = !equalFlags(cell, oldCell)
+
+ if (fgChanged || bgChanged || flagsChanged) {
+ if (this._isAttributeDefault(cell)) {
+ if (!this._isAttributeDefault(oldCell)) {
+ sgrSeq.push(0)
+ }
+ } else {
+ if (fgChanged) {
+ const color = cell.getFgColor()
+ const mode = cell.getFgColorMode()
+ if (mode === 2) {
+ // RGB
+ sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(38, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(39)
+ }
+ }
+ if (bgChanged) {
+ const color = cell.getBgColor()
+ const mode = cell.getBgColorMode()
+ if (mode === 2) {
+ // RGB
+ sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
+ } else if (mode === 1) {
+ // Palette
+ if (color >= 16) {
+ sgrSeq.push(48, 5, color)
+ } else {
+ sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
+ }
+ } else {
+ sgrSeq.push(49)
+ }
+ }
+ if (flagsChanged) {
+ if (!!cell.isInverse() !== !!oldCell.isInverse()) {
+ sgrSeq.push(cell.isInverse() ? 7 : 27)
+ }
+ if (!!cell.isBold() !== !!oldCell.isBold()) {
+ sgrSeq.push(cell.isBold() ? 1 : 22)
+ }
+ if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
+ sgrSeq.push(cell.isUnderline() ? 4 : 24)
+ }
+ if (!!cell.isBlink() !== !!oldCell.isBlink()) {
+ sgrSeq.push(cell.isBlink() ? 5 : 25)
+ }
+ if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
+ sgrSeq.push(cell.isInvisible() ? 8 : 28)
+ }
+ if (!!cell.isItalic() !== !!oldCell.isItalic()) {
+ sgrSeq.push(cell.isItalic() ? 3 : 23)
+ }
+ if (!!cell.isDim() !== !!oldCell.isDim()) {
+ sgrSeq.push(cell.isDim() ? 2 : 22)
+ }
+ if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
+ sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
+ }
+ }
+ }
+ }
+
+ return sgrSeq
+ }
+
+ private _isAttributeDefault(cell: IBufferCell): boolean {
+ return (
+ cell.getFgColorMode() === 0 &&
+ cell.getBgColorMode() === 0 &&
+ !cell.isBold() &&
+ !cell.isItalic() &&
+ !cell.isUnderline() &&
+ !cell.isBlink() &&
+ !cell.isInverse() &&
+ !cell.isInvisible() &&
+ !cell.isDim() &&
+ !cell.isStrikethrough()
+ )
+ }
+
+ protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
+ const isPlaceHolderCell = cell.getWidth() === 0
+
+ if (isPlaceHolderCell) {
+ return
+ }
+
+ const isEmptyCell = cell.getChars() === ""
+
+ const sgrSeq = this._diffStyle(cell, this._cursorStyle)
+
+ const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+
+ if (styleChanged) {
+ if (this._nullCellCount > 0) {
+ if (!equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ }
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ this._nullCellCount = 0
+ }
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col
+
+ this._currentRow += `\u001b[${sgrSeq.join(";")}m`
+
+ const line = this._buffer.getLine(row)
+ const cellFromLine = line?.getCell(col)
+ if (cellFromLine) {
+ this._cursorStyle = cellFromLine
+ this._cursorStyleRow = row
+ this._cursorStyleCol = col
+ }
+ }
+
+ if (isEmptyCell) {
+ this._nullCellCount += cell.getWidth()
+ } else {
+ if (this._nullCellCount > 0) {
+ if (equalBg(this._cursorStyle, this._backgroundCell)) {
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ } else {
+ this._currentRow += `\u001b[${this._nullCellCount}X`
+ this._currentRow += `\u001b[${this._nullCellCount}C`
+ }
+ this._nullCellCount = 0
+ }
+
+ this._currentRow += cell.getChars()
+
+ this._lastContentCursorRow = this._lastCursorRow = row
+ this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
+ }
+ }
+
+ protected _serializeString(excludeFinalCursorPosition?: boolean): string {
+ let rowEnd = this._allRows.length
+
+ if (this._buffer.length - this._firstRow <= this._terminal.rows) {
+ rowEnd = this._lastContentCursorRow + 1 - this._firstRow
+ this._lastCursorCol = this._lastContentCursorCol
+ this._lastCursorRow = this._lastContentCursorRow
+ }
+
+ let content = ""
+
+ for (let i = 0; i < rowEnd; i++) {
+ content += this._allRows[i]
+ if (i + 1 < rowEnd) {
+ content += this._allRowSeparators[i]
+ }
+ }
+
+ if (!excludeFinalCursorPosition) {
+ // Get cursor position relative to viewport (1-indexed for ANSI)
+ // cursorY is relative to the viewport, cursorX is column position
+ const cursorRow = this._buffer.cursorY + 1 // 1-indexed
+ const cursorCol = this._buffer.cursorX + 1 // 1-indexed
+
+ // Use absolute cursor positioning (CUP - Cursor Position)
+ // This is more reliable than relative moves which depend on knowing
+ // exactly where the cursor ended up after all the content
+ content += `\u001b[${cursorRow};${cursorCol}H`
+ }
+
+ return content
+ }
+}
+
+// ============================================================================
+// SerializeAddon Class
+// ============================================================================
+
+export class SerializeAddon implements ITerminalAddon {
+ private _terminal?: ITerminalCore
+
+ /**
+ * Activate the addon (called by Terminal.loadAddon)
+ */
+ public activate(terminal: ITerminalCore): void {
+ this._terminal = terminal
+ }
+
+ /**
+ * Dispose the addon and clean up resources
+ */
+ public dispose(): void {
+ this._terminal = undefined
+ }
+
+ /**
+ * Serializes terminal rows into a string that can be written back to the
+ * terminal to restore the state. The cursor will also be positioned to the
+ * correct cell.
+ *
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serialize(options?: ISerializeOptions): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const activeBuffer = buffer.active || buffer.normal
+ if (!activeBuffer) {
+ return ""
+ }
+
+ let content = options?.range
+ ? this._serializeBufferByRange(activeBuffer, options.range, true)
+ : this._serializeBufferByScrollback(activeBuffer, options?.scrollback)
+
+ // Handle alternate buffer if active and not excluded
+ if (!options?.excludeAltBuffer) {
+ const altBuffer = buffer.alternate
+ if (altBuffer && buffer.active?.type === "alternate") {
+ const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
+ content += `\u001b[?1049h\u001b[H${alternateContent}`
+ }
+ }
+
+ return content
+ }
+
+ /**
+ * Serializes terminal content as plain text (no escape sequences)
+ * @param options Custom options to allow control over what gets serialized.
+ */
+ public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
+ if (!this._terminal) {
+ throw new Error("Cannot use addon until it has been loaded")
+ }
+
+ const terminal = this._terminal as any
+ const buffer = terminal.buffer
+
+ if (!buffer) {
+ return ""
+ }
+
+ const activeBuffer = buffer.active || buffer.normal
+ if (!activeBuffer) {
+ return ""
+ }
+
+ const maxRows = activeBuffer.length
+ const scrollback = options?.scrollback
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
+
+ const startRow = maxRows - correctRows
+ const endRow = maxRows - 1
+ const lines: string[] = []
+
+ for (let row = startRow; row <= endRow; row++) {
+ const line = activeBuffer.getLine(row)
+ if (line) {
+ const text = line.translateToString(options?.trimWhitespace ?? true)
+ lines.push(text)
+ }
+ }
+
+ // Trim trailing empty lines if requested
+ if (options?.trimWhitespace) {
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
+ lines.pop()
+ }
+ }
+
+ return lines.join("\n")
+ }
+
+ private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
+ const maxRows = buffer.length
+ const rows = this._terminal?.rows ?? 24
+ const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
+ return this._serializeBufferByRange(
+ buffer,
+ {
+ start: maxRows - correctRows,
+ end: maxRows - 1,
+ },
+ false,
+ )
+ }
+
+ private _serializeBufferByRange(
+ buffer: IBuffer,
+ range: ISerializeRange,
+ excludeFinalCursorPosition: boolean,
+ ): string {
+ const handler = new StringSerializeHandler(buffer, this._terminal!)
+ const cols = this._terminal?.cols ?? 80
+ return handler.serialize(
+ {
+ start: { x: 0, y: range.start },
+ end: { x: cols, y: range.end },
+ },
+ excludeFinalCursorPosition,
+ )
+ }
+}
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
index 3f6977041d53..49a45a432bcd 100644
--- a/packages/desktop/src/components/terminal.tsx
+++ b/packages/desktop/src/components/terminal.tsx
@@ -1,22 +1,30 @@
import { init, Terminal as Term, FitAddon } from "ghostty-web"
-import { ComponentProps, onMount, splitProps } from "solid-js"
-import { createReconnectingWS } from "@solid-primitives/websocket"
+import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
+import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket"
import { useSDK } from "@/context/sdk"
+import { SerializeAddon } from "@/addons/serialize"
+import { LocalPTY } from "@/context/session"
await init()
export interface TerminalProps extends ComponentProps<"div"> {
- id: string
+ pty: LocalPTY
+ onSubmit?: () => void
+ onCleanup?: (pty: LocalPTY) => void
}
export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["id", "class", "classList"])
+ const [local, others] = splitProps(props, ["pty", "class", "classList"])
+ let ws: ReconnectingWebSocket
+ let term: Term
+ let serializeAddon: SerializeAddon
+ let fitAddon: FitAddon
onMount(async () => {
- const ws = createReconnectingWS(sdk.url + `/pty/${local.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
- const term = new Term({
+ ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ term = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "TX-02, monospace",
@@ -27,11 +35,38 @@ export const Terminal = (props: TerminalProps) => {
},
scrollback: 10_000,
})
+ term.attachCustomKeyEventHandler((event) => {
+ // allow for ctrl-` to toggle terminal in parent
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ return true
+ }
+ return false
+ })
- const fitAddon = new FitAddon()
+ fitAddon = new FitAddon()
+ serializeAddon = new SerializeAddon()
+ term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
+
term.open(container)
+ if (local.pty.buffer) {
+ const originalSize = { cols: term.cols, rows: term.rows }
+ let resized = false
+ if (local.pty.rows && local.pty.cols) {
+ term.resize(local.pty.cols, local.pty.rows)
+ resized = true
+ }
+ term.write(local.pty.buffer)
+ if (local.pty.scrollY) {
+ term.scrollToLine(local.pty.scrollY)
+ }
+ if (resized) {
+ term.resize(originalSize.cols, originalSize.rows)
+ }
+ }
+
container.focus()
fitAddon.fit()
@@ -40,7 +75,7 @@ export const Terminal = (props: TerminalProps) => {
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty.update({
- path: { id: local.id },
+ path: { id: local.pty.id },
body: {
size: {
cols: size.cols,
@@ -55,13 +90,18 @@ export const Terminal = (props: TerminalProps) => {
ws.send(data)
}
})
+ term.onKey((key) => {
+ if (key.key == "Enter") {
+ props.onSubmit?.()
+ }
+ })
// term.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
ws.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty.update({
- path: { id: local.id },
+ path: { id: local.pty.id },
body: {
size: {
cols: term.cols,
@@ -81,6 +121,21 @@ export const Terminal = (props: TerminalProps) => {
})
})
+ onCleanup(() => {
+ if (serializeAddon && props.onCleanup) {
+ const buffer = serializeAddon.serialize()
+ props.onCleanup({
+ ...local.pty,
+ buffer,
+ rows: term.rows,
+ cols: term.cols,
+ scrollY: term.getViewportY(),
+ })
+ }
+ ws?.close()
+ term?.dispose()
+ })
+
return (
store.terminal.opened),
+ open() {
+ setStore("terminal", "opened", true)
+ },
+ close() {
+ setStore("terminal", "opened", false)
+ },
+ toggle() {
+ setStore("terminal", "opened", (x) => !x)
+ },
+ height: createMemo(() => store.terminal.height),
+ resize(height: number) {
+ setStore("terminal", "height", height)
+ },
+ },
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 8efb354e11d8..4e9fe71f8a73 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -8,14 +8,25 @@ import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@/utils"
+import { useSDK } from "./sdk"
+
+export type LocalPTY = {
+ id: string
+ title: string
+ rows?: number
+ cols?: number
+ buffer?: string
+ scrollY?: number
+}
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: () => {
+ const sdk = useSDK()
const params = useParams()
const sync = useSync()
const name = createMemo(
- () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
+ () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
)
const [store, setStore] = makePersisted(
@@ -23,16 +34,21 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
messageId?: string
tabs: {
active?: string
- opened: string[]
+ all: string[]
}
prompt: Prompt
cursor?: number
+ terminals: {
+ active?: string
+ all: LocalPTY[]
+ }
}>({
tabs: {
- opened: [],
+ all: [],
},
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
+ terminals: { all: [] },
}),
{
name: name(),
@@ -138,16 +154,16 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", tab)
},
setOpenedTabs(tabs: string[]) {
- setStore("tabs", "opened", tabs)
+ setStore("tabs", "all", tabs)
},
async openTab(tab: string) {
if (tab === "chat") {
setStore("tabs", "active", undefined)
return
}
- if (tab !== "review" && tab !== "terminal") {
- if (!store.tabs.opened.includes(tab)) {
- setStore("tabs", "opened", [...store.tabs.opened, tab])
+ if (tab !== "review") {
+ if (!store.tabs.all.includes(tab)) {
+ setStore("tabs", "all", [...store.tabs.all, tab])
}
}
setStore("tabs", "active", tab)
@@ -156,28 +172,88 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
batch(() => {
setStore(
"tabs",
- "opened",
- store.tabs.opened.filter((x) => x !== tab),
+ "all",
+ store.tabs.all.filter((x) => x !== tab),
)
if (store.tabs.active === tab) {
- const index = store.tabs.opened.findIndex((f) => f === tab)
- const previous = store.tabs.opened[Math.max(0, index - 1)]
+ const index = store.tabs.all.findIndex((f) => f === tab)
+ const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("tabs", "active", previous)
}
})
},
moveTab(tab: string, to: number) {
- const index = store.tabs.opened.findIndex((f) => f === tab)
+ const index = store.tabs.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
- "opened",
+ "all",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
},
},
+ terminal: {
+ all: createMemo(() => Object.values(store.terminals.all)),
+ active: createMemo(() => store.terminals.active),
+ new() {
+ sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => {
+ const id = pty.data?.id
+ if (!id) return
+ batch(() => {
+ setStore("terminals", "all", [
+ ...store.terminals.all,
+ {
+ id,
+ title: pty.data?.title ?? "Terminal",
+ // rows: pty.data?.rows ?? 24,
+ // cols: pty.data?.cols ?? 80,
+ // buffer: "",
+ // scrollY: 0,
+ },
+ ])
+ setStore("terminals", "active", id)
+ })
+ })
+ },
+ update(pty: Partial
& { id: string }) {
+ setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+ sdk.client.pty.update({
+ path: { id: pty.id },
+ body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined },
+ })
+ },
+ open(id: string) {
+ setStore("terminals", "active", id)
+ },
+ async close(id: string) {
+ batch(() => {
+ setStore(
+ "terminals",
+ "all",
+ store.terminals.all.filter((x) => x.id !== id),
+ )
+ if (store.terminals.active === id) {
+ const index = store.terminals.all.findIndex((f) => f.id === id)
+ const previous = store.tabs.all[Math.max(0, index - 1)]
+ setStore("terminals", "active", previous)
+ }
+ })
+ await sdk.client.pty.remove({ path: { id } })
+ },
+ move(id: string, to: number) {
+ const index = store.terminals.all.findIndex((f) => f.id === id)
+ if (index === -1) return
+ setStore(
+ "terminals",
+ "all",
+ produce((all) => {
+ all.splice(to, 0, all.splice(index, 1)[0])
+ }),
+ )
+ },
+ },
}
},
})
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 15180c885660..377545ab9f5c 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,9 +1,9 @@
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
-import { A, useParams } from "@solidjs/router"
+import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
-import { base64Encode } from "@/utils"
+import { base64Decode, base64Encode } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -12,11 +12,21 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
+import { Select } from "@opencode-ai/ui/select"
+import { Session } from "@opencode-ai/sdk/client"
export default function Layout(props: ParentProps) {
+ const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync()
const layout = useLayout()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
+ const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0))
+
+ function navigateToSession(session: Session | undefined) {
+ navigate(`/${params.dir}/session/${session?.id}`)
+ }
const handleOpenProject = async () => {
// layout.projects.open(dir.)
@@ -24,7 +34,7 @@ export default function Layout(props: ParentProps) {
return (
- {props.children}
+ {props.children}
)
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 18a385e39ec3..8c3a5eb842a6 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@@ -32,19 +32,16 @@ import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Diff } from "@opencode-ai/ui/diff"
import { Terminal } from "@/components/terminal"
-import { useSDK } from "@/context/sdk"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
const session = useSession()
- const sdk = useSDK()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
fileSelectOpen: false,
activeDraggable: undefined as string | undefined,
- ptyId: undefined as string | undefined,
})
let inputRef!: HTMLDivElement
@@ -52,14 +49,20 @@ export default function Page() {
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
-
- sdk.client.pty.create().then((pty) => setStore("ptyId", pty.data?.id))
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
+ createEffect(() => {
+ if (layout.terminal.opened()) {
+ if (session.terminal.all().length === 0) {
+ session.terminal.new()
+ }
+ }
+ })
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
@@ -79,8 +82,12 @@ export default function Page() {
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ layout.terminal.toggle()
+ return
+ }
- // check if active element has `data-component="terminal"`
// @ts-expect-error
if (document.activeElement?.dataset?.component === "terminal") {
return
@@ -153,7 +160,7 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const currentTabs = session.layout.tabs.opened
+ const currentTabs = session.layout.tabs.all
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
@@ -271,331 +278,361 @@ export default function Page() {
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
-
-
-
-
-
-
-
-
-
-
Session
-
+
+
+
+
+
+
+
+
+
+
Session
+
+
+ {session.usage.context() ?? 0}%
+
+
+
+
+
+ }
>
-
- {session.usage.context() ?? 0}%
-
-
-
-
- Terminal
-
-
-
- }
- >
-
-
-
-
-
-
Review
-
-
- {session.info()?.summary?.files ?? 0}
-
+
+
+
+
+
Review
+
+
+ {session.info()?.summary?.files ?? 0}
+
+
+
-
-
-
-
-
- {(tab) => }
-
-
-
-
- setStore("fileSelectOpen", true)}
- />
-
-
-
-
-
-
+
+
+
+
+ {(tab) => (
+
+ )}
+
+
+
+
+ setStore("fileSelectOpen", true)}
+ />
+
+
+
+
+
-
-
-
-
- 1
- ? "pr-6 pl-18"
- : "px-6"),
- }}
- diffComponent={Diff}
- />
-
-
-
-
-
New session
-
-
-
- {getDirectory(sync.data.path.directory)}
- {getFilename(sync.data.path.directory)}
-
+
+
+
+
+
+ 1
+ ? "pr-6 pl-18"
+ : "px-6"),
+ }}
+ diffComponent={Diff}
+ />
-
-
-
- Last modified
-
- {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
-
+
+
+
+
New session
+
+
+
+ {getDirectory(sync.data.path.directory)}
+ {getFilename(sync.data.path.directory)}
+
+
+
+
+
+ Last modified
+
+ {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
+
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
-
-
-
+
+
+
+ {
+ layout.review.tab()
+ session.layout.setActiveTab("review")
+ }}
+ />
+
+ }
/>
-
+
-
+
+
+
- {
- layout.review.tab()
- session.layout.setActiveTab("review")
- }}
- />
-
- }
+ split
/>
-
-
-
-
-
-
-
-
-
+
+
+
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+
+
+ {(f) => (
+
+ )}
+
+
+
+ )
+ }}
+
+
+
+
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+ {(f) => }
+
+ )
+ }}
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+
+ {/* */}
+
+
+ No changes
}
+ >
+
+
+ {(path) => (
+ -
+
+
+ )}
+
+
-
-
+
+
+ x}
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => {
+ if (x) {
+ local.file.open(x)
+ return session.layout.openTab("file://" + x)
+ }
+ return undefined
+ }}
+ >
+ {(i) => (
-
-
-
-
-
- {(tab) => {
- const [file] = createResource(
- () => tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
-
-
-
- {(f) => (
-
- )}
-
-
-
- )
- }}
-
-
-
-
- {(draggedFile) => {
- const [file] = createResource(
- () => draggedFile(),
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
-
- {(f) => }
-
- )
- }}
-
-
-
-
-
-
{
- inputRef = el
- }}
- />
-
-
-
- {/* */}
-
-
- No changes
}>
-
-
- {(path) => (
- -
-
+ )}
+
-
- x}
- onOpenChange={(open) => setStore("fileSelectOpen", open)}
- onSelect={(x) => {
- if (x) {
- local.file.open(x)
- return session.layout.openTab("file://" + x)
- }
- return undefined
- }}
+
+
- {(i) => (
-
-
-
-
-
- {getDirectory(i)}
-
- {getFilename(i)}
-
+
+
+
+ {(terminal) => (
+ 1 && (
+ session.terminal.close(terminal.id)} />
+ )
+ }
+ >
+ {terminal.title}
+
+ )}
+
+
+
+
+
-
-
- )}
-
+
+
+ {(terminal) => (
+
+
+
+ )}
+
+
+
)
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 31e6e852be79..efb519ff2a7a 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -56,7 +56,7 @@ export namespace Pty {
interface ActiveSession {
info: Info
process: IPty
- history: string
+ buffer: string
subscribers: Set
}
@@ -108,12 +108,15 @@ export namespace Pty {
const session: ActiveSession = {
info,
process: ptyProcess,
- history: "",
+ buffer: "",
subscribers: new Set(),
}
state().set(id, session)
ptyProcess.onData((data) => {
- session.history += data
+ if (session.subscribers.size === 0) {
+ session.buffer += data
+ return
+ }
for (const ws of session.subscribers) {
if (ws.readyState === 1) {
ws.send(data)
@@ -179,8 +182,9 @@ export namespace Pty {
}
log.info("client connected to session", { id })
session.subscribers.add(ws)
- if (session.history) {
- ws.send(session.history)
+ if (session.buffer) {
+ ws.send(session.buffer)
+ session.buffer = ""
}
return {
onMessage: (message: string | ArrayBuffer) => {
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 306d7964988c..9e4f00a0de7c 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -153,10 +153,10 @@ const newIcons = {
stop: ``,
enter: ``,
"layout-left": ``,
- "layout-left-partial": ``,
+ "layout-left-partial": ``,
"layout-left-full": ``,
"layout-right": ``,
- "layout-right-partial": ``,
+ "layout-right-partial": ``,
"layout-right-full": ``,
"speech-bubble": ``,
"align-right": ``,
@@ -167,6 +167,9 @@ const newIcons = {
"bubble-5": ``,
github: ``,
discord: ``,
+ "layout-bottom": ``,
+ "layout-bottom-partial": ``,
+ "layout-bottom-full": ``,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css
index 421215a78cea..96ddf174cd83 100644
--- a/packages/ui/src/components/select.css
+++ b/packages/ui/src/components/select.css
@@ -20,6 +20,7 @@
[data-component="select-content"] {
min-width: 4rem;
+ max-width: 23rem;
overflow: hidden;
border-radius: var(--radius-md);
border-width: 1px;
@@ -39,6 +40,7 @@
}
[data-slot="select-select-content-list"] {
+ min-height: 2rem;
overflow-y: auto;
max-height: 12rem;
white-space: nowrap;
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index 464900ef97b3..9ba1f177b56d 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -1,10 +1,10 @@
import { Select as Kobalte } from "@kobalte/core/select"
-import { createMemo, type ComponentProps } from "solid-js"
+import { createMemo, splitProps, type ComponentProps } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, ButtonProps } from "./button"
import { Icon } from "./icon"
-export interface SelectProps {
+export type SelectProps = Omit>, "value" | "onSelect"> & {
placeholder?: string
options: T[]
current?: T
@@ -17,10 +17,21 @@ export interface SelectProps {
}
export function Select(props: SelectProps & ButtonProps) {
+ const [local, others] = splitProps(props, [
+ "class",
+ "classList",
+ "placeholder",
+ "options",
+ "current",
+ "value",
+ "label",
+ "groupBy",
+ "onSelect",
+ ])
const grouped = createMemo(() => {
const result = pipe(
- props.options,
- groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+ local.options,
+ groupBy((x) => (local.groupBy ? local.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
map(([k, v]) => ({ category: k, options: v })),
@@ -29,28 +40,30 @@ export function Select(props: SelectProps & ButtonProps) {
})
return (
+ // @ts-ignore
+ {...others}
data-component="select"
- value={props.current}
+ value={local.current}
options={grouped()}
- optionValue={(x) => (props.value ? props.value(x) : (x as string))}
- optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
+ optionValue={(x) => (local.value ? local.value(x) : (x as string))}
+ optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
optionGroupChildren="options"
- placeholder={props.placeholder}
- sectionComponent={(props) => (
- {props.section.rawValue.category}
+ placeholder={local.placeholder}
+ sectionComponent={(local) => (
+ {local.section.rawValue.category}
)}
itemComponent={(itemProps) => (
- {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
+ {local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
@@ -58,24 +71,25 @@ export function Select(props: SelectProps & ButtonProps) {
)}
onChange={(v) => {
- props.onSelect?.(v ?? undefined)
+ local.onSelect?.(v ?? undefined)
}}
>
data-slot="select-select-trigger-value">
{(state) => {
- const selected = state.selectedOption() ?? props.current
- if (!selected) return props.placeholder || ""
- if (props.label) return props.label(selected)
+ const selected = state.selectedOption() ?? local.current
+ if (!selected) return local.placeholder || ""
+ if (local.label) return local.label(selected)
return selected as string
}}
@@ -86,8 +100,8 @@ export function Select(props: SelectProps & ButtonProps) {
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index d03e57320ff2..d60edc5c509a 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -6,7 +6,7 @@
background-color: var(--background-stronger);
overflow: clip;
- [data-slot="tabs-tabs-list"] {
+ [data-slot="tabs-list"] {
height: 48px;
width: 100%;
position: relative;
@@ -36,7 +36,7 @@
}
}
- [data-slot="tabs-tabs-trigger-wrapper"] {
+ [data-slot="tabs-trigger-wrapper"] {
position: relative;
height: 100%;
display: flex;
@@ -58,14 +58,14 @@
border-right: 1px solid var(--border-weak-base);
background-color: var(--background-base);
- [data-slot="tabs-tabs-trigger"] {
+ [data-slot="tabs-trigger"] {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 24px;
}
- [data-slot="tabs-tabs-trigger-close-button"] {
+ [data-slot="tabs-trigger-close-button"] {
display: flex;
align-items: center;
justify-content: center;
@@ -84,12 +84,12 @@
box-shadow: 0 0 0 2px var(--border-focus);
}
&:has([data-hidden]) {
- [data-slot="tabs-tabs-trigger-close-button"] {
+ [data-slot="tabs-trigger-close-button"] {
opacity: 0;
}
&:hover {
- [data-slot="tabs-tabs-trigger-close-button"] {
+ [data-slot="tabs-trigger-close-button"] {
opacity: 1;
}
}
@@ -98,23 +98,23 @@
color: var(--text-strong);
background-color: transparent;
border-bottom-color: transparent;
- [data-slot="tabs-tabs-trigger-close-button"] {
+ [data-slot="tabs-trigger-close-button"] {
opacity: 1;
}
}
&:hover:not(:disabled):not([data-selected]) {
color: var(--text-strong);
}
- &:has([data-slot="tabs-tabs-trigger-close-button"]) {
+ &:has([data-slot="tabs-trigger-close-button"]) {
padding-right: 12px;
- [data-slot="tabs-tabs-trigger"] {
+ [data-slot="tabs-trigger"] {
padding-right: 0;
}
}
}
- [data-slot="tabs-tabs-content"] {
+ [data-slot="tabs-content"] {
overflow-y: auto;
flex: 1;
@@ -129,4 +129,80 @@
outline: none;
}
}
+
+ &[data-variant="alt"] {
+ [data-slot="tabs-list"] {
+ padding-left: 24px;
+ padding-right: 24px;
+ gap: 12px;
+ border-bottom: 1px solid var(--border-weak-base);
+ background-color: transparent;
+
+ &::after {
+ border: none;
+ background-color: transparent;
+ }
+ &:empty::after {
+ display: none;
+ }
+ }
+
+ [data-slot="tabs-trigger-wrapper"] {
+ border: none;
+ color: var(--text-base);
+ background-color: transparent;
+ border-bottom-width: 2px;
+ border-bottom-style: solid;
+ border-bottom-color: transparent;
+ gap: 4px;
+
+ /* text-14-regular */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-x-large); /* 171.429% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ [data-slot="tabs-trigger"] {
+ height: 100%;
+ padding: 4px;
+ background-color: transparent;
+ border-bottom-width: 2px;
+ border-bottom-color: transparent;
+ }
+
+ [data-slot="tabs-trigger-close-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ [data-component="icon-button"] {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ }
+
+ &:has([data-selected]) {
+ color: var(--text-strong);
+ background-color: transparent;
+ border-bottom-color: var(--icon-strong-base);
+ }
+
+ &:hover:not(:disabled):not([data-selected]) {
+ color: var(--text-strong);
+ }
+
+ &:has([data-slot="tabs-trigger-close-button"]) {
+ padding-right: 0;
+ [data-slot="tabs-trigger"] {
+ padding-right: 0;
+ }
+ }
+ }
+
+ /* [data-slot="tabs-content"] { */
+ /* } */
+ }
}
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index 68acd88d4e16..d91ad3c41565 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -2,7 +2,9 @@ import { Tabs as Kobalte } from "@kobalte/core/tabs"
import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
-export interface TabsProps extends ComponentProps {}
+export interface TabsProps extends ComponentProps {
+ variant?: "normal" | "alt"
+}
export interface TabsListProps extends ComponentProps {}
export interface TabsTriggerProps extends ComponentProps {
classes?: {
@@ -14,11 +16,12 @@ export interface TabsTriggerProps extends ComponentProps
export interface TabsContentProps extends ComponentProps {}
function TabsRoot(props: TabsProps) {
- const [split, rest] = splitProps(props, ["class", "classList"])
+ const [split, rest] = splitProps(props, ["class", "classList", "variant"])
return (
) {
])
return (
) {
>
{split.children}
{(closeButton) => (
-
+
{closeButton()}
)}
@@ -81,7 +84,7 @@ function TabsContent(props: ParentProps
) {
return (
Date: Thu, 4 Dec 2025 14:59:12 -0600
Subject: [PATCH 08/10] feat(desktop): resizeable terminal pane
---
packages/desktop/src/pages/session.tsx | 38 +++++++++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 8c3a5eb842a6..78a8692cb750 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -599,9 +599,45 @@ export default function Page() {
+
{
+ e.preventDefault()
+ const startY = e.clientY
+ const startHeight = layout.terminal.height()
+ const maxHeight = window.innerHeight * 0.8
+ const minHeight = 100
+ const collapseThreshold = 50
+ let currentHeight = startHeight
+
+ document.body.style.userSelect = "none"
+ document.body.style.overflow = "hidden"
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const deltaY = startY - moveEvent.clientY
+ currentHeight = startHeight + deltaY
+ const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight))
+ layout.terminal.resize(clampedHeight)
+ }
+
+ const onMouseUp = () => {
+ document.body.style.userSelect = ""
+ document.body.style.overflow = ""
+ document.removeEventListener("mousemove", onMouseMove)
+ document.removeEventListener("mouseup", onMouseUp)
+
+ if (currentHeight < collapseThreshold) {
+ layout.terminal.close()
+ }
+ }
+
+ document.addEventListener("mousemove", onMouseMove)
+ document.addEventListener("mouseup", onMouseUp)
+ }}
+ />
From b3b95fb8cb536a60d369bc448594b762b7784483 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Thu, 4 Dec 2025 15:05:50 -0600
Subject: [PATCH 09/10] fix(desktop): max pane sizes
---
packages/desktop/src/pages/layout.tsx | 40 +++++++++++++++++++++++++-
packages/desktop/src/pages/session.tsx | 4 +--
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 377545ab9f5c..106a2e733fbe 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -106,12 +106,50 @@ export default function Layout(props: ParentProps) {
+
+ {
+ e.preventDefault()
+ const startX = e.clientX
+ const startWidth = layout.sidebar.width()
+ const maxWidth = window.innerWidth * 0.3
+ const minWidth = 150
+ const collapseThreshold = 80
+ let currentWidth = startWidth
+
+ document.body.style.userSelect = "none"
+ document.body.style.overflow = "hidden"
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const deltaX = moveEvent.clientX - startX
+ currentWidth = startWidth + deltaX
+ const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth))
+ layout.sidebar.resize(clampedWidth)
+ }
+
+ const onMouseUp = () => {
+ document.body.style.userSelect = ""
+ document.body.style.overflow = ""
+ document.removeEventListener("mousemove", onMouseMove)
+ document.removeEventListener("mouseup", onMouseUp)
+
+ if (currentWidth < collapseThreshold) {
+ layout.sidebar.close()
+ }
+ }
+
+ document.addEventListener("mousemove", onMouseMove)
+ document.addEventListener("mouseup", onMouseUp)
+ }}
+ />
+