From 794fe8f381c846f5241800363023d892c12cf495 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 22 Dec 2025 19:38:50 -0600
Subject: chore: rename packages/desktop -> packages/app
---
bun.lock | 114 +-
infra/app.ts | 9 +
infra/desktop.ts | 2 +-
packages/app/.gitignore | 1 +
packages/app/AGENTS.md | 28 +
packages/app/README.md | 34 +
packages/app/bunfig.toml | 2 +
packages/app/happydom.ts | 75 ++
packages/app/index.html | 28 +
packages/app/package.json | 62 ++
packages/app/public/apple-touch-icon.png | 1 +
packages/app/public/favicon-96x96.png | 1 +
packages/app/public/favicon.ico | 1 +
packages/app/public/favicon.svg | 1 +
packages/app/public/site.webmanifest | 1 +
packages/app/public/social-share-zen.png | 1 +
packages/app/public/social-share.png | 1 +
packages/app/public/web-app-manifest-192x192.png | 1 +
packages/app/public/web-app-manifest-512x512.png | 1 +
packages/app/src/addons/serialize.test.ts | 272 +++++
packages/app/src/addons/serialize.ts | 595 ++++++++++
packages/app/src/app.tsx | 92 ++
.../app/src/components/dialog-connect-provider.tsx | 383 +++++++
.../app/src/components/dialog-manage-models.tsx | 57 +
packages/app/src/components/dialog-select-file.tsx | 48 +
.../src/components/dialog-select-model-unpaid.tsx | 110 ++
.../app/src/components/dialog-select-model.tsx | 83 ++
.../app/src/components/dialog-select-provider.tsx | 54 +
packages/app/src/components/file-tree.tsx | 112 ++
packages/app/src/components/header.tsx | 209 ++++
packages/app/src/components/link.tsx | 17 +
packages/app/src/components/prompt-input.tsx | 1153 ++++++++++++++++++++
.../app/src/components/session-context-usage.tsx | 64 ++
packages/app/src/components/terminal.tsx | 160 +++
packages/app/src/context/command.tsx | 243 +++++
packages/app/src/context/global-sdk.tsx | 34 +
packages/app/src/context/global-sync.tsx | 376 +++++++
packages/app/src/context/layout.tsx | 260 +++++
packages/app/src/context/local.tsx | 548 ++++++++++
packages/app/src/context/notification.tsx | 127 +++
packages/app/src/context/platform.tsx | 41 +
packages/app/src/context/prompt.tsx | 111 ++
packages/app/src/context/sdk.tsx | 30 +
packages/app/src/context/sync.tsx | 114 ++
packages/app/src/context/terminal.tsx | 105 ++
packages/app/src/custom-elements.d.ts | 1 +
packages/app/src/entry.tsx | 30 +
packages/app/src/env.d.ts | 8 +
packages/app/src/hooks/use-providers.ts | 31 +
packages/app/src/index.css | 7 +
packages/app/src/index.ts | 2 +
packages/app/src/pages/directory-layout.tsx | 31 +
packages/app/src/pages/error.tsx | 155 +++
packages/app/src/pages/home.tsx | 93 ++
packages/app/src/pages/layout.tsx | 904 +++++++++++++++
packages/app/src/pages/session.tsx | 927 ++++++++++++++++
packages/app/src/sst-env.d.ts | 10 +
packages/app/src/utils/dom.ts | 51 +
packages/app/src/utils/id.ts | 99 ++
packages/app/src/utils/index.ts | 1 +
packages/app/src/utils/persist.ts | 26 +
packages/app/src/utils/prompt.ts | 47 +
packages/app/src/utils/solid-dnd.tsx | 55 +
packages/app/src/utils/speech.ts | 302 +++++
packages/app/sst-env.d.ts | 9 +
packages/app/tsconfig.json | 24 +
packages/app/vite.config.ts | 15 +
packages/app/vite.js | 26 +
packages/desktop/.gitignore | 1 -
packages/desktop/AGENTS.md | 28 -
packages/desktop/README.md | 34 -
packages/desktop/bunfig.toml | 2 -
packages/desktop/happydom.ts | 75 --
packages/desktop/index.html | 28 -
packages/desktop/package.json | 62 --
packages/desktop/public/apple-touch-icon.png | 1 -
packages/desktop/public/favicon-96x96.png | 1 -
packages/desktop/public/favicon.ico | 1 -
packages/desktop/public/favicon.svg | 1 -
packages/desktop/public/site.webmanifest | 1 -
packages/desktop/public/social-share-zen.png | 1 -
packages/desktop/public/social-share.png | 1 -
.../desktop/public/web-app-manifest-192x192.png | 1 -
.../desktop/public/web-app-manifest-512x512.png | 1 -
packages/desktop/src/addons/serialize.test.ts | 272 -----
packages/desktop/src/addons/serialize.ts | 595 ----------
packages/desktop/src/app.tsx | 92 --
.../src/components/dialog-connect-provider.tsx | 383 -------
.../src/components/dialog-manage-models.tsx | 57 -
.../desktop/src/components/dialog-select-file.tsx | 48 -
.../src/components/dialog-select-model-unpaid.tsx | 110 --
.../desktop/src/components/dialog-select-model.tsx | 83 --
.../src/components/dialog-select-provider.tsx | 54 -
packages/desktop/src/components/file-tree.tsx | 112 --
packages/desktop/src/components/header.tsx | 209 ----
packages/desktop/src/components/link.tsx | 17 -
packages/desktop/src/components/prompt-input.tsx | 1153 --------------------
.../src/components/session-context-usage.tsx | 64 --
packages/desktop/src/components/terminal.tsx | 160 ---
packages/desktop/src/context/command.tsx | 243 -----
packages/desktop/src/context/global-sdk.tsx | 34 -
packages/desktop/src/context/global-sync.tsx | 376 -------
packages/desktop/src/context/layout.tsx | 260 -----
packages/desktop/src/context/local.tsx | 548 ----------
packages/desktop/src/context/notification.tsx | 127 ---
packages/desktop/src/context/platform.tsx | 41 -
packages/desktop/src/context/prompt.tsx | 111 --
packages/desktop/src/context/sdk.tsx | 30 -
packages/desktop/src/context/sync.tsx | 114 --
packages/desktop/src/context/terminal.tsx | 105 --
packages/desktop/src/custom-elements.d.ts | 1 -
packages/desktop/src/entry.tsx | 30 -
packages/desktop/src/env.d.ts | 8 -
packages/desktop/src/hooks/use-providers.ts | 31 -
packages/desktop/src/index.css | 7 -
packages/desktop/src/index.ts | 2 -
packages/desktop/src/pages/directory-layout.tsx | 31 -
packages/desktop/src/pages/error.tsx | 155 ---
packages/desktop/src/pages/home.tsx | 93 --
packages/desktop/src/pages/layout.tsx | 904 ---------------
packages/desktop/src/pages/session.tsx | 927 ----------------
packages/desktop/src/sst-env.d.ts | 10 -
packages/desktop/src/utils/dom.ts | 51 -
packages/desktop/src/utils/id.ts | 99 --
packages/desktop/src/utils/index.ts | 1 -
packages/desktop/src/utils/persist.ts | 26 -
packages/desktop/src/utils/prompt.ts | 47 -
packages/desktop/src/utils/solid-dnd.tsx | 55 -
packages/desktop/src/utils/speech.ts | 302 -----
packages/desktop/sst-env.d.ts | 9 -
packages/desktop/tsconfig.json | 24 -
packages/desktop/vite.config.ts | 15 -
packages/desktop/vite.js | 26 -
packages/opencode/src/server/server.ts | 4 +-
packages/tauri/package.json | 2 +-
packages/tauri/src/index.tsx | 2 +-
packages/tauri/tsconfig.json | 2 +-
packages/tauri/vite.config.ts | 4 +-
script/publish-start.ts | 4 +-
139 files changed, 8568 insertions(+), 8557 deletions(-)
create mode 100644 packages/app/.gitignore
create mode 100644 packages/app/AGENTS.md
create mode 100644 packages/app/README.md
create mode 100644 packages/app/bunfig.toml
create mode 100644 packages/app/happydom.ts
create mode 100644 packages/app/index.html
create mode 100644 packages/app/package.json
create mode 120000 packages/app/public/apple-touch-icon.png
create mode 120000 packages/app/public/favicon-96x96.png
create mode 120000 packages/app/public/favicon.ico
create mode 120000 packages/app/public/favicon.svg
create mode 120000 packages/app/public/site.webmanifest
create mode 120000 packages/app/public/social-share-zen.png
create mode 120000 packages/app/public/social-share.png
create mode 120000 packages/app/public/web-app-manifest-192x192.png
create mode 120000 packages/app/public/web-app-manifest-512x512.png
create mode 100644 packages/app/src/addons/serialize.test.ts
create mode 100644 packages/app/src/addons/serialize.ts
create mode 100644 packages/app/src/app.tsx
create mode 100644 packages/app/src/components/dialog-connect-provider.tsx
create mode 100644 packages/app/src/components/dialog-manage-models.tsx
create mode 100644 packages/app/src/components/dialog-select-file.tsx
create mode 100644 packages/app/src/components/dialog-select-model-unpaid.tsx
create mode 100644 packages/app/src/components/dialog-select-model.tsx
create mode 100644 packages/app/src/components/dialog-select-provider.tsx
create mode 100644 packages/app/src/components/file-tree.tsx
create mode 100644 packages/app/src/components/header.tsx
create mode 100644 packages/app/src/components/link.tsx
create mode 100644 packages/app/src/components/prompt-input.tsx
create mode 100644 packages/app/src/components/session-context-usage.tsx
create mode 100644 packages/app/src/components/terminal.tsx
create mode 100644 packages/app/src/context/command.tsx
create mode 100644 packages/app/src/context/global-sdk.tsx
create mode 100644 packages/app/src/context/global-sync.tsx
create mode 100644 packages/app/src/context/layout.tsx
create mode 100644 packages/app/src/context/local.tsx
create mode 100644 packages/app/src/context/notification.tsx
create mode 100644 packages/app/src/context/platform.tsx
create mode 100644 packages/app/src/context/prompt.tsx
create mode 100644 packages/app/src/context/sdk.tsx
create mode 100644 packages/app/src/context/sync.tsx
create mode 100644 packages/app/src/context/terminal.tsx
create mode 120000 packages/app/src/custom-elements.d.ts
create mode 100644 packages/app/src/entry.tsx
create mode 100644 packages/app/src/env.d.ts
create mode 100644 packages/app/src/hooks/use-providers.ts
create mode 100644 packages/app/src/index.css
create mode 100644 packages/app/src/index.ts
create mode 100644 packages/app/src/pages/directory-layout.tsx
create mode 100644 packages/app/src/pages/error.tsx
create mode 100644 packages/app/src/pages/home.tsx
create mode 100644 packages/app/src/pages/layout.tsx
create mode 100644 packages/app/src/pages/session.tsx
create mode 100644 packages/app/src/sst-env.d.ts
create mode 100644 packages/app/src/utils/dom.ts
create mode 100644 packages/app/src/utils/id.ts
create mode 100644 packages/app/src/utils/index.ts
create mode 100644 packages/app/src/utils/persist.ts
create mode 100644 packages/app/src/utils/prompt.ts
create mode 100644 packages/app/src/utils/solid-dnd.tsx
create mode 100644 packages/app/src/utils/speech.ts
create mode 100644 packages/app/sst-env.d.ts
create mode 100644 packages/app/tsconfig.json
create mode 100644 packages/app/vite.config.ts
create mode 100644 packages/app/vite.js
delete mode 100644 packages/desktop/.gitignore
delete mode 100644 packages/desktop/AGENTS.md
delete mode 100644 packages/desktop/README.md
delete mode 100644 packages/desktop/bunfig.toml
delete mode 100644 packages/desktop/happydom.ts
delete mode 100644 packages/desktop/index.html
delete mode 100644 packages/desktop/package.json
delete mode 120000 packages/desktop/public/apple-touch-icon.png
delete mode 120000 packages/desktop/public/favicon-96x96.png
delete mode 120000 packages/desktop/public/favicon.ico
delete mode 120000 packages/desktop/public/favicon.svg
delete mode 120000 packages/desktop/public/site.webmanifest
delete mode 120000 packages/desktop/public/social-share-zen.png
delete mode 120000 packages/desktop/public/social-share.png
delete mode 120000 packages/desktop/public/web-app-manifest-192x192.png
delete mode 120000 packages/desktop/public/web-app-manifest-512x512.png
delete mode 100644 packages/desktop/src/addons/serialize.test.ts
delete mode 100644 packages/desktop/src/addons/serialize.ts
delete mode 100644 packages/desktop/src/app.tsx
delete mode 100644 packages/desktop/src/components/dialog-connect-provider.tsx
delete mode 100644 packages/desktop/src/components/dialog-manage-models.tsx
delete mode 100644 packages/desktop/src/components/dialog-select-file.tsx
delete mode 100644 packages/desktop/src/components/dialog-select-model-unpaid.tsx
delete mode 100644 packages/desktop/src/components/dialog-select-model.tsx
delete mode 100644 packages/desktop/src/components/dialog-select-provider.tsx
delete mode 100644 packages/desktop/src/components/file-tree.tsx
delete mode 100644 packages/desktop/src/components/header.tsx
delete mode 100644 packages/desktop/src/components/link.tsx
delete mode 100644 packages/desktop/src/components/prompt-input.tsx
delete mode 100644 packages/desktop/src/components/session-context-usage.tsx
delete mode 100644 packages/desktop/src/components/terminal.tsx
delete mode 100644 packages/desktop/src/context/command.tsx
delete mode 100644 packages/desktop/src/context/global-sdk.tsx
delete mode 100644 packages/desktop/src/context/global-sync.tsx
delete mode 100644 packages/desktop/src/context/layout.tsx
delete mode 100644 packages/desktop/src/context/local.tsx
delete mode 100644 packages/desktop/src/context/notification.tsx
delete mode 100644 packages/desktop/src/context/platform.tsx
delete mode 100644 packages/desktop/src/context/prompt.tsx
delete mode 100644 packages/desktop/src/context/sdk.tsx
delete mode 100644 packages/desktop/src/context/sync.tsx
delete mode 100644 packages/desktop/src/context/terminal.tsx
delete mode 120000 packages/desktop/src/custom-elements.d.ts
delete mode 100644 packages/desktop/src/entry.tsx
delete mode 100644 packages/desktop/src/env.d.ts
delete mode 100644 packages/desktop/src/hooks/use-providers.ts
delete mode 100644 packages/desktop/src/index.css
delete mode 100644 packages/desktop/src/index.ts
delete mode 100644 packages/desktop/src/pages/directory-layout.tsx
delete mode 100644 packages/desktop/src/pages/error.tsx
delete mode 100644 packages/desktop/src/pages/home.tsx
delete mode 100644 packages/desktop/src/pages/layout.tsx
delete mode 100644 packages/desktop/src/pages/session.tsx
delete mode 100644 packages/desktop/src/sst-env.d.ts
delete mode 100644 packages/desktop/src/utils/dom.ts
delete mode 100644 packages/desktop/src/utils/id.ts
delete mode 100644 packages/desktop/src/utils/index.ts
delete mode 100644 packages/desktop/src/utils/persist.ts
delete mode 100644 packages/desktop/src/utils/prompt.ts
delete mode 100644 packages/desktop/src/utils/solid-dnd.tsx
delete mode 100644 packages/desktop/src/utils/speech.ts
delete mode 100644 packages/desktop/sst-env.d.ts
delete mode 100644 packages/desktop/tsconfig.json
delete mode 100644 packages/desktop/vite.config.ts
delete mode 100644 packages/desktop/vite.js
diff --git a/bun.lock b/bun.lock
index dee942825..55d5d46ec 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,6 +27,54 @@
"turbo": "2.5.6",
},
},
+ "packages/app": {
+ "name": "@opencode-ai/app",
+ "version": "1.0.190",
+ "dependencies": {
+ "@kobalte/core": "catalog:",
+ "@opencode-ai/sdk": "workspace:*",
+ "@opencode-ai/ui": "workspace:*",
+ "@opencode-ai/util": "workspace:*",
+ "@shikijs/transformers": "3.9.2",
+ "@solid-primitives/active-element": "2.1.3",
+ "@solid-primitives/audio": "1.4.2",
+ "@solid-primitives/event-bus": "1.1.2",
+ "@solid-primitives/media": "2.3.3",
+ "@solid-primitives/resize-observer": "2.1.3",
+ "@solid-primitives/scroll": "2.1.3",
+ "@solid-primitives/storage": "catalog:",
+ "@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",
+ "remeda": "catalog:",
+ "shiki": "3.9.2",
+ "solid-js": "catalog:",
+ "solid-list": "catalog:",
+ "tailwindcss": "catalog:",
+ "virtua": "catalog:",
+ "zod": "catalog:",
+ },
+ "devDependencies": {
+ "@happy-dom/global-registrator": "20.0.11",
+ "@tailwindcss/vite": "catalog:",
+ "@tsconfig/bun": "1.0.9",
+ "@types/bun": "catalog:",
+ "@types/luxon": "catalog:",
+ "@types/node": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:",
+ "vite-plugin-icons-spritesheet": "3.0.1",
+ "vite-plugin-solid": "catalog:",
+ },
+ },
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.190",
@@ -130,54 +178,6 @@
"cloudflare": "5.2.0",
},
},
- "packages/desktop": {
- "name": "@opencode-ai/desktop",
- "version": "1.0.190",
- "dependencies": {
- "@kobalte/core": "catalog:",
- "@opencode-ai/sdk": "workspace:*",
- "@opencode-ai/ui": "workspace:*",
- "@opencode-ai/util": "workspace:*",
- "@shikijs/transformers": "3.9.2",
- "@solid-primitives/active-element": "2.1.3",
- "@solid-primitives/audio": "1.4.2",
- "@solid-primitives/event-bus": "1.1.2",
- "@solid-primitives/media": "2.3.3",
- "@solid-primitives/resize-observer": "2.1.3",
- "@solid-primitives/scroll": "2.1.3",
- "@solid-primitives/storage": "catalog:",
- "@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",
- "remeda": "catalog:",
- "shiki": "3.9.2",
- "solid-js": "catalog:",
- "solid-list": "catalog:",
- "tailwindcss": "catalog:",
- "virtua": "catalog:",
- "zod": "catalog:",
- },
- "devDependencies": {
- "@happy-dom/global-registrator": "20.0.11",
- "@tailwindcss/vite": "catalog:",
- "@tsconfig/bun": "1.0.9",
- "@types/bun": "catalog:",
- "@types/luxon": "catalog:",
- "@types/node": "catalog:",
- "@typescript/native-preview": "catalog:",
- "typescript": "catalog:",
- "vite": "catalog:",
- "vite-plugin-icons-spritesheet": "3.0.1",
- "vite-plugin-solid": "catalog:",
- },
- },
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.190",
@@ -365,7 +365,7 @@
"name": "@opencode-ai/tauri",
"version": "1.0.190",
"dependencies": {
- "@opencode-ai/desktop": "workspace:*",
+ "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -1148,6 +1148,8 @@
"@openauthjs/openauth": ["@openauthjs/openauth@0.0.0-20250322224806", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-p5IWSRXvABcwocH2dNI0w8c1QJelIOFulwhKk+aLLFfUbs8u1pr7kQbYe8yCSM2+bcLHiwbogpUQc2ovrGwCuw=="],
+ "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"],
+
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1158,8 +1160,6 @@
"@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"],
- "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
-
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -2068,7 +2068,7 @@
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
- "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -3982,6 +3982,8 @@
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
+ "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
@@ -4028,6 +4030,8 @@
"@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+ "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"@jsx-email/cli/tailwindcss": ["tailwindcss@3.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w=="],
@@ -4208,8 +4212,6 @@
"body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
- "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
-
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -4228,6 +4230,8 @@
"es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+ "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
@@ -4378,8 +4382,6 @@
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
- "vite-plugin-icons-spritesheet/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
-
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "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-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
diff --git a/infra/app.ts b/infra/app.ts
index 7215995ba..da4ac45b8 100644
--- a/infra/app.ts
+++ b/infra/app.ts
@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
VITE_API_URL: api.url.apply((url) => url!),
},
})
+
+new sst.cloudflare.StaticSite("App", {
+ domain: "app." + domain,
+ path: "packages/app",
+ build: {
+ command: "bun turbo build",
+ output: "./dist",
+ },
+})
diff --git a/infra/desktop.ts b/infra/desktop.ts
index d4e32c65d..5c4155cc9 100644
--- a/infra/desktop.ts
+++ b/infra/desktop.ts
@@ -2,7 +2,7 @@ import { domain } from "./stage"
new sst.cloudflare.StaticSite("Desktop", {
domain: "desktop." + domain,
- path: "packages/desktop",
+ path: "packages/app",
build: {
command: "bun turbo build",
output: "./dist",
diff --git a/packages/app/.gitignore b/packages/app/.gitignore
new file mode 100644
index 000000000..4a20d55a7
--- /dev/null
+++ b/packages/app/.gitignore
@@ -0,0 +1 @@
+src/assets/theme.css
diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md
new file mode 100644
index 000000000..3137bddc2
--- /dev/null
+++ b/packages/app/AGENTS.md
@@ -0,0 +1,28 @@
+# Agent Guidelines for @opencode/app
+
+## Build/Test Commands
+
+- **Development**: `bun run dev` (starts Vite dev server on port 3000)
+- **Build**: `bun run build` (production build)
+- **Preview**: `bun run serve` (preview production build)
+- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
+- **Testing**: Do not create or run automated tests
+
+## Code Style
+
+- **Framework**: SolidJS with TypeScript
+- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
+- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
+- **Components**: Use function declarations, splitProps for component props
+- **Types**: Define interfaces for component props, avoid `any` type
+- **CSS**: TailwindCSS with custom CSS variables theme system
+- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
+- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
+
+## Key Dependencies
+
+- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
+- TailwindCSS 4.x with @tailwindcss/vite
+- Custom theme system with CSS variables
+
+No special rules files found.
diff --git a/packages/app/README.md b/packages/app/README.md
new file mode 100644
index 000000000..6a1764536
--- /dev/null
+++ b/packages/app/README.md
@@ -0,0 +1,34 @@
+## Usage
+
+Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
+
+This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
+
+```bash
+$ npm install # or pnpm install or yarn install
+```
+
+### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `npm run dev` or `npm start`
+
+Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.
+
+### `npm run build`
+
+Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.
+Your app is ready to be deployed!
+
+## Deployment
+
+You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
diff --git a/packages/app/bunfig.toml b/packages/app/bunfig.toml
new file mode 100644
index 000000000..363990451
--- /dev/null
+++ b/packages/app/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./happydom.ts"]
diff --git a/packages/app/happydom.ts b/packages/app/happydom.ts
new file mode 100644
index 000000000..de726718f
--- /dev/null
+++ b/packages/app/happydom.ts
@@ -0,0 +1,75 @@
+import { GlobalRegistrator } from "@happy-dom/global-registrator"
+
+GlobalRegistrator.register()
+
+const originalGetContext = HTMLCanvasElement.prototype.getContext
+// @ts-expect-error - we're overriding with a simplified mock
+HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) {
+ if (contextType === "2d") {
+ return {
+ canvas: this,
+ fillStyle: "#000000",
+ strokeStyle: "#000000",
+ font: "12px monospace",
+ textAlign: "start",
+ textBaseline: "alphabetic",
+ globalAlpha: 1,
+ globalCompositeOperation: "source-over",
+ imageSmoothingEnabled: true,
+ lineWidth: 1,
+ lineCap: "butt",
+ lineJoin: "miter",
+ miterLimit: 10,
+ shadowBlur: 0,
+ shadowColor: "rgba(0, 0, 0, 0)",
+ shadowOffsetX: 0,
+ shadowOffsetY: 0,
+ fillRect: () => {},
+ strokeRect: () => {},
+ clearRect: () => {},
+ fillText: () => {},
+ strokeText: () => {},
+ measureText: (text: string) => ({ width: text.length * 8 }),
+ drawImage: () => {},
+ save: () => {},
+ restore: () => {},
+ scale: () => {},
+ rotate: () => {},
+ translate: () => {},
+ transform: () => {},
+ setTransform: () => {},
+ resetTransform: () => {},
+ createLinearGradient: () => ({ addColorStop: () => {} }),
+ createRadialGradient: () => ({ addColorStop: () => {} }),
+ createPattern: () => null,
+ beginPath: () => {},
+ closePath: () => {},
+ moveTo: () => {},
+ lineTo: () => {},
+ bezierCurveTo: () => {},
+ quadraticCurveTo: () => {},
+ arc: () => {},
+ arcTo: () => {},
+ ellipse: () => {},
+ rect: () => {},
+ fill: () => {},
+ stroke: () => {},
+ clip: () => {},
+ isPointInPath: () => false,
+ isPointInStroke: () => false,
+ getTransform: () => ({}),
+ getImageData: () => ({
+ data: new Uint8ClampedArray(0),
+ width: 0,
+ height: 0,
+ }),
+ putImageData: () => {},
+ createImageData: () => ({
+ data: new Uint8ClampedArray(0),
+ width: 0,
+ height: 0,
+ }),
+ } as unknown as CanvasRenderingContext2D
+ }
+ return originalGetContext.call(this, contextType as "2d", _options)
+}
diff --git a/packages/app/index.html b/packages/app/index.html
new file mode 100644
index 000000000..9803517a0
--- /dev/null
+++ b/packages/app/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ OpenCode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/package.json b/packages/app/package.json
new file mode 100644
index 000000000..f1f5aef50
--- /dev/null
+++ b/packages/app/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@opencode-ai/app",
+ "version": "1.0.190",
+ "description": "",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./vite": "./vite.js"
+ },
+ "scripts": {
+ "typecheck": "tsgo -b",
+ "start": "vite",
+ "dev": "vite",
+ "build": "vite build",
+ "serve": "vite preview"
+ },
+ "license": "MIT",
+ "devDependencies": {
+ "@happy-dom/global-registrator": "20.0.11",
+ "@tailwindcss/vite": "catalog:",
+ "@tsconfig/bun": "1.0.9",
+ "@types/bun": "catalog:",
+ "@types/luxon": "catalog:",
+ "@types/node": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:",
+ "vite-plugin-icons-spritesheet": "3.0.1",
+ "vite-plugin-solid": "catalog:"
+ },
+ "dependencies": {
+ "@kobalte/core": "catalog:",
+ "@opencode-ai/sdk": "workspace:*",
+ "@opencode-ai/ui": "workspace:*",
+ "@opencode-ai/util": "workspace:*",
+ "@shikijs/transformers": "3.9.2",
+ "@solid-primitives/active-element": "2.1.3",
+ "@solid-primitives/audio": "1.4.2",
+ "@solid-primitives/event-bus": "1.1.2",
+ "@solid-primitives/media": "2.3.3",
+ "@solid-primitives/resize-observer": "2.1.3",
+ "@solid-primitives/scroll": "2.1.3",
+ "@solid-primitives/storage": "catalog:",
+ "@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",
+ "remeda": "catalog:",
+ "shiki": "3.9.2",
+ "solid-js": "catalog:",
+ "solid-list": "catalog:",
+ "tailwindcss": "catalog:",
+ "virtua": "catalog:",
+ "zod": "catalog:"
+ }
+}
diff --git a/packages/app/public/apple-touch-icon.png b/packages/app/public/apple-touch-icon.png
new file mode 120000
index 000000000..fb6e8b170
--- /dev/null
+++ b/packages/app/public/apple-touch-icon.png
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/apple-touch-icon.png
\ No newline at end of file
diff --git a/packages/app/public/favicon-96x96.png b/packages/app/public/favicon-96x96.png
new file mode 120000
index 000000000..155c5ed2f
--- /dev/null
+++ b/packages/app/public/favicon-96x96.png
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/favicon-96x96.png
\ No newline at end of file
diff --git a/packages/app/public/favicon.ico b/packages/app/public/favicon.ico
new file mode 120000
index 000000000..1c90f01b1
--- /dev/null
+++ b/packages/app/public/favicon.ico
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/favicon.ico
\ No newline at end of file
diff --git a/packages/app/public/favicon.svg b/packages/app/public/favicon.svg
new file mode 120000
index 000000000..80804d257
--- /dev/null
+++ b/packages/app/public/favicon.svg
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/favicon.svg
\ No newline at end of file
diff --git a/packages/app/public/site.webmanifest b/packages/app/public/site.webmanifest
new file mode 120000
index 000000000..a116d7879
--- /dev/null
+++ b/packages/app/public/site.webmanifest
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/site.webmanifest
\ No newline at end of file
diff --git a/packages/app/public/social-share-zen.png b/packages/app/public/social-share-zen.png
new file mode 120000
index 000000000..02f205fc5
--- /dev/null
+++ b/packages/app/public/social-share-zen.png
@@ -0,0 +1 @@
+../../ui/src/assets/images/social-share-zen.png
\ No newline at end of file
diff --git a/packages/app/public/social-share.png b/packages/app/public/social-share.png
new file mode 120000
index 000000000..88bf2d4c6
--- /dev/null
+++ b/packages/app/public/social-share.png
@@ -0,0 +1 @@
+../../ui/src/assets/images/social-share.png
\ No newline at end of file
diff --git a/packages/app/public/web-app-manifest-192x192.png b/packages/app/public/web-app-manifest-192x192.png
new file mode 120000
index 000000000..8cfdf8ca5
--- /dev/null
+++ b/packages/app/public/web-app-manifest-192x192.png
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/web-app-manifest-192x192.png
\ No newline at end of file
diff --git a/packages/app/public/web-app-manifest-512x512.png b/packages/app/public/web-app-manifest-512x512.png
new file mode 120000
index 000000000..4165998e6
--- /dev/null
+++ b/packages/app/public/web-app-manifest-512x512.png
@@ -0,0 +1 @@
+../../ui/src/assets/favicon/web-app-manifest-512x512.png
\ No newline at end of file
diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts
new file mode 100644
index 000000000..ad165f43f
--- /dev/null
+++ b/packages/app/src/addons/serialize.test.ts
@@ -0,0 +1,272 @@
+import { describe, test, expect, beforeAll, afterEach } from "bun:test"
+import { Terminal, Ghostty } from "ghostty-web"
+import { SerializeAddon } from "./serialize"
+
+let ghostty: Ghostty
+beforeAll(async () => {
+ ghostty = await Ghostty.load()
+})
+
+const terminals: Terminal[] = []
+
+afterEach(() => {
+ for (const term of terminals) {
+ term.dispose()
+ }
+ terminals.length = 0
+ document.body.innerHTML = ""
+})
+
+function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } {
+ const container = document.createElement("div")
+ document.body.appendChild(container)
+
+ const term = new Terminal({ cols, rows, ghostty })
+ const addon = new SerializeAddon()
+ term.loadAddon(addon)
+ term.open(container)
+ terminals.push(term)
+
+ return { term, addon, container }
+}
+
+function writeAndWait(term: Terminal, data: string): Promise {
+ return new Promise((resolve) => {
+ term.write(data, resolve)
+ })
+}
+
+describe("SerializeAddon", () => {
+ describe("ANSI color preservation", () => {
+ test("should preserve text attributes (bold, italic, underline)", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ expect(origLine!.getCell(0)!.isBold()).toBe(1)
+ expect(origLine!.getCell(5)!.isItalic()).toBe(1)
+ expect(origLine!.getCell(12)!.isUnderline()).toBe(1)
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+
+ const boldCell = line!.getCell(0)
+ expect(boldCell!.getChars()).toBe("B")
+ expect(boldCell!.isBold()).toBe(1)
+
+ const italicCell = line!.getCell(5)
+ expect(italicCell!.getChars()).toBe("I")
+ expect(italicCell!.isItalic()).toBe(1)
+
+ const underCell = line!.getCell(12)
+ expect(underCell!.getChars()).toBe("U")
+ expect(underCell!.isUnderline()).toBe(1)
+ })
+
+ test("should preserve basic 16-color foreground colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedFg = origLine!.getCell(0)!.getFgColor()
+ const origGreenFg = origLine!.getCell(3)!.getFgColor()
+ const origBlueFg = origLine!.getCell(8)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ expect(line).toBeDefined()
+
+ const redCell = line!.getCell(0)
+ expect(redCell!.getChars()).toBe("R")
+ expect(redCell!.getFgColor()).toBe(origRedFg)
+
+ const greenCell = line!.getCell(3)
+ expect(greenCell!.getChars()).toBe("G")
+ expect(greenCell!.getFgColor()).toBe(origGreenFg)
+
+ const blueCell = line!.getCell(8)
+ expect(blueCell!.getChars()).toBe("B")
+ expect(blueCell!.getFgColor()).toBe(origBlueFg)
+ })
+
+ test("should preserve 256-color palette colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedFg = origLine!.getCell(0)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const redCell = line!.getCell(0)
+ expect(redCell!.getChars()).toBe("R")
+ expect(redCell!.getFgColor()).toBe(origRedFg)
+ })
+
+ test("should preserve RGB/truecolor colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRgbFg = origLine!.getCell(0)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const rgbCell = line!.getCell(0)
+ expect(rgbCell!.getChars()).toBe("R")
+ expect(rgbCell!.getFgColor()).toBe(origRgbFg)
+ })
+
+ test("should preserve background colors", async () => {
+ const { term, addon } = createTerminal()
+
+ const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL"
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origRedBg = origLine!.getCell(0)!.getBgColor()
+ const origGreenBg = origLine!.getCell(6)!.getBgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const line = term2.buffer.active.getLine(0)
+
+ const redBgCell = line!.getCell(0)
+ expect(redBgCell!.getChars()).toBe("R")
+ expect(redBgCell!.getBgColor()).toBe(origRedBg)
+
+ const greenBgCell = line!.getCell(6)
+ expect(greenBgCell!.getChars()).toBe("G")
+ expect(greenBgCell!.getBgColor()).toBe(origGreenBg)
+ })
+
+ test("should handle combined colors and attributes", async () => {
+ const { term, addon } = createTerminal()
+
+ const input =
+ "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL "
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origFg = origLine!.getCell(0)!.getFgColor()
+ const origBg = origLine!.getCell(0)!.getBgColor()
+ expect(origLine!.getCell(0)!.isBold()).toBe(1)
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+ const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "")
+
+ expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true)
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, cleanSerialized)
+
+ const line = term2.buffer.active.getLine(0)
+ const comboCell = line!.getCell(0)
+
+ expect(comboCell!.getChars()).toBe("C")
+ expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m")
+ })
+ })
+
+ describe("round-trip serialization", () => {
+ test("should not produce ECH sequences", async () => {
+ const { term, addon } = createTerminal()
+
+ await writeAndWait(term, "\x1b[31mHello\x1b[0m World")
+
+ const serialized = addon.serialize()
+
+ const hasECH = /\x1b\[\d+X/.test(serialized)
+ expect(hasECH).toBe(false)
+ })
+
+ test("multi-line content should not have garbage characters", async () => {
+ const { term, addon } = createTerminal()
+
+ const content = [
+ "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
+ "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
+ "total 42",
+ ].join("\r\n")
+
+ await writeAndWait(term, content)
+
+ const serialized = addon.serialize()
+
+ expect(/\x1b\[\d+X/.test(serialized)).toBe(false)
+
+ const { term: term2 } = createTerminal()
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ for (let row = 0; row < 3; row++) {
+ const line = term2.buffer.active.getLine(row)?.translateToString(true)
+ expect(line?.includes("𑼝")).toBe(false)
+ }
+
+ expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
+ expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
+ expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
+ })
+
+ test("serialized output written to new terminal should match original colors", async () => {
+ const { term, addon } = createTerminal(40, 5)
+
+ const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! "
+ await writeAndWait(term, input)
+
+ const origLine = term.buffer.active.getLine(0)
+ const origHelloFg = origLine!.getCell(0)!.getFgColor()
+ const origWorldFg = origLine!.getCell(6)!.getFgColor()
+
+ const serialized = addon.serialize({ range: { start: 0, end: 0 } })
+
+ const { term: term2 } = createTerminal(40, 5)
+ terminals.push(term2)
+ await writeAndWait(term2, serialized)
+
+ const newLine = term2.buffer.active.getLine(0)
+
+ expect(newLine!.getCell(0)!.getChars()).toBe("H")
+ expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg)
+
+ expect(newLine!.getCell(6)!.getChars()).toBe("W")
+ expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg)
+
+ expect(newLine!.getCell(11)!.getChars()).toBe("!")
+ })
+ })
+})
diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts
new file mode 100644
index 000000000..cb1ff8442
--- /dev/null
+++ b/packages/app/src/addons/serialize.ts
@@ -0,0 +1,595 @@
+/**
+ * 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) {}
+
+ private _isRealContent(codepoint: number): boolean {
+ if (codepoint === 0) return false
+ if (codepoint >= 0xf000) return false
+ return true
+ }
+
+ private _findLastContentColumn(line: IBufferLine): number {
+ let lastContent = -1
+ for (let col = 0; col < line.length; col++) {
+ const cell = line.getCell(col)
+ if (cell && this._isRealContent(cell.getCode())) {
+ lastContent = col
+ }
+ }
+ return lastContent + 1
+ }
+
+ 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 maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
+ const endLineColumn = Math.min(maxColumn, 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 _firstRow: number = 0
+ private _lastCursorRow: number = 0
+ private _lastCursorCol: number = 0
+ private _lastContentCursorRow: number = 0
+ private _lastContentCursorCol: number = 0
+
+ constructor(
+ buffer: IBuffer,
+ private readonly _terminal: ITerminalCore,
+ ) {
+ super(buffer)
+ this._cursorStyle = 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 {
+ let rowSeparator = ""
+
+ if (!isLastRow) {
+ const nextLine = this._buffer.getLine(row + 1)
+
+ if (!nextLine?.isWrapped) {
+ rowSeparator = "\r\n"
+ 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 (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)
+ }
+ }
+ if (fgChanged) {
+ const color = cell.getFgColor()
+ const mode = cell.getFgColorMode()
+ if (mode === 2 || mode === 3 || mode === -1) {
+ 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 || mode === 3 || mode === -1) {
+ 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)
+ }
+ }
+ }
+ }
+
+ return sgrSeq
+ }
+
+ private _isAttributeDefault(cell: IBufferCell): boolean {
+ const mode = cell.getFgColorMode()
+ const bgMode = cell.getBgColorMode()
+
+ if (mode === 0 && bgMode === 0) {
+ return (
+ !cell.isBold() &&
+ !cell.isItalic() &&
+ !cell.isUnderline() &&
+ !cell.isBlink() &&
+ !cell.isInverse() &&
+ !cell.isInvisible() &&
+ !cell.isDim() &&
+ !cell.isStrikethrough()
+ )
+ }
+
+ const fgColor = cell.getFgColor()
+ const bgColor = cell.getBgColor()
+ const nullCell = this._buffer.getNullCell()
+ const nullFg = nullCell.getFgColor()
+ const nullBg = nullCell.getBgColor()
+
+ return (
+ fgColor === nullFg &&
+ bgColor === nullBg &&
+ !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 codepoint = cell.getCode()
+ const isGarbage = codepoint >= 0xf000
+ const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
+
+ const sgrSeq = this._diffStyle(cell, this._cursorStyle)
+
+ const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+
+ if (styleChanged) {
+ if (this._nullCellCount > 0) {
+ 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
+ }
+ }
+
+ if (isEmptyCell) {
+ this._nullCellCount += cell.getWidth()
+ } else {
+ if (this._nullCellCount > 0) {
+ 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) {
+ const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
+ const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
+ const cursorCol = this._buffer.cursorX + 1
+ 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 normalBuffer = buffer.normal || buffer.active
+ const altBuffer = buffer.alternate
+
+ if (!normalBuffer) {
+ return ""
+ }
+
+ let content = options?.range
+ ? this._serializeBufferByRange(normalBuffer, options.range, true)
+ : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
+
+ if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
+ 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/app/src/app.tsx b/packages/app/src/app.tsx
new file mode 100644
index 000000000..11216643e
--- /dev/null
+++ b/packages/app/src/app.tsx
@@ -0,0 +1,92 @@
+import "@/index.css"
+import { ErrorBoundary, Show } from "solid-js"
+import { Router, Route, Navigate } from "@solidjs/router"
+import { MetaProvider } from "@solidjs/meta"
+import { Font } from "@opencode-ai/ui/font"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { Diff } from "@opencode-ai/ui/diff"
+import { Code } from "@opencode-ai/ui/code"
+import { GlobalSyncProvider } from "@/context/global-sync"
+import { LayoutProvider } from "@/context/layout"
+import { GlobalSDKProvider } from "@/context/global-sdk"
+import { TerminalProvider } from "@/context/terminal"
+import { PromptProvider } from "@/context/prompt"
+import { NotificationProvider } from "@/context/notification"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { CommandProvider } from "@/context/command"
+import Layout from "@/pages/layout"
+import Home from "@/pages/home"
+import DirectoryLayout from "@/pages/directory-layout"
+import Session from "@/pages/session"
+import { ErrorPage } from "./pages/error"
+import { iife } from "@opencode-ai/util/iife"
+
+declare global {
+ interface Window {
+ __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
+ }
+}
+
+const url = iife(() => {
+ const param = new URLSearchParams(document.location.search).get("url")
+ if (param) return param
+
+ if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+ if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+ if (import.meta.env.DEV)
+ return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+
+ return "http://localhost:4096"
+})
+
+export function App() {
+ return (
+
+
+ }>
+
+
+
+
+
+
+
+
+ (
+
+ {props.children}
+
+ )}
+ >
+
+
+ } />
+ (
+
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
new file mode 100644
index 000000000..789a5d3b7
--- /dev/null
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -0,0 +1,383 @@
+import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { showToast } from "@opencode-ai/ui/toast"
+import { iife } from "@opencode-ai/util/iife"
+import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Link } from "@/components/link"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { DialogSelectModel } from "./dialog-select-model"
+import { DialogSelectProvider } from "./dialog-select-provider"
+
+export function DialogConnectProvider(props: { provider: string }) {
+ const dialog = useDialog()
+ const globalSync = useGlobalSync()
+ const globalSDK = useGlobalSDK()
+ const platform = usePlatform()
+ const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
+ const methods = createMemo(
+ () =>
+ globalSync.data.provider_auth[props.provider] ?? [
+ {
+ type: "api",
+ label: "API key",
+ },
+ ],
+ )
+ const [store, setStore] = createStore({
+ methodIndex: undefined as undefined | number,
+ authorization: undefined as undefined | ProviderAuthAuthorization,
+ state: "pending" as undefined | "pending" | "complete" | "error",
+ error: undefined as string | undefined,
+ })
+
+ const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
+
+ async function selectMethod(index: number) {
+ const method = methods()[index]
+ setStore(
+ produce((draft) => {
+ draft.methodIndex = index
+ draft.authorization = undefined
+ draft.state = undefined
+ draft.error = undefined
+ }),
+ )
+
+ if (method.type === "oauth") {
+ setStore("state", "pending")
+ const start = Date.now()
+ await globalSDK.client.provider.oauth
+ .authorize(
+ {
+ providerID: props.provider,
+ method: index,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => {
+ const elapsed = Date.now() - start
+ const delay = 1000 - elapsed
+
+ if (delay > 0) {
+ setTimeout(() => {
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ }, delay)
+ return
+ }
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ })
+ .catch((e) => {
+ setStore("state", "error")
+ setStore("error", String(e))
+ })
+ }
+ }
+
+ let listRef: ListRef | undefined
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
+ return
+ }
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ if (methods().length === 1) {
+ selectMethod(0)
+ }
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ async function complete() {
+ await globalSDK.client.global.dispose()
+ dialog.close()
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: `${provider().name} connected`,
+ description: `${provider().name} models are now available to use.`,
+ })
+ }
+
+ function goBack() {
+ if (methods().length === 1) {
+ dialog.show(() => )
+ return
+ }
+ if (store.authorization) {
+ setStore("authorization", undefined)
+ setStore("methodIndex", undefined)
+ return
+ }
+ if (store.methodIndex) {
+ setStore("methodIndex", undefined)
+ return
+ }
+ dialog.show(() => )
+ }
+
+ return (
+ }>
+
+
+
+
+
+
+ Login with Claude Pro/Max
+
+ Connect {provider().name}
+
+
+
+
+
+
+ Select login method for {provider().name}.
+
+
{
+ listRef = ref
+ }}
+ items={methods}
+ key={(m) => m?.label}
+ onSelect={async (method, index) => {
+ if (!method) return
+ selectMethod(index)
+ }}
+ >
+ {(i) => (
+
+ )}
+
+
+
+
+
+
+
+ Authorization in progress...
+
+
+
+
+
+
+
+ Authorization failed: {store.error}
+
+
+
+
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const apiKey = formData.get("apiKey") as string
+
+ if (!apiKey?.trim()) {
+ setFormStore("error", "API key is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ await globalSDK.client.auth.set({
+ providerID: props.provider,
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await complete()
+ }
+
+ return (
+
+
+
+
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding
+ agents.
+
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
+
+
+ Visit{" "}
+
+ opencode.ai/zen
+ {" "}
+ to collect your API key.
+
+
+
+
+
+ Enter your {provider().name} API key to connect your account and use {provider().name} models
+ in OpenCode.
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ onMount(() => {
+ if (store.authorization?.method === "code" && store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const code = formData.get("code") as string
+
+ if (!code?.trim()) {
+ setFormStore("error", "Authorization code is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ const { error } = await globalSDK.client.provider.oauth.callback({
+ providerID: props.provider,
+ method: store.methodIndex,
+ code,
+ })
+ if (!error) {
+ await complete()
+ return
+ }
+ setFormStore("error", "Invalid authorization code")
+ }
+
+ return (
+
+
+ Visit this link to collect your authorization
+ code to connect your account and use {provider().name} models in OpenCode.
+
+
+
+ )
+ })}
+
+
+ {iife(() => {
+ const code = createMemo(() => {
+ const instructions = store.authorization?.instructions
+ if (instructions?.includes(":")) {
+ return instructions?.split(":")[1]?.trim()
+ }
+ return instructions
+ })
+
+ onMount(async () => {
+ const result = await globalSDK.client.provider.oauth.callback({
+ providerID: props.provider,
+ method: store.methodIndex,
+ })
+ if (result.error) {
+ // TODO: show error
+ dialog.close()
+ return
+ }
+ await complete()
+ })
+
+ return (
+
+
+ Visit this link and enter the code below to
+ connect your account and use {provider().name} models in OpenCode.
+
+
+
+
+ Waiting for authorization...
+
+
+ )
+ })}
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx
new file mode 100644
index 000000000..66d125288
--- /dev/null
+++ b/packages/app/src/components/dialog-manage-models.tsx
@@ -0,0 +1,57 @@
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Switch } from "@opencode-ai/ui/switch"
+import type { Component } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders } from "@/hooks/use-providers"
+
+export const DialogManageModels: Component = () => {
+ const local = useLocal()
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
new file mode 100644
index 000000000..b27afdc8b
--- /dev/null
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -0,0 +1,48 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+
+export function DialogSelectFile() {
+ const layout = useLayout()
+ const local = useLocal()
+ const dialog = useDialog()
+ const params = useParams()
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx
new file mode 100644
index 000000000..24ec8092d
--- /dev/null
+++ b/packages/app/src/components/dialog-select-model-unpaid.tsx
@@ -0,0 +1,110 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Tag } from "@opencode-ai/ui/tag"
+import { type Component, onCleanup, onMount, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { DialogConnectProvider } from "./dialog-connect-provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
+
+export const DialogSelectModelUnpaid: Component = () => {
+ const local = useLocal()
+ const dialog = useDialog()
+ const providers = useProviders()
+
+ let listRef: ListRef | undefined
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
new file mode 100644
index 000000000..54783386a
--- /dev/null
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -0,0 +1,83 @@
+import { Component, createMemo, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders } from "@/hooks/use-providers"
+import { Button } from "@opencode-ai/ui/button"
+import { Tag } from "@opencode-ai/ui/tag"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogManageModels } from "./dialog-manage-models"
+
+export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+ const local = useLocal()
+ const dialog = useDialog()
+
+ const models = createMemo(() =>
+ local.model
+ .list()
+ .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
+ .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
+ )
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx
new file mode 100644
index 000000000..5bbde5d41
--- /dev/null
+++ b/packages/app/src/components/dialog-select-provider.tsx
@@ -0,0 +1,54 @@
+import { Component, Show } from "solid-js"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Tag } from "@opencode-ai/ui/tag"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogConnectProvider } from "./dialog-connect-provider"
+
+export const DialogSelectProvider: Component = () => {
+ const dialog = useDialog()
+ const providers = useProviders()
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
new file mode 100644
index 000000000..0841c71d1
--- /dev/null
+++ b/packages/app/src/components/file-tree.tsx
@@ -0,0 +1,112 @@
+import { useLocal, type LocalFile } from "@/context/local"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
+import { Dynamic } from "solid-js/web"
+
+export default function FileTree(props: {
+ path: string
+ class?: string
+ nodeClass?: string
+ level?: number
+ onFileClick?: (file: LocalFile) => void
+}) {
+ const local = useLocal()
+ const level = props.level ?? 0
+
+ const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
+ {
+ const evt = e as globalThis.DragEvent
+ evt.dataTransfer!.effectAllowed = "copy"
+ evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
+
+ // Create custom drag image without margins
+ const dragImage = document.createElement("div")
+ dragImage.className =
+ "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
+ dragImage.style.position = "absolute"
+ dragImage.style.top = "-1000px"
+
+ // Copy only the icon and text content without padding
+ const icon = e.currentTarget.querySelector("svg")
+ const text = e.currentTarget.querySelector("span")
+ if (icon && text) {
+ dragImage.innerHTML = icon.outerHTML + text.outerHTML
+ }
+
+ document.body.appendChild(dragImage)
+ evt.dataTransfer!.setDragImage(dragImage, 0, 12)
+ setTimeout(() => document.body.removeChild(dragImage), 0)
+ }}
+ {...p}
+ >
+ {p.children}
+
+ {p.node.name}
+
+
+
+
+
+ )
+
+ return (
+
+
+ {(node) => (
+
+
+
+ (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ props.onFileClick?.(node)}>
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx
new file mode 100644
index 000000000..ec7cdfa25
--- /dev/null
+++ b/packages/app/src/components/header.tsx
@@ -0,0 +1,209 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useLayout } from "@/context/layout"
+import { Session } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Popover } from "@opencode-ai/ui/popover"
+import { Select } from "@opencode-ai/ui/select"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useCommand } from "@/context/command"
+import { getFilename } from "@opencode-ai/util/path"
+import { A, useParams } from "@solidjs/router"
+import { createMemo, createResource, Show } from "solid-js"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { iife } from "@opencode-ai/util/iife"
+
+export function Header(props: {
+ navigateToProject: (directory: string) => void
+ navigateToSession: (session: Session | undefined) => void
+ onMobileMenuToggle?: () => void
+}) {
+ const globalSync = useGlobalSync()
+ const globalSDK = useGlobalSDK()
+ const layout = useLayout()
+ const params = useParams()
+ const command = useCommand()
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx
new file mode 100644
index 000000000..e13c31330
--- /dev/null
+++ b/packages/app/src/components/link.tsx
@@ -0,0 +1,17 @@
+import { ComponentProps, splitProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+
+export interface LinkProps extends ComponentProps<"button"> {
+ href: string
+}
+
+export function Link(props: LinkProps) {
+ const platform = usePlatform()
+ const [local, rest] = splitProps(props, ["href", "children"])
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
new file mode 100644
index 000000000..94d4ae97e
--- /dev/null
+++ b/packages/app/src/components/prompt-input.tsx
@@ -0,0 +1,1153 @@
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { createFocusSignal } from "@solid-primitives/active-element"
+import { useLocal } from "@/context/local"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useNavigate, useParams } from "@solidjs/router"
+import { useSync } from "@/context/sync"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Select } from "@opencode-ai/ui/select"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
+import { useProviders } from "@/hooks/use-providers"
+import { useCommand } from "@/context/command"
+import { persisted } from "@/utils/persist"
+import { Identifier } from "@/utils/id"
+import { SessionContextUsage } from "@/components/session-context-usage"
+
+const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+interface PromptInputProps {
+ class?: string
+ ref?: (el: HTMLDivElement) => void
+}
+
+const PLACEHOLDERS = [
+ "Fix a TODO in the codebase",
+ "What is the tech stack of this project?",
+ "Fix broken tests",
+ "Explain how authentication works",
+ "Find and fix security vulnerabilities",
+ "Add unit tests for the user service",
+ "Refactor this function to be more readable",
+ "What does this error mean?",
+ "Help me debug this issue",
+ "Generate API documentation",
+ "Optimize database queries",
+ "Add input validation",
+ "Create a new component for...",
+ "How do I deploy this project?",
+ "Review my code for best practices",
+ "Add error handling to this function",
+ "Explain this regex pattern",
+ "Convert this to TypeScript",
+ "Add logging throughout the codebase",
+ "What dependencies are outdated?",
+ "Help me write a migration script",
+ "Implement caching for this endpoint",
+ "Add pagination to this list",
+ "Create a CLI command for...",
+ "How do environment variables work here?",
+]
+
+interface SlashCommand {
+ id: string
+ trigger: string
+ title: string
+ description?: string
+ keybind?: string
+ type: "builtin" | "custom"
+}
+
+export const PromptInput: Component = (props) => {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const local = useLocal()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const params = useParams()
+ const dialog = useDialog()
+ const providers = useProviders()
+ const command = useCommand()
+ let editorRef!: HTMLDivElement
+ let fileInputRef!: HTMLInputElement
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const status = createMemo(
+ () =>
+ sync.data.session_status[params.id ?? ""] ?? {
+ type: "idle",
+ },
+ )
+ const working = createMemo(() => status()?.type !== "idle")
+
+ const [store, setStore] = createStore<{
+ popover: "file" | "slash" | null
+ historyIndex: number
+ savedPrompt: Prompt | null
+ placeholder: number
+ dragging: boolean
+ imageAttachments: ImageAttachmentPart[]
+ mode: "normal" | "shell"
+ applyingHistory: boolean
+ userHasEdited: boolean
+ }>({
+ popover: null,
+ historyIndex: -1,
+ savedPrompt: null,
+ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+ dragging: false,
+ imageAttachments: [],
+ mode: "normal",
+ applyingHistory: false,
+ userHasEdited: false,
+ })
+
+ const MAX_HISTORY = 100
+ const [history, setHistory] = persisted(
+ "prompt-history.v1",
+ createStore<{
+ entries: Prompt[]
+ }>({
+ entries: [],
+ }),
+ )
+ const [shellHistory, setShellHistory] = persisted(
+ "prompt-history-shell.v1",
+ createStore<{
+ entries: Prompt[]
+ }>({
+ entries: [],
+ }),
+ )
+
+ const clonePromptParts = (prompt: Prompt): Prompt =>
+ prompt.map((part) => {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ return {
+ ...part,
+ selection: part.selection ? { ...part.selection } : undefined,
+ }
+ })
+
+ const promptLength = (prompt: Prompt) =>
+ prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+
+ const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+ const length = position === "start" ? 0 : promptLength(p)
+ setStore("applyingHistory", true)
+ setStore("userHasEdited", false)
+ prompt.set(p, length)
+ requestAnimationFrame(() => {
+ editorRef.focus()
+ setCursorPosition(editorRef, length)
+ setStore("applyingHistory", false)
+ })
+ }
+
+ const getCaretState = () => {
+ const selection = window.getSelection()
+ const textLength = promptLength(prompt.current())
+ if (!selection || selection.rangeCount === 0) {
+ return { collapsed: false, cursorPosition: 0, textLength }
+ }
+ const anchorNode = selection.anchorNode
+ if (!anchorNode || !editorRef.contains(anchorNode)) {
+ return { collapsed: false, cursorPosition: 0, textLength }
+ }
+ return {
+ collapsed: selection.isCollapsed,
+ cursorPosition: getCursorPosition(editorRef),
+ textLength,
+ }
+ }
+
+ createEffect(() => {
+ params.id
+ editorRef.focus()
+ if (params.id) return
+ const interval = setInterval(() => {
+ setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
+ }, 6500)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ const isFocused = createFocusSignal(() => editorRef)
+
+ const addImageAttachment = async (file: File) => {
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const dataUrl = reader.result as string
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: crypto.randomUUID(),
+ filename: file.name,
+ mime: file.type,
+ dataUrl,
+ }
+ setStore(
+ produce((draft) => {
+ draft.imageAttachments.push(attachment)
+ }),
+ )
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const removeImageAttachment = (id: string) => {
+ setStore(
+ produce((draft) => {
+ draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
+ }),
+ )
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ const items = Array.from(clipboardData.items)
+ const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+ if (imageItems.length > 0) {
+ event.preventDefault()
+ event.stopPropagation()
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) await addImageAttachment(file)
+ }
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+ const plainText = clipboardData.getData("text/plain") ?? ""
+ addPart({ type: "text", content: plainText, start: 0, end: 0 })
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ event.preventDefault()
+ const hasFiles = event.dataTransfer?.types.includes("Files")
+ if (hasFiles) {
+ setStore("dragging", true)
+ }
+ }
+
+ const handleDragLeave = (event: DragEvent) => {
+ const related = event.relatedTarget as Node | null
+ const form = event.currentTarget as HTMLElement
+ if (!related || !form.contains(related)) {
+ setStore("dragging", false)
+ }
+ }
+
+ const handleDrop = async (event: DragEvent) => {
+ event.preventDefault()
+ setStore("dragging", false)
+
+ const files = event.dataTransfer?.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+ await addImageAttachment(file)
+ }
+ }
+ }
+
+ onMount(() => {
+ editorRef.addEventListener("paste", handlePaste)
+ })
+ onCleanup(() => {
+ editorRef.removeEventListener("paste", handlePaste)
+ })
+
+ createEffect(() => {
+ if (isFocused()) {
+ handleInput()
+ } else {
+ setStore("popover", null)
+ }
+ })
+
+ const handleFileSelect = (path: string | undefined) => {
+ if (!path) return
+ addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
+ }
+
+ const { flat, active, onInput, onKeyDown } = useFilteredList({
+ items: local.file.searchFilesAndDirectories,
+ key: (x) => x,
+ onSelect: handleFileSelect,
+ })
+
+ const slashCommands = createMemo(() => {
+ const builtin = command.options
+ .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
+ .map((opt) => ({
+ id: opt.id,
+ trigger: opt.slash!,
+ title: opt.title,
+ description: opt.description,
+ keybind: opt.keybind,
+ type: "builtin" as const,
+ }))
+
+ const custom = sync.data.command.map((cmd) => ({
+ id: `custom.${cmd.name}`,
+ trigger: cmd.name,
+ title: cmd.name,
+ description: cmd.description,
+ type: "custom" as const,
+ }))
+
+ return [...custom, ...builtin]
+ })
+
+ const handleSlashSelect = (cmd: SlashCommand | undefined) => {
+ if (!cmd) return
+ setStore("popover", null)
+
+ if (cmd.type === "custom") {
+ const text = `/${cmd.trigger} `
+ editorRef.innerHTML = ""
+ editorRef.textContent = text
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+ requestAnimationFrame(() => {
+ editorRef.focus()
+ const range = document.createRange()
+ const sel = window.getSelection()
+ range.selectNodeContents(editorRef)
+ range.collapse(false)
+ sel?.removeAllRanges()
+ sel?.addRange(range)
+ })
+ return
+ }
+
+ editorRef.innerHTML = ""
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ command.trigger(cmd.id, "slash")
+ }
+
+ const {
+ flat: slashFlat,
+ active: slashActive,
+ onInput: slashOnInput,
+ onKeyDown: slashOnKeyDown,
+ } = useFilteredList({
+ items: slashCommands,
+ key: (x) => x?.id,
+ filterKeys: ["trigger", "title", "description"],
+ onSelect: handleSlashSelect,
+ })
+
+ createEffect(
+ on(
+ () => prompt.current(),
+ (currentParts) => {
+ const domParts = parseFromDOM()
+ if (isPromptEqual(currentParts, domParts)) return
+
+ const selection = window.getSelection()
+ let cursorPosition: number | null = null
+ if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
+ cursorPosition = getCursorPosition(editorRef)
+ }
+
+ editorRef.innerHTML = ""
+ currentParts.forEach((part) => {
+ if (part.type === "text") {
+ editorRef.appendChild(document.createTextNode(part.content))
+ } else if (part.type === "file") {
+ const pill = document.createElement("span")
+ pill.textContent = part.content
+ pill.setAttribute("data-type", "file")
+ pill.setAttribute("data-path", part.path)
+ pill.setAttribute("contenteditable", "false")
+ pill.style.userSelect = "text"
+ pill.style.cursor = "default"
+ editorRef.appendChild(pill)
+ }
+ })
+
+ if (cursorPosition !== null) {
+ setCursorPosition(editorRef, cursorPosition)
+ }
+ },
+ ),
+ )
+
+ const parseFromDOM = (): Prompt => {
+ const newParts: Prompt = []
+ let position = 0
+
+ const pushText = (content: string) => {
+ if (!content) return
+ newParts.push({ type: "text", content, start: position, end: position + content.length })
+ position += content.length
+ }
+
+ const rangeText = (range: Range) => {
+ const fragment = range.cloneContents()
+ const container = document.createElement("div")
+ container.append(fragment)
+ return container.innerText
+ }
+
+ const files = Array.from(editorRef.querySelectorAll("[data-type=file]"))
+ let last: HTMLElement | undefined
+
+ files.forEach((file) => {
+ const before = document.createRange()
+ before.selectNodeContents(editorRef)
+ if (last) before.setStartAfter(last)
+ before.setEndBefore(file)
+ pushText(rangeText(before))
+
+ const content = file.textContent ?? ""
+ newParts.push({
+ type: "file",
+ path: file.dataset.path!,
+ content,
+ start: position,
+ end: position + content.length,
+ })
+ position += content.length
+ last = file
+ })
+
+ const after = document.createRange()
+ after.selectNodeContents(editorRef)
+ if (last) after.setStartAfter(last)
+ pushText(rangeText(after))
+
+ if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
+ return newParts
+ }
+
+ const handleInput = () => {
+ const rawParts = parseFromDOM()
+ const cursorPosition = getCursorPosition(editorRef)
+ const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
+ const trimmed = rawText.replace(/\u200B/g, "").trim()
+ const hasNonText = rawParts.some((part) => part.type !== "text")
+ const shouldReset = trimmed.length === 0 && !hasNonText
+
+ if (shouldReset) {
+ setStore("popover", null)
+ setStore("userHasEdited", false)
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ }
+ if (prompt.dirty()) {
+ prompt.set(DEFAULT_PROMPT, 0)
+ }
+ return
+ }
+
+ const shellMode = store.mode === "shell"
+
+ if (!shellMode) {
+ const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
+ const slashMatch = rawText.match(/^\/(\S*)$/)
+
+ if (atMatch) {
+ onInput(atMatch[1])
+ setStore("popover", "file")
+ } else if (slashMatch) {
+ slashOnInput(slashMatch[1])
+ setStore("popover", "slash")
+ } else {
+ setStore("popover", null)
+ }
+ } else {
+ setStore("popover", null)
+ }
+
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ }
+
+ if (!store.applyingHistory) {
+ setStore("userHasEdited", true)
+ }
+
+ prompt.set(rawParts, cursorPosition)
+ }
+
+ const addPart = (part: ContentPart) => {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return
+
+ const cursorPosition = getCursorPosition(editorRef)
+ const currentPrompt = prompt.current()
+ const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
+ const textBeforeCursor = rawText.substring(0, cursorPosition)
+ const atMatch = textBeforeCursor.match(/@(\S*)$/)
+
+ if (part.type === "file") {
+ const pill = document.createElement("span")
+ pill.textContent = part.content
+ pill.setAttribute("data-type", "file")
+ pill.setAttribute("data-path", part.path)
+ pill.setAttribute("contenteditable", "false")
+ pill.style.userSelect = "text"
+ pill.style.cursor = "default"
+
+ const gap = document.createTextNode(" ")
+ const range = selection.getRangeAt(0)
+
+ if (atMatch) {
+ let runningLength = 0
+
+ const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
+ let currentNode = walker.nextNode()
+ while (currentNode) {
+ const textContent = currentNode.textContent || ""
+ if (runningLength + textContent.length >= atMatch.index!) {
+ const localStart = atMatch.index! - runningLength
+ const localEnd = cursorPosition - runningLength
+ if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
+ range.setStart(currentNode, localStart)
+ range.setEnd(currentNode, Math.min(localEnd, textContent.length))
+ break
+ }
+ }
+ runningLength += textContent.length
+ currentNode = walker.nextNode()
+ }
+ }
+
+ range.deleteContents()
+ range.insertNode(gap)
+ range.insertNode(pill)
+ range.setStartAfter(gap)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ } else if (part.type === "text") {
+ const textNode = document.createTextNode(part.content)
+ const range = selection.getRangeAt(0)
+ range.deleteContents()
+ range.insertNode(textNode)
+ range.setStartAfter(textNode)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ }
+
+ handleInput()
+ setStore("popover", null)
+ }
+
+ const abort = () =>
+ sdk.client.session.abort({
+ sessionID: params.id!,
+ })
+
+ const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
+ const text = prompt
+ .map((p) => ("content" in p ? p.content : ""))
+ .join("")
+ .trim()
+ if (!text) return
+
+ const entry = clonePromptParts(prompt)
+ const currentHistory = mode === "shell" ? shellHistory : history
+ const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
+ const lastEntry = currentHistory.entries[0]
+ if (lastEntry) {
+ const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
+ if (lastText === text) return
+ }
+
+ setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
+ }
+
+ const navigateHistory = (direction: "up" | "down") => {
+ if (store.userHasEdited) return false
+
+ const entries = store.mode === "shell" ? shellHistory.entries : history.entries
+ const current = store.historyIndex
+
+ if (direction === "up") {
+ if (entries.length === 0) return false
+ if (current === -1) {
+ setStore("savedPrompt", clonePromptParts(prompt.current()))
+ setStore("historyIndex", 0)
+ applyHistoryPrompt(entries[0], "start")
+ return true
+ }
+ if (current < entries.length - 1) {
+ const next = current + 1
+ setStore("historyIndex", next)
+ applyHistoryPrompt(entries[next], "start")
+ return true
+ }
+ return false
+ }
+
+ if (current > 0) {
+ const next = current - 1
+ setStore("historyIndex", next)
+ applyHistoryPrompt(entries[next], "end")
+ return true
+ }
+ if (current === 0) {
+ setStore("historyIndex", -1)
+ const saved = store.savedPrompt
+ if (saved) {
+ applyHistoryPrompt(saved, "end")
+ setStore("savedPrompt", null)
+ return true
+ }
+ applyHistoryPrompt(DEFAULT_PROMPT, "end")
+ return true
+ }
+
+ return false
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "!" && store.mode === "normal") {
+ const cursorPosition = getCursorPosition(editorRef)
+ if (cursorPosition === 0) {
+ setStore("mode", "shell")
+ setStore("popover", null)
+ event.preventDefault()
+ return
+ }
+ }
+ if (store.mode === "shell") {
+ const { collapsed, cursorPosition, textLength } = getCaretState()
+ if (event.key === "Escape") {
+ setStore("mode", "normal")
+ event.preventDefault()
+ return
+ }
+ if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
+ setStore("mode", "normal")
+ event.preventDefault()
+ return
+ }
+ }
+
+ if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
+ if (store.popover === "file") {
+ onKeyDown(event)
+ } else {
+ slashOnKeyDown(event)
+ }
+ event.preventDefault()
+ return
+ }
+
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+ if (event.altKey || event.ctrlKey || event.metaKey) return
+ const { collapsed } = getCaretState()
+ if (!collapsed) return
+
+ const cursorPosition = getCursorPosition(editorRef)
+ const textLength = promptLength(prompt.current())
+ const textContent = editorRef.textContent ?? ""
+ const isEmpty = textContent.trim() === "" || textLength <= 1
+ const hasNewlines = textContent.includes("\n")
+ const inHistory = store.historyIndex >= 0
+ const atStart = cursorPosition <= (isEmpty ? 1 : 0)
+ const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
+ const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
+ const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
+
+ if (event.key === "ArrowUp") {
+ if (!allowUp) return
+ if (navigateHistory("up")) {
+ event.preventDefault()
+ }
+ return
+ }
+
+ if (!allowDown) return
+ if (navigateHistory("down")) {
+ event.preventDefault()
+ }
+ return
+ }
+
+ if (event.key === "Enter" && !event.shiftKey) {
+ handleSubmit(event)
+ }
+ if (event.key === "Escape") {
+ if (store.popover) {
+ setStore("popover", null)
+ } else if (working()) {
+ abort()
+ }
+ }
+ }
+
+ const handleSubmit = async (event: Event) => {
+ event.preventDefault()
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+ const hasImageAttachments = store.imageAttachments.length > 0
+ if (text.trim().length === 0 && !hasImageAttachments) {
+ if (working()) abort()
+ return
+ }
+
+ addToHistory(currentPrompt, store.mode)
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ setStore("userHasEdited", false)
+
+ let existing = info()
+ if (!existing) {
+ const created = await sdk.client.session.create()
+ existing = created.data ?? undefined
+ if (existing) navigate(existing.id)
+ }
+ if (!existing) return
+
+ const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const attachments = currentPrompt.filter(
+ (part) => part.type === "file",
+ ) as import("@/context/prompt").FileAttachmentPart[]
+
+ const fileAttachmentParts = attachments.map((attachment) => {
+ const absolute = toAbsolutePath(attachment.path)
+ const query = attachment.selection
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+ : ""
+ return {
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: "text/plain",
+ url: `file://${absolute}${query}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file" as const,
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path: absolute,
+ },
+ }
+ })
+
+ const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ }))
+
+ const isShellMode = store.mode === "shell"
+ tabs().setActive(undefined)
+ editorRef.innerHTML = ""
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ setStore("imageAttachments", [])
+ setStore("mode", "normal")
+
+ const model = {
+ modelID: local.model.current()!.id,
+ providerID: local.model.current()!.provider.id,
+ }
+ const agent = local.agent.current()!.name
+
+ if (isShellMode) {
+ sdk.client.session.shell({
+ sessionID: existing.id,
+ agent,
+ model,
+ command: text,
+ })
+ return
+ }
+
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1)
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ sdk.client.session.command({
+ sessionID: existing.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent,
+ model: `${model.providerID}/${model.modelID}`,
+ })
+ return
+ }
+ }
+
+ const messageID = Identifier.ascending("message")
+ const textPart = {
+ id: Identifier.ascending("part"),
+ type: "text" as const,
+ text,
+ }
+ const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
+ const optimisticParts = requestParts.map((part) => ({
+ ...part,
+ sessionID: existing.id,
+ messageID,
+ }))
+
+ sync.session.addOptimisticMessage({
+ sessionID: existing.id,
+ messageID,
+ parts: optimisticParts,
+ agent,
+ model,
+ })
+
+ sdk.client.session.prompt({
+ sessionID: existing.id,
+ agent,
+ model,
+ messageID,
+ parts: requestParts,
+ })
+ }
+
+ return (
+
+
+
+
{
+ editorRef = el
+ props.ref?.(el)
+ }}
+ contenteditable="true"
+ onInput={handleInput}
+ onKeyDown={handleKeyDown}
+ classList={{
+ "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+ "[&>[data-type=file]]:text-icon-info-active": true,
+ "font-mono!": store.mode === "shell",
+ }}
+ />
+
+
+ {store.mode === "shell"
+ ? "Enter shell command..."
+ : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
+
+
+
+
+
+
+
+
+
+ Shell
+ esc to exit
+
+
+
+
+ Cycle agent
+ {command.keybind("agent.cycle")}
+
+ }
+ >
+
+ }
+ >
+
+
+
+
+
+
+
+
{
+ const file = e.currentTarget.files?.[0]
+ if (file) addImageAttachment(file)
+ e.currentTarget.value = ""
+ }}
+ />
+
+
+ fileInputRef.click()}
+ />
+
+
+
+
+
+ Stop
+ ESC
+
+
+
+
+ Send
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ )
+}
+
+function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return preCaretRange.toString().length
+}
+
+function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = node.textContent ? node.textContent.length : 0
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if (isFile && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStartAfter(node)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
new file mode 100644
index 000000000..5474005c7
--- /dev/null
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -0,0 +1,64 @@
+import { createMemo, Show } from "solid-js"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
+import { useSync } from "@/context/sync"
+import { useParams } from "@solidjs/router"
+import { AssistantMessage } from "@opencode-ai/sdk/v2"
+
+export function SessionContextUsage() {
+ const sync = useSync()
+ const params = useParams()
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+
+ const cost = createMemo(() => {
+ const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(total)
+ })
+
+ const context = createMemo(() => {
+ const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
+ if (!last) return
+ const total =
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
+ return {
+ tokens: total.toLocaleString(),
+ percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+ }
+ })
+
+ return (
+
+ {(ctx) => (
+
+
+ Tokens
+ {ctx().tokens}
+
+
+ Usage
+ {ctx().percentage ?? 0}%
+
+
+ Cost
+ {cost()}
+
+
+ }
+ placement="top"
+ >
+
+
{`${ctx().percentage ?? 0}%`}
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
new file mode 100644
index 000000000..c05ddfbf6
--- /dev/null
+++ b/packages/app/src/components/terminal.tsx
@@ -0,0 +1,160 @@
+import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
+import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
+import { useSDK } from "@/context/sdk"
+import { SerializeAddon } from "@/addons/serialize"
+import { LocalPTY } from "@/context/terminal"
+import { usePrefersDark } from "@solid-primitives/media"
+
+export interface TerminalProps extends ComponentProps<"div"> {
+ pty: LocalPTY
+ onSubmit?: () => void
+ onCleanup?: (pty: LocalPTY) => void
+ onConnectError?: (error: unknown) => void
+}
+
+export const Terminal = (props: TerminalProps) => {
+ const sdk = useSDK()
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
+ let ws: WebSocket
+ let term: Term
+ let ghostty: Ghostty
+ let serializeAddon: SerializeAddon
+ let fitAddon: FitAddon
+ let handleResize: () => void
+ const prefersDark = usePrefersDark()
+
+ onMount(async () => {
+ ghostty = await Ghostty.load()
+
+ ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ term = new Term({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: "IBM Plex Mono, monospace",
+ allowTransparency: true,
+ theme: prefersDark()
+ ? {
+ background: "#191515",
+ foreground: "#d4d4d4",
+ cursor: "#d4d4d4",
+ }
+ : {
+ background: "#fcfcfc",
+ foreground: "#211e1e",
+ cursor: "#211e1e",
+ },
+ scrollback: 10_000,
+ ghostty,
+ })
+ term.attachCustomKeyEventHandler((event) => {
+ // allow for ctrl-` to toggle terminal in parent
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ return true
+ }
+ return false
+ })
+
+ fitAddon = new FitAddon()
+ serializeAddon = new SerializeAddon()
+ term.loadAddon(serializeAddon)
+ term.loadAddon(fitAddon)
+
+ term.open(container)
+
+ if (local.pty.buffer) {
+ if (local.pty.rows && local.pty.cols) {
+ term.resize(local.pty.cols, local.pty.rows)
+ }
+ term.reset()
+ term.write(local.pty.buffer)
+ if (local.pty.scrollY) {
+ term.scrollToLine(local.pty.scrollY)
+ }
+ fitAddon.fit()
+ }
+
+ container.focus()
+
+ fitAddon.observeResize()
+ handleResize = () => fitAddon.fit()
+ window.addEventListener("resize", handleResize)
+ term.onResize(async (size) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ await sdk.client.pty.update({
+ ptyID: local.pty.id,
+ size: {
+ cols: size.cols,
+ rows: size.rows,
+ },
+ })
+ }
+ })
+ term.onData((data) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ 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({
+ ptyID: local.pty.id,
+ size: {
+ cols: term.cols,
+ rows: term.rows,
+ },
+ })
+ })
+ ws.addEventListener("message", (event) => {
+ term.write(event.data)
+ })
+ ws.addEventListener("error", (error) => {
+ console.error("WebSocket error:", error)
+ props.onConnectError?.(error)
+ })
+ ws.addEventListener("close", () => {
+ console.log("WebSocket disconnected")
+ })
+ })
+
+ onCleanup(() => {
+ if (handleResize) {
+ window.removeEventListener("resize", handleResize)
+ }
+ 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 (
+
+ )
+}
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
new file mode 100644
index 000000000..f91a1cf05
--- /dev/null
+++ b/packages/app/src/context/command.tsx
@@ -0,0 +1,243 @@
+import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+export type KeybindConfig = string
+
+export interface Keybind {
+ key: string
+ ctrl: boolean
+ meta: boolean
+ shift: boolean
+ alt: boolean
+}
+
+export interface CommandOption {
+ id: string
+ title: string
+ description?: string
+ category?: string
+ keybind?: KeybindConfig
+ slash?: string
+ suggested?: boolean
+ disabled?: boolean
+ onSelect?: (source?: "palette" | "keybind" | "slash") => void
+}
+
+export function parseKeybind(config: string): Keybind[] {
+ if (!config || config === "none") return []
+
+ return config.split(",").map((combo) => {
+ const parts = combo.trim().toLowerCase().split("+")
+ const keybind: Keybind = {
+ key: "",
+ ctrl: false,
+ meta: false,
+ shift: false,
+ alt: false,
+ }
+
+ for (const part of parts) {
+ switch (part) {
+ case "ctrl":
+ case "control":
+ keybind.ctrl = true
+ break
+ case "meta":
+ case "cmd":
+ case "command":
+ keybind.meta = true
+ break
+ case "mod":
+ if (IS_MAC) keybind.meta = true
+ else keybind.ctrl = true
+ break
+ case "alt":
+ case "option":
+ keybind.alt = true
+ break
+ case "shift":
+ keybind.shift = true
+ break
+ default:
+ keybind.key = part
+ break
+ }
+ }
+
+ return keybind
+ })
+}
+
+export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
+ const eventKey = event.key.toLowerCase()
+
+ for (const kb of keybinds) {
+ const keyMatch = kb.key === eventKey
+ const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
+ const metaMatch = kb.meta === (event.metaKey || false)
+ const shiftMatch = kb.shift === (event.shiftKey || false)
+ const altMatch = kb.alt === (event.altKey || false)
+
+ if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
+ return true
+ }
+ }
+
+ return false
+}
+
+export function formatKeybind(config: string): string {
+ if (!config || config === "none") return ""
+
+ const keybinds = parseKeybind(config)
+ if (keybinds.length === 0) return ""
+
+ const kb = keybinds[0]
+ const parts: string[] = []
+
+ if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
+ if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
+ if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
+ if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
+
+ if (kb.key) {
+ const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
+ parts.push(displayKey)
+ }
+
+ return IS_MAC ? parts.join("") : parts.join("+")
+}
+
+function DialogCommand(props: { options: CommandOption[] }) {
+ const dialog = useDialog()
+
+ return (
+
+ )
+}
+
+export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
+ name: "Command",
+ init: () => {
+ const [registrations, setRegistrations] = createSignal[]>([])
+ const [suspendCount, setSuspendCount] = createSignal(0)
+ const dialog = useDialog()
+
+ const options = createMemo(() => {
+ const all = registrations().flatMap((x) => x())
+ const suggested = all.filter((x) => x.suggested && !x.disabled)
+ return [
+ ...suggested.map((x) => ({
+ ...x,
+ id: "suggested." + x.id,
+ category: "Suggested",
+ })),
+ ...all,
+ ]
+ })
+
+ const suspended = () => suspendCount() > 0
+
+ const showPalette = () => {
+ if (!dialog.active) {
+ dialog.show(() => !x.disabled)} />)
+ }
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (suspended()) return
+
+ const paletteKeybinds = parseKeybind("mod+shift+p")
+ if (matchKeybind(paletteKeybinds, event)) {
+ event.preventDefault()
+ showPalette()
+ return
+ }
+
+ for (const option of options()) {
+ if (option.disabled) continue
+ if (!option.keybind) continue
+
+ const keybinds = parseKeybind(option.keybind)
+ if (matchKeybind(keybinds, event)) {
+ event.preventDefault()
+ option.onSelect?.("keybind")
+ return
+ }
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKeyDown)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKeyDown)
+ })
+
+ return {
+ register(cb: () => CommandOption[]) {
+ const results = createMemo(cb)
+ setRegistrations((arr) => [results, ...arr])
+ onCleanup(() => {
+ setRegistrations((arr) => arr.filter((x) => x !== results))
+ })
+ },
+ trigger(id: string, source?: "palette" | "keybind" | "slash") {
+ for (const option of options()) {
+ if (option.id === id || option.id === "suggested." + id) {
+ option.onSelect?.(source)
+ return
+ }
+ }
+ },
+ keybind(id: string) {
+ const option = options().find((x) => x.id === id || x.id === "suggested." + id)
+ if (!option?.keybind) return ""
+ return formatKeybind(option.keybind)
+ },
+ show: showPalette,
+ keybinds(enabled: boolean) {
+ setSuspendCount((count) => count + (enabled ? -1 : 1))
+ },
+ suspended,
+ get options() {
+ return options()
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
new file mode 100644
index 000000000..3732ca085
--- /dev/null
+++ b/packages/app/src/context/global-sdk.tsx
@@ -0,0 +1,34 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { usePlatform } from "./platform"
+
+export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
+ name: "GlobalSDK",
+ init: (props: { url: string }) => {
+ const eventSdk = createOpencodeClient({
+ baseUrl: props.url,
+ // signal: AbortSignal.timeout(1000 * 60 * 10),
+ })
+ const emitter = createGlobalEmitter<{
+ [key: string]: Event
+ }>()
+
+ eventSdk.global.event().then(async (events) => {
+ for await (const event of events.stream) {
+ // console.log("event", event)
+ emitter.emit(event.directory ?? "global", event.payload)
+ }
+ })
+
+ const platform = usePlatform()
+ const sdk = createOpencodeClient({
+ baseUrl: props.url,
+ signal: AbortSignal.timeout(1000 * 60 * 10),
+ fetch: platform.fetch,
+ throwOnError: true,
+ })
+
+ return { url: props.url, client: sdk, event: emitter }
+ },
+})
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
new file mode 100644
index 000000000..ae40555d6
--- /dev/null
+++ b/packages/app/src/context/global-sync.tsx
@@ -0,0 +1,376 @@
+import {
+ type Message,
+ type Agent,
+ type Session,
+ type Part,
+ type Config,
+ type Path,
+ type File,
+ type FileNode,
+ type Project,
+ type FileDiff,
+ type Todo,
+ type SessionStatus,
+ type ProviderListResponse,
+ type ProviderAuthResponse,
+ type Command,
+ createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { Binary } from "@opencode-ai/util/binary"
+import { retry } from "@opencode-ai/util/retry"
+import { useGlobalSDK } from "./global-sdk"
+import { ErrorPage, type InitError } from "../pages/error"
+import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { getFilename } from "@opencode-ai/util/path"
+
+type State = {
+ ready: boolean
+ agent: Agent[]
+ command: Command[]
+ project: string
+ provider: ProviderListResponse
+ config: Config
+ path: Path
+ session: Session[]
+ session_status: {
+ [sessionID: string]: SessionStatus
+ }
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+ node: FileNode[]
+ changes: File[]
+}
+
+function createGlobalSync() {
+ const globalSDK = useGlobalSDK()
+ const [globalStore, setGlobalStore] = createStore<{
+ ready: boolean
+ error?: InitError
+ path: Path
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ children: Record
+ }>({
+ ready: false,
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ project: [],
+ provider: { all: [], connected: [], default: {} },
+ provider_auth: {},
+ children: {},
+ })
+
+ const children: Record>> = {}
+ function child(directory: string) {
+ if (!directory) console.error("No directory provided")
+ if (!children[directory]) {
+ setGlobalStore("children", directory, {
+ project: "",
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ ready: false,
+ agent: [],
+ command: [],
+ session: [],
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ limit: 5,
+ message: {},
+ part: {},
+ node: [],
+ changes: [],
+ })
+ children[directory] = createStore(globalStore.children[directory])
+ bootstrapInstance(directory)
+ }
+ return children[directory]
+ }
+
+ async function loadSessions(directory: string) {
+ const [store, setStore] = child(directory)
+ globalSDK.client.session
+ .list({ directory })
+ .then((x) => {
+ const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
+ const nonArchived = (x.data ?? [])
+ .slice()
+ .filter((s) => !s.time.archived)
+ .sort((a, b) => a.id.localeCompare(b.id))
+ // Include up to the limit, plus any updated in the last 4 hours
+ const sessions = nonArchived.filter((s, i) => {
+ if (i < store.limit) return true
+ const updated = new Date(s.time.updated).getTime()
+ return updated > fourHoursAgo
+ })
+ setStore("session", sessions)
+ })
+ .catch((err) => {
+ console.error("Failed to load sessions", err)
+ const project = getFilename(directory)
+ showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
+ })
+ }
+
+ async function bootstrapInstance(directory: string) {
+ if (!directory) return
+ const [, setStore] = child(directory)
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ directory,
+ throwOnError: true,
+ })
+ const load = {
+ project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
+ provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
+ path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
+ agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+ command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
+ session: () => loadSessions(directory),
+ status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
+ config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+ changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
+ node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+ }
+ await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
+ .then(() => setStore("ready", true))
+ .catch((e) => setGlobalStore("error", e))
+ }
+
+ globalSDK.event.listen((e) => {
+ const directory = e.name
+ const event = e.details
+
+ if (directory === "global") {
+ switch (event?.type) {
+ case "global.disposed": {
+ bootstrap()
+ break
+ }
+ case "project.updated": {
+ const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
+ if (result.found) {
+ setGlobalStore("project", result.index, reconcile(event.properties))
+ return
+ }
+ setGlobalStore(
+ "project",
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties)
+ }),
+ )
+ break
+ }
+ }
+ return
+ }
+
+ const [store, setStore] = child(directory)
+ switch (event.type) {
+ case "server.instance.disposed": {
+ bootstrapInstance(directory)
+ break
+ }
+ case "session.updated": {
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ if (event.properties.info.time.archived) {
+ if (result.found) {
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
+ if (result.found) {
+ setStore("session", result.index, reconcile(event.properties.info))
+ break
+ }
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties.info)
+ }),
+ )
+ break
+ }
+ case "session.diff":
+ setStore("session_diff", event.properties.sessionID, event.properties.diff)
+ break
+ case "todo.updated":
+ setStore("todo", event.properties.sessionID, event.properties.todos)
+ break
+ case "session.status": {
+ setStore("session_status", event.properties.sessionID, event.properties.status)
+ break
+ }
+ case "message.updated": {
+ const messages = store.message[event.properties.info.sessionID]
+ if (!messages) {
+ setStore("message", event.properties.info.sessionID, [event.properties.info])
+ break
+ }
+ const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+ if (result.found) {
+ setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+ break
+ }
+ setStore(
+ "message",
+ event.properties.info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties.info)
+ }),
+ )
+ break
+ }
+ case "message.removed": {
+ const messages = store.message[event.properties.sessionID]
+ if (!messages) break
+ const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
+ if (result.found) {
+ setStore(
+ "message",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
+ case "message.part.updated": {
+ const part = event.properties.part
+ const parts = store.part[part.messageID]
+ if (!parts) {
+ setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ case "message.part.removed": {
+ const parts = store.part[event.properties.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+ if (result.found) {
+ setStore(
+ "part",
+ event.properties.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
+ }
+ })
+
+ async function bootstrap() {
+ const health = await globalSDK.client.global.health().then((x) => x.data)
+ if (!health?.healthy) {
+ setGlobalStore(
+ "error",
+ new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
+ )
+ return
+ }
+
+ return Promise.all([
+ retry(() =>
+ globalSDK.client.path.get().then((x) => {
+ setGlobalStore("path", x.data!)
+ }),
+ ),
+ retry(() =>
+ globalSDK.client.project.list().then(async (x) => {
+ setGlobalStore(
+ "project",
+ x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
+ )
+ }),
+ ),
+ retry(() =>
+ globalSDK.client.provider.list().then((x) => {
+ setGlobalStore("provider", x.data ?? {})
+ }),
+ ),
+ retry(() =>
+ globalSDK.client.provider.auth().then((x) => {
+ setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ),
+ ])
+ .then(() => setGlobalStore("ready", true))
+ .catch((e) => setGlobalStore("error", e))
+ }
+
+ onMount(() => {
+ bootstrap()
+ })
+
+ return {
+ data: globalStore,
+ get ready() {
+ return globalStore.ready
+ },
+ get error() {
+ return globalStore.error
+ },
+ child,
+ bootstrap,
+ project: {
+ loadSessions,
+ },
+ }
+}
+
+const GlobalSyncContext = createContext>()
+
+export function GlobalSyncProvider(props: ParentProps) {
+ const value = createGlobalSync()
+ return (
+
+
+
+
+
+ {props.children}
+
+
+ )
+}
+
+export function useGlobalSync() {
+ const context = useContext(GlobalSyncContext)
+ if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
+ return context
+}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
new file mode 100644
index 000000000..c6ba5fef5
--- /dev/null
+++ b/packages/app/src/context/layout.tsx
@@ -0,0 +1,260 @@
+import { createStore, produce } from "solid-js/store"
+import { batch, createMemo, onMount } from "solid-js"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useGlobalSync } from "./global-sync"
+import { useGlobalSDK } from "./global-sdk"
+import { Project } from "@opencode-ai/sdk/v2"
+import { persisted } from "@/utils/persist"
+
+const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
+
+export function getAvatarColors(key?: string) {
+ if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
+ return {
+ background: `var(--avatar-background-${key})`,
+ foreground: `var(--avatar-text-${key})`,
+ }
+ }
+ return {
+ background: "var(--surface-info-base)",
+ foreground: "var(--text-base)",
+ }
+}
+
+type SessionTabs = {
+ active?: string
+ all: string[]
+}
+
+export type LocalProject = Partial & { worktree: string; expanded: boolean }
+
+export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
+ name: "Layout",
+ init: () => {
+ const globalSdk = useGlobalSDK()
+ const globalSync = useGlobalSync()
+ const [store, setStore, _, ready] = persisted(
+ "layout.v3",
+ createStore({
+ projects: [] as { worktree: string; expanded: boolean }[],
+ sidebar: {
+ opened: false,
+ width: 280,
+ },
+ terminal: {
+ opened: false,
+ height: 280,
+ },
+ review: {
+ opened: true,
+ },
+ session: {
+ width: 600,
+ },
+ sessionTabs: {} as Record,
+ }),
+ )
+
+ const usedColors = new Set()
+
+ function pickAvailableColor(): AvatarColorKey {
+ const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
+ if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
+ return available[Math.floor(Math.random() * available.length)]
+ }
+
+ function enrich(project: { worktree: string; expanded: boolean }) {
+ const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
+ return [
+ {
+ ...project,
+ ...(metadata ?? {}),
+ },
+ ]
+ }
+
+ function colorize(project: LocalProject) {
+ if (project.icon?.color) return project
+ const color = pickAvailableColor()
+ usedColors.add(color)
+ project.icon = { ...project.icon, color }
+ if (project.id) {
+ globalSdk.client.project.update({ projectID: project.id, icon: { color } })
+ }
+ return project
+ }
+
+ const enriched = createMemo(() => store.projects.flatMap(enrich))
+ const list = createMemo(() => enriched().flatMap(colorize))
+
+ onMount(() => {
+ Promise.all(
+ store.projects.map((project) => {
+ return globalSync.project.loadSessions(project.worktree)
+ }),
+ )
+ })
+
+ return {
+ ready,
+ projects: {
+ list,
+ open(directory: string) {
+ if (store.projects.find((x) => x.worktree === directory)) {
+ return
+ }
+ globalSync.project.loadSessions(directory)
+ setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
+ },
+ close(directory: string) {
+ setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
+ },
+ expand(directory: string) {
+ const index = store.projects.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", index, "expanded", true)
+ },
+ collapse(directory: string) {
+ const index = store.projects.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", index, "expanded", false)
+ },
+ move(directory: string, toIndex: number) {
+ setStore("projects", (projects) => {
+ const fromIndex = projects.findIndex((x) => x.worktree === directory)
+ if (fromIndex === -1 || fromIndex === toIndex) return projects
+ const result = [...projects]
+ const [item] = result.splice(fromIndex, 1)
+ result.splice(toIndex, 0, item)
+ return result
+ })
+ },
+ },
+ sidebar: {
+ opened: createMemo(() => store.sidebar.opened),
+ open() {
+ setStore("sidebar", "opened", true)
+ },
+ close() {
+ setStore("sidebar", "opened", false)
+ },
+ toggle() {
+ setStore("sidebar", "opened", (x) => !x)
+ },
+ width: createMemo(() => store.sidebar.width),
+ resize(width: number) {
+ setStore("sidebar", "width", width)
+ },
+ },
+ terminal: {
+ opened: createMemo(() => 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: {
+ opened: createMemo(() => store.review?.opened ?? true),
+ open() {
+ setStore("review", "opened", true)
+ },
+ close() {
+ setStore("review", "opened", false)
+ },
+ toggle() {
+ setStore("review", "opened", (x) => !x)
+ },
+ },
+ session: {
+ width: createMemo(() => store.session?.width ?? 600),
+ resize(width: number) {
+ if (!store.session) {
+ setStore("session", { width })
+ } else {
+ setStore("session", "width", width)
+ }
+ },
+ },
+ tabs(sessionKey: string) {
+ const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
+ return {
+ tabs,
+ active: createMemo(() => tabs().active),
+ all: createMemo(() => tabs().all),
+ setActive(tab: string | undefined) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ },
+ setAll(all: string[]) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all, active: undefined })
+ } else {
+ setStore("sessionTabs", sessionKey, "all", all)
+ }
+ },
+ async open(tab: string) {
+ const current = store.sessionTabs[sessionKey] ?? { all: [] }
+ if (tab !== "review") {
+ if (!current.all.includes(tab)) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ return
+ }
+ }
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ },
+ close(tab: string) {
+ const current = store.sessionTabs[sessionKey]
+ if (!current) return
+ batch(() => {
+ setStore(
+ "sessionTabs",
+ sessionKey,
+ "all",
+ current.all.filter((x) => x !== tab),
+ )
+ if (current.active === tab) {
+ const index = current.all.findIndex((f) => f === tab)
+ const previous = current.all[Math.max(0, index - 1)]
+ setStore("sessionTabs", sessionKey, "active", previous)
+ }
+ })
+ },
+ move(tab: string, to: number) {
+ const current = store.sessionTabs[sessionKey]
+ if (!current) return
+ const index = current.all.findIndex((f) => f === tab)
+ if (index === -1) return
+ setStore(
+ "sessionTabs",
+ sessionKey,
+ "all",
+ produce((opened) => {
+ opened.splice(to, 0, opened.splice(index, 1)[0])
+ }),
+ )
+ },
+ }
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
new file mode 100644
index 000000000..69807a2f4
--- /dev/null
+++ b/packages/app/src/context/local.tsx
@@ -0,0 +1,548 @@
+import { createStore, produce, reconcile } from "solid-js/store"
+import { batch, createEffect, createMemo } from "solid-js"
+import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
+import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useSDK } from "./sdk"
+import { useSync } from "./sync"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useProviders } from "@/hooks/use-providers"
+import { DateTime } from "luxon"
+import { persisted } from "@/utils/persist"
+
+export type LocalFile = FileNode &
+ Partial<{
+ loaded: boolean
+ pinned: boolean
+ expanded: boolean
+ content: FileContent
+ selection: { startLine: number; startChar: number; endLine: number; endChar: number }
+ scrollTop: number
+ view: "raw" | "diff-unified" | "diff-split"
+ folded: string[]
+ selectedChange: number
+ status: FileStatus
+ }>
+export type TextSelection = LocalFile["selection"]
+export type View = LocalFile["view"]
+
+export type LocalModel = Omit & {
+ provider: Provider
+ latest?: boolean
+}
+export type ModelKey = { providerID: string; modelID: string }
+
+export type FileContext = { type: "file"; path: string; selection?: TextSelection }
+export type ContextItem = FileContext
+
+export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
+ name: "Local",
+ init: () => {
+ const sdk = useSDK()
+ const sync = useSync()
+ const providers = useProviders()
+
+ function isModelValid(model: ModelKey) {
+ const provider = providers.all().find((x) => x.id === model.providerID)
+ return (
+ !!provider?.models[model.modelID] &&
+ providers
+ .connected()
+ .map((p) => p.id)
+ .includes(model.providerID)
+ )
+ }
+
+ function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
+ for (const modelFn of modelFns) {
+ const model = modelFn()
+ if (!model) continue
+ if (isModelValid(model)) return model
+ }
+ }
+
+ // Automatically update model when agent changes
+ createEffect(() => {
+ const value = agent.current()
+ if (value.model) {
+ if (isModelValid(value.model))
+ model.set({
+ providerID: value.model.providerID,
+ modelID: value.model.modelID,
+ })
+ // else
+ // toast.show({
+ // type: "warning",
+ // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
+ // duration: 3000,
+ // })
+ }
+ })
+
+ const agent = (() => {
+ const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
+ const [store, setStore] = createStore<{
+ current: string
+ }>({
+ current: list()[0].name,
+ })
+ return {
+ list,
+ current() {
+ return list().find((x) => x.name === store.current)!
+ },
+ set(name: string | undefined) {
+ setStore("current", name ?? list()[0].name)
+ },
+ move(direction: 1 | -1) {
+ let next = list().findIndex((x) => x.name === store.current) + direction
+ if (next < 0) next = list().length - 1
+ if (next >= list().length) next = 0
+ const value = list()[next]
+ setStore("current", value.name)
+ if (value.model)
+ model.set({
+ providerID: value.model.providerID,
+ modelID: value.model.modelID,
+ })
+ },
+ }
+ })()
+
+ const model = (() => {
+ const [store, setStore, _, modelReady] = persisted(
+ "model.v1",
+ createStore<{
+ user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
+ recent: ModelKey[]
+ }>({
+ user: [],
+ recent: [],
+ }),
+ )
+
+ const [ephemeral, setEphemeral] = createStore<{
+ model: Record
+ }>({
+ model: {},
+ })
+
+ const available = createMemo(() =>
+ providers.connected().flatMap((p) =>
+ Object.values(p.models).map((m) => ({
+ ...m,
+ provider: p,
+ })),
+ ),
+ )
+
+ const latest = createMemo(() =>
+ pipe(
+ available(),
+ filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
+ groupBy((x) => x.provider.id),
+ mapValues((models) =>
+ pipe(
+ models,
+ groupBy((x) => x.family),
+ values(),
+ (groups) =>
+ groups.flatMap((g) => {
+ const first = firstBy(g, [(x) => x.release_date, "desc"])
+ return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
+ }),
+ ),
+ ),
+ values(),
+ flat(),
+ ),
+ )
+
+ const list = createMemo(() =>
+ available().map((m) => ({
+ ...m,
+ name: m.name.replace("(latest)", "").trim(),
+ latest: m.name.includes("(latest)"),
+ })),
+ )
+
+ const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
+
+ const fallbackModel = createMemo(() => {
+ if (sync.data.config.model) {
+ const [providerID, modelID] = sync.data.config.model.split("/")
+ if (isModelValid({ providerID, modelID })) {
+ return {
+ providerID,
+ modelID,
+ }
+ }
+ }
+
+ for (const item of store.recent) {
+ if (isModelValid(item)) {
+ return item
+ }
+ }
+
+ for (const p of providers.connected()) {
+ if (p.id in providers.default()) {
+ return {
+ providerID: p.id,
+ modelID: providers.default()[p.id],
+ }
+ }
+ }
+
+ throw new Error("No default model found")
+ })
+
+ const current = createMemo(() => {
+ const a = agent.current()
+ const key = getFirstValidModel(
+ () => ephemeral.model[a.name],
+ () => a.model,
+ fallbackModel,
+ )!
+ return find(key)
+ })
+
+ const recent = createMemo(() => store.recent.map(find).filter(Boolean))
+
+ const cycle = (direction: 1 | -1) => {
+ const recentList = recent()
+ const currentModel = current()
+ if (!currentModel) return
+
+ const index = recentList.findIndex(
+ (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
+ )
+ if (index === -1) return
+
+ let next = index + direction
+ if (next < 0) next = recentList.length - 1
+ if (next >= recentList.length) next = 0
+
+ const val = recentList[next]
+ if (!val) return
+
+ model.set({
+ providerID: val.provider.id,
+ modelID: val.id,
+ })
+ }
+
+ function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
+ const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
+ if (index >= 0) {
+ setStore("user", index, { visibility })
+ } else {
+ setStore("user", store.user.length, { ...model, visibility })
+ }
+ }
+
+ return {
+ ready: modelReady,
+ current,
+ recent,
+ list,
+ cycle,
+ set(model: ModelKey | undefined, options?: { recent?: boolean }) {
+ batch(() => {
+ setEphemeral("model", agent.current().name, model ?? fallbackModel())
+ if (model) updateVisibility(model, "show")
+ if (options?.recent && model) {
+ const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
+ if (uniq.length > 5) uniq.pop()
+ setStore("recent", uniq)
+ }
+ })
+ },
+ visible(model: ModelKey) {
+ const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
+ return (
+ user?.visibility !== "hide" &&
+ (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
+ user?.visibility === "show")
+ )
+ },
+ setVisibility(model: ModelKey, visible: boolean) {
+ updateVisibility(model, visible ? "show" : "hide")
+ },
+ }
+ })()
+
+ const file = (() => {
+ const [store, setStore] = createStore<{
+ node: Record
+ }>({
+ node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
+ })
+
+ const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
+ const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
+
+ // createEffect((prev: FileStatus[]) => {
+ // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
+ // for (const p of removed) {
+ // setStore(
+ // "node",
+ // p.path,
+ // produce((draft) => {
+ // draft.status = undefined
+ // draft.view = "raw"
+ // }),
+ // )
+ // load(p.path)
+ // }
+ // for (const p of sync.data.changes) {
+ // if (store.node[p.path] === undefined) {
+ // fetch(p.path).then(() => {
+ // if (store.node[p.path] === undefined) return
+ // setStore("node", p.path, "status", p)
+ // })
+ // } else {
+ // setStore("node", p.path, "status", p)
+ // }
+ // }
+ // return sync.data.changes
+ // }, sync.data.changes)
+
+ const changed = (path: string) => {
+ const node = store.node[path]
+ if (node?.status) return true
+ const set = changeset()
+ if (set.has(path)) return true
+ for (const p of set) {
+ if (p.startsWith(path ? path + "/" : "")) return true
+ }
+ return false
+ }
+
+ // const resetNode = (path: string) => {
+ // setStore("node", path, {
+ // loaded: undefined,
+ // pinned: undefined,
+ // content: undefined,
+ // selection: undefined,
+ // scrollTop: undefined,
+ // folded: undefined,
+ // view: undefined,
+ // selectedChange: undefined,
+ // })
+ // }
+
+ const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
+
+ const load = async (path: string) => {
+ const relativePath = relative(path)
+ await sdk.client.file.read({ path: relativePath }).then((x) => {
+ if (!store.node[relativePath]) return
+ setStore(
+ "node",
+ relativePath,
+ produce((draft) => {
+ draft.loaded = true
+ draft.content = x.data
+ }),
+ )
+ })
+ }
+
+ const fetch = async (path: string) => {
+ const relativePath = relative(path)
+ const parent = relativePath.split("/").slice(0, -1).join("/")
+ if (parent) {
+ await list(parent)
+ }
+ }
+
+ const init = async (path: string) => {
+ const relativePath = relative(path)
+ if (!store.node[relativePath]) await fetch(path)
+ if (store.node[relativePath]?.loaded) return
+ return load(relativePath)
+ }
+
+ const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
+ const relativePath = relative(path)
+ if (!store.node[relativePath]) await fetch(path)
+ // setStore("opened", (x) => {
+ // if (x.includes(relativePath)) return x
+ // return [
+ // ...opened()
+ // .filter((x) => x.pinned)
+ // .map((x) => x.path),
+ // relativePath,
+ // ]
+ // })
+ // setStore("active", relativePath)
+ context.addActive()
+ if (options?.pinned) setStore("node", path, "pinned", true)
+ if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
+ if (store.node[relativePath]?.loaded) return
+ return load(relativePath)
+ }
+
+ const list = async (path: string) => {
+ return sdk.client.file.list({ path: path + "/" }).then((x) => {
+ setStore(
+ "node",
+ produce((draft) => {
+ x.data!.forEach((node) => {
+ if (node.path in draft) return
+ draft[node.path] = node
+ })
+ }),
+ )
+ })
+ }
+
+ const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
+ const searchFilesAndDirectories = (query: string) =>
+ sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
+
+ sdk.event.listen((e) => {
+ const event = e.details
+ switch (event.type) {
+ case "file.watcher.updated":
+ const relativePath = relative(event.properties.file)
+ if (relativePath.startsWith(".git/")) return
+ if (store.node[relativePath]) load(relativePath)
+ break
+ }
+ })
+
+ return {
+ node: async (path: string) => {
+ if (!store.node[path] || !store.node[path].loaded) {
+ await init(path)
+ }
+ return store.node[path]
+ },
+ update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
+ open,
+ load,
+ init,
+ expand(path: string) {
+ setStore("node", path, "expanded", true)
+ if (store.node[path]?.loaded) return
+ setStore("node", path, "loaded", true)
+ list(path)
+ },
+ collapse(path: string) {
+ setStore("node", path, "expanded", false)
+ },
+ select(path: string, selection: TextSelection | undefined) {
+ setStore("node", path, "selection", selection)
+ },
+ scroll(path: string, scrollTop: number) {
+ setStore("node", path, "scrollTop", scrollTop)
+ },
+ view(path: string): View {
+ const n = store.node[path]
+ return n && n.view ? n.view : "raw"
+ },
+ setView(path: string, view: View) {
+ setStore("node", path, "view", view)
+ },
+ unfold(path: string, key: string) {
+ setStore("node", path, "folded", (xs) => {
+ const a = xs ?? []
+ if (a.includes(key)) return a
+ return [...a, key]
+ })
+ },
+ fold(path: string, key: string) {
+ setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
+ },
+ folded(path: string) {
+ const n = store.node[path]
+ return n && n.folded ? n.folded : []
+ },
+ changeIndex(path: string) {
+ return store.node[path]?.selectedChange
+ },
+ setChangeIndex(path: string, index: number | undefined) {
+ setStore("node", path, "selectedChange", index)
+ },
+ changes,
+ changed,
+ children(path: string) {
+ return Object.values(store.node).filter(
+ (x) =>
+ x.path.startsWith(path) &&
+ x.path !== path &&
+ !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
+ )
+ },
+ searchFiles,
+ searchFilesAndDirectories,
+ relative,
+ }
+ })()
+
+ const context = (() => {
+ const [store, setStore] = createStore<{
+ activeTab: boolean
+ files: string[]
+ activeFile?: string
+ items: (ContextItem & { key: string })[]
+ }>({
+ activeTab: true,
+ files: [],
+ items: [],
+ })
+ const files = createMemo(() => store.files.map((x) => file.node(x)))
+ const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
+
+ return {
+ all() {
+ return store.items
+ },
+ // active() {
+ // return store.activeTab ? file.active() : undefined
+ // },
+ addActive() {
+ setStore("activeTab", true)
+ },
+ removeActive() {
+ setStore("activeTab", false)
+ },
+ add(item: ContextItem) {
+ let key = item.type
+ switch (item.type) {
+ case "file":
+ key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
+ break
+ }
+ if (store.items.find((x) => x.key === key)) return
+ setStore("items", (x) => [...x, { key, ...item }])
+ },
+ remove(key: string) {
+ setStore("items", (x) => x.filter((x) => x.key !== key))
+ },
+ files,
+ openFile(path: string) {
+ file.init(path).then(() => {
+ setStore("files", (x) => [...x, path])
+ setStore("activeFile", path)
+ })
+ },
+ activeFile,
+ setActiveFile(path: string | undefined) {
+ setStore("activeFile", path)
+ },
+ }
+ })()
+
+ const result = {
+ slug: createMemo(() => base64Encode(sdk.directory)),
+ model,
+ agent,
+ file,
+ context,
+ }
+ return result
+ },
+})
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
new file mode 100644
index 000000000..2b258ebd6
--- /dev/null
+++ b/packages/app/src/context/notification.tsx
@@ -0,0 +1,127 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useGlobalSDK } from "./global-sdk"
+import { useGlobalSync } from "./global-sync"
+import { Binary } from "@opencode-ai/util/binary"
+import { EventSessionError } from "@opencode-ai/sdk/v2"
+import { makeAudioPlayer } from "@solid-primitives/audio"
+import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
+import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
+import { persisted } from "@/utils/persist"
+
+type NotificationBase = {
+ directory?: string
+ session?: string
+ metadata?: any
+ time: number
+ viewed: boolean
+}
+
+type TurnCompleteNotification = NotificationBase & {
+ type: "turn-complete"
+}
+
+type ErrorNotification = NotificationBase & {
+ type: "error"
+ error: EventSessionError["properties"]["error"]
+}
+
+export type Notification = TurnCompleteNotification | ErrorNotification
+
+export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
+ name: "Notification",
+ init: () => {
+ let idlePlayer: ReturnType | undefined
+ let errorPlayer: ReturnType | undefined
+
+ try {
+ idlePlayer = makeAudioPlayer(idleSound)
+ errorPlayer = makeAudioPlayer(errorSound)
+ } catch (err) {
+ console.log("Failed to load audio", err)
+ }
+
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
+
+ const [store, setStore, _, ready] = persisted(
+ "notification.v1",
+ createStore({
+ list: [] as Notification[],
+ }),
+ )
+
+ globalSDK.event.listen((e) => {
+ const directory = e.name
+ const event = e.details
+ const base = {
+ directory,
+ time: Date.now(),
+ viewed: false,
+ }
+ switch (event.type) {
+ case "session.idle": {
+ const sessionID = event.properties.sessionID
+ const [syncStore] = globalSync.child(directory)
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+ const isChild = match.found && syncStore.session[match.index].parentID
+ if (isChild) break
+ try {
+ idlePlayer?.play()
+ } catch {}
+ setStore("list", store.list.length, {
+ ...base,
+ type: "turn-complete",
+ session: sessionID,
+ })
+ break
+ }
+ case "session.error": {
+ const sessionID = event.properties.sessionID
+ if (sessionID) {
+ const [syncStore] = globalSync.child(directory)
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+ const isChild = match.found && syncStore.session[match.index].parentID
+ if (isChild) break
+ }
+ try {
+ errorPlayer?.play()
+ } catch {}
+ setStore("list", store.list.length, {
+ ...base,
+ type: "error",
+ session: sessionID ?? "global",
+ error: "error" in event.properties ? event.properties.error : undefined,
+ })
+ break
+ }
+ }
+ })
+
+ return {
+ ready,
+ session: {
+ all(session: string) {
+ return store.list.filter((n) => n.session === session)
+ },
+ unseen(session: string) {
+ return store.list.filter((n) => n.session === session && !n.viewed)
+ },
+ markViewed(session: string) {
+ setStore("list", (n) => n.session === session, "viewed", true)
+ },
+ },
+ project: {
+ all(directory: string) {
+ return store.list.filter((n) => n.directory === directory)
+ },
+ unseen(directory: string) {
+ return store.list.filter((n) => n.directory === directory && !n.viewed)
+ },
+ markViewed(directory: string) {
+ setStore("list", (n) => n.directory === directory, "viewed", true)
+ },
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
new file mode 100644
index 000000000..73d4c7f3e
--- /dev/null
+++ b/packages/app/src/context/platform.tsx
@@ -0,0 +1,41 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
+
+export type Platform = {
+ /** Platform discriminator */
+ platform: "web" | "tauri"
+
+ /** Open a URL in the default browser */
+ openLink(url: string): void
+
+ /** Restart the app */
+ restart(): Promise
+
+ /** Open native directory picker dialog (Tauri only) */
+ openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise
+
+ /** Open native file picker dialog (Tauri only) */
+ openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise
+
+ /** Save file picker dialog (Tauri only) */
+ saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise
+
+ /** Storage mechanism, defaults to localStorage */
+ storage?: (name?: string) => SyncStorage | AsyncStorage
+
+ /** Check for updates (Tauri only) */
+ checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
+
+ /** Install updates (Tauri only) */
+ update?(): Promise
+
+ /** Fetch override */
+ fetch?: typeof fetch
+}
+
+export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+ name: "Platform",
+ init: (props: { value: Platform }) => {
+ return props.value
+ },
+})
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
new file mode 100644
index 000000000..8d3590cd9
--- /dev/null
+++ b/packages/app/src/context/prompt.tsx
@@ -0,0 +1,111 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { TextSelection } from "./local"
+import { persisted } from "@/utils/persist"
+
+interface PartBase {
+ content: string
+ start: number
+ end: number
+}
+
+export interface TextPart extends PartBase {
+ type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+ type: "file"
+ path: string
+ selection?: TextSelection
+}
+
+export interface ImageAttachmentPart {
+ type: "image"
+ id: string
+ filename: string
+ mime: string
+ dataUrl: string
+}
+
+export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
+export type Prompt = ContentPart[]
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+ if (promptA.length !== promptB.length) return false
+ for (let i = 0; i < promptA.length; i++) {
+ const partA = promptA[i]
+ const partB = promptB[i]
+ if (partA.type !== partB.type) return false
+ if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
+ return false
+ }
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
+ return false
+ }
+ if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
+ return false
+ }
+ }
+ return true
+}
+
+function cloneSelection(selection?: TextSelection) {
+ if (!selection) return undefined
+ return { ...selection }
+}
+
+function clonePart(part: ContentPart): ContentPart {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ return {
+ ...part,
+ selection: cloneSelection(part.selection),
+ }
+}
+
+function clonePrompt(prompt: Prompt): Prompt {
+ return prompt.map(clonePart)
+}
+
+export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
+ name: "Prompt",
+ init: () => {
+ const params = useParams()
+ const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
+
+ const [store, setStore, _, ready] = persisted(
+ name(),
+ createStore<{
+ prompt: Prompt
+ cursor?: number
+ }>({
+ prompt: clonePrompt(DEFAULT_PROMPT),
+ cursor: undefined,
+ }),
+ )
+
+ return {
+ ready,
+ current: createMemo(() => store.prompt),
+ cursor: createMemo(() => store.cursor),
+ dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+ set(prompt: Prompt, cursorPosition?: number) {
+ const next = clonePrompt(prompt)
+ batch(() => {
+ setStore("prompt", next)
+ if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+ })
+ },
+ reset() {
+ batch(() => {
+ setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+ setStore("cursor", 0)
+ })
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx
new file mode 100644
index 000000000..4d1c797c9
--- /dev/null
+++ b/packages/app/src/context/sdk.tsx
@@ -0,0 +1,30 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { useGlobalSDK } from "./global-sdk"
+import { usePlatform } from "./platform"
+
+export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
+ name: "SDK",
+ init: (props: { directory: string }) => {
+ const platform = usePlatform()
+ const globalSDK = useGlobalSDK()
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ signal: AbortSignal.timeout(1000 * 60 * 10),
+ fetch: platform.fetch,
+ directory: props.directory,
+ throwOnError: true,
+ })
+
+ const emitter = createGlobalEmitter<{
+ [key in Event["type"]]: Extract
+ }>()
+
+ globalSDK.event.on(props.directory, async (event) => {
+ emitter.emit(event.type, event)
+ })
+
+ return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
+ },
+})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
new file mode 100644
index 000000000..941b8b629
--- /dev/null
+++ b/packages/app/src/context/sync.tsx
@@ -0,0 +1,114 @@
+import { produce } from "solid-js/store"
+import { createMemo } from "solid-js"
+import { Binary } from "@opencode-ai/util/binary"
+import { retry } from "@opencode-ai/util/retry"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useGlobalSync } from "./global-sync"
+import { useSDK } from "./sdk"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+
+export const { use: useSync, provider: SyncProvider } = createSimpleContext({
+ name: "Sync",
+ init: () => {
+ const globalSync = useGlobalSync()
+ const sdk = useSDK()
+ const [store, setStore] = globalSync.child(sdk.directory)
+ const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
+
+ return {
+ data: store,
+ set: setStore,
+ get ready() {
+ return store.ready
+ },
+ get project() {
+ const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
+ if (match.found) return globalSync.data.project[match.index]
+ return undefined
+ },
+ session: {
+ get(sessionID: string) {
+ const match = Binary.search(store.session, sessionID, (s) => s.id)
+ if (match.found) return store.session[match.index]
+ return undefined
+ },
+ addOptimisticMessage(input: {
+ sessionID: string
+ messageID: string
+ parts: Part[]
+ agent: string
+ model: { providerID: string; modelID: string }
+ }) {
+ const message: Message = {
+ id: input.messageID,
+ sessionID: input.sessionID,
+ role: "user",
+ time: { created: Date.now() },
+ agent: input.agent,
+ model: input.model,
+ }
+ setStore(
+ produce((draft) => {
+ const messages = draft.message[input.sessionID]
+ if (!messages) {
+ draft.message[input.sessionID] = [message]
+ } else {
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
+ messages.splice(result.index, 0, message)
+ }
+ draft.part[input.messageID] = input.parts.slice()
+ }),
+ )
+ },
+ async sync(sessionID: string, _isRetry = false) {
+ const [session, messages, todo, diff] = await Promise.all([
+ retry(() => sdk.client.session.get({ sessionID })),
+ retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
+ retry(() => sdk.client.session.todo({ sessionID })),
+ retry(() => sdk.client.session.diff({ sessionID })),
+ ])
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, sessionID, (s) => s.id)
+ if (match.found) draft.session[match.index] = session.data!
+ if (!match.found) draft.session.splice(match.index, 0, session.data!)
+ draft.todo[sessionID] = todo.data ?? []
+ draft.message[sessionID] = messages
+ .data!.map((x) => x.info)
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
+ for (const message of messages.data!) {
+ draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+ }
+ draft.session_diff[sessionID] = diff.data ?? []
+ }),
+ )
+ },
+ fetch: async (count = 10) => {
+ setStore("limit", (x) => x + count)
+ await sdk.client.session.list().then((x) => {
+ const sessions = (x.data ?? [])
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, store.limit)
+ setStore("session", sessions)
+ })
+ },
+ more: createMemo(() => store.session.length >= store.limit),
+ archive: async (sessionID: string) => {
+ await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, sessionID, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ },
+ },
+ absolute,
+ get directory() {
+ return store.path.directory
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
new file mode 100644
index 000000000..6f7c11dea
--- /dev/null
+++ b/packages/app/src/context/terminal.tsx
@@ -0,0 +1,105 @@
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { useSDK } from "./sdk"
+import { persisted } from "@/utils/persist"
+
+export type LocalPTY = {
+ id: string
+ title: string
+ rows?: number
+ cols?: number
+ buffer?: string
+ scrollY?: number
+}
+
+export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
+ name: "Terminal",
+ init: () => {
+ const sdk = useSDK()
+ const params = useParams()
+ const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+
+ const [store, setStore, _, ready] = persisted(
+ name(),
+ createStore<{
+ active?: string
+ all: LocalPTY[]
+ }>({
+ all: [],
+ }),
+ )
+
+ return {
+ ready,
+ all: createMemo(() => Object.values(store.all)),
+ active: createMemo(() => store.active),
+ new() {
+ sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
+ const id = pty.data?.id
+ if (!id) return
+ setStore("all", [
+ ...store.all,
+ {
+ id,
+ title: pty.data?.title ?? "Terminal",
+ },
+ ])
+ setStore("active", id)
+ })
+ },
+ update(pty: Partial & { id: string }) {
+ setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+ sdk.client.pty.update({
+ ptyID: pty.id,
+ title: pty.title,
+ size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+ })
+ },
+ async clone(id: string) {
+ const index = store.all.findIndex((x) => x.id === id)
+ const pty = store.all[index]
+ if (!pty) return
+ const clone = await sdk.client.pty.create({
+ title: pty.title,
+ })
+ if (!clone.data) return
+ setStore("all", index, {
+ ...pty,
+ ...clone.data,
+ })
+ if (store.active === pty.id) {
+ setStore("active", clone.data.id)
+ }
+ },
+ open(id: string) {
+ setStore("active", id)
+ },
+ async close(id: string) {
+ batch(() => {
+ setStore(
+ "all",
+ store.all.filter((x) => x.id !== id),
+ )
+ if (store.active === id) {
+ const index = store.all.findIndex((f) => f.id === id)
+ const previous = store.all[Math.max(0, index - 1)]
+ setStore("active", previous?.id)
+ }
+ })
+ await sdk.client.pty.remove({ ptyID: id })
+ },
+ move(id: string, to: number) {
+ const index = store.all.findIndex((f) => f.id === id)
+ if (index === -1) return
+ setStore(
+ "all",
+ produce((all) => {
+ all.splice(to, 0, all.splice(index, 1)[0])
+ }),
+ )
+ },
+ }
+ },
+})
diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts
new file mode 120000
index 000000000..e4ea0d6ce
--- /dev/null
+++ b/packages/app/src/custom-elements.d.ts
@@ -0,0 +1 @@
+../../ui/src/custom-elements.d.ts
\ No newline at end of file
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
new file mode 100644
index 000000000..ecbce9815
--- /dev/null
+++ b/packages/app/src/entry.tsx
@@ -0,0 +1,30 @@
+// @refresh reload
+import { render } from "solid-js/web"
+import { App } from "@/app"
+import { Platform, PlatformProvider } from "@/context/platform"
+
+const root = document.getElementById("root")
+if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
+ throw new Error(
+ "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
+ )
+}
+
+const platform: Platform = {
+ platform: "web",
+ openLink(url: string) {
+ window.open(url, "_blank")
+ },
+ restart: async () => {
+ window.location.reload()
+ },
+}
+
+render(
+ () => (
+
+
+
+ ),
+ root!,
+)
diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts
new file mode 100644
index 000000000..ad575e93b
--- /dev/null
+++ b/packages/app/src/env.d.ts
@@ -0,0 +1,8 @@
+interface ImportMetaEnv {
+ readonly VITE_OPENCODE_SERVER_HOST: string
+ readonly VITE_OPENCODE_SERVER_PORT: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts
new file mode 100644
index 000000000..4a73fa055
--- /dev/null
+++ b/packages/app/src/hooks/use-providers.ts
@@ -0,0 +1,31 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+
+export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+
+export function useProviders() {
+ const globalSync = useGlobalSync()
+ const params = useParams()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const providers = createMemo(() => {
+ if (currentDirectory()) {
+ const [projectStore] = globalSync.child(currentDirectory())
+ return projectStore.provider
+ }
+ return globalSync.data.provider
+ })
+ const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id)))
+ const paid = createMemo(() =>
+ connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
+ )
+ const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
+ return {
+ all: createMemo(() => providers().all),
+ default: createMemo(() => providers().default),
+ popular,
+ connected,
+ paid,
+ }
+}
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
new file mode 100644
index 000000000..e40f0842b
--- /dev/null
+++ b/packages/app/src/index.css
@@ -0,0 +1,7 @@
+@import "@opencode-ai/ui/styles/tailwind";
+
+:root {
+ a {
+ cursor: default;
+ }
+}
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
new file mode 100644
index 000000000..cf5be9f51
--- /dev/null
+++ b/packages/app/src/index.ts
@@ -0,0 +1,2 @@
+export { PlatformProvider, type Platform } from "./context/platform"
+export { App } from "./app"
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
new file mode 100644
index 000000000..c909a373d
--- /dev/null
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -0,0 +1,31 @@
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { SDKProvider } from "@/context/sdk"
+import { SyncProvider, useSync } from "@/context/sync"
+import { LocalProvider } from "@/context/local"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { DataProvider } from "@opencode-ai/ui/context"
+import { iife } from "@opencode-ai/util/iife"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ const directory = createMemo(() => {
+ return base64Decode(params.dir!)
+ })
+ return (
+
+
+
+ {iife(() => {
+ const sync = useSync()
+ return (
+
+ {props.children}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx
new file mode 100644
index 000000000..9914279ad
--- /dev/null
+++ b/packages/app/src/pages/error.tsx
@@ -0,0 +1,155 @@
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Logo } from "@opencode-ai/ui/logo"
+import { Button } from "@opencode-ai/ui/button"
+import { Component } from "solid-js"
+import { usePlatform } from "@/context/platform"
+import { Icon } from "@opencode-ai/ui/icon"
+
+export type InitError = {
+ name: string
+ data: Record
+}
+
+function isInitError(error: unknown): error is InitError {
+ return (
+ typeof error === "object" &&
+ error !== null &&
+ "name" in error &&
+ "data" in error &&
+ typeof (error as InitError).data === "object"
+ )
+}
+
+function formatInitError(error: InitError): string {
+ const data = error.data
+ switch (error.name) {
+ case "MCPFailed":
+ return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
+ case "ProviderModelNotFoundError": {
+ const { providerID, modelID, suggestions } = data as {
+ providerID: string
+ modelID: string
+ suggestions?: string[]
+ }
+ return [
+ `Model not found: ${providerID}/${modelID}`,
+ ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
+ `Check your config (opencode.json) provider/model names`,
+ ].join("\n")
+ }
+ case "ProviderInitError":
+ return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
+ case "ConfigJsonError":
+ return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
+ case "ConfigDirectoryTypoError":
+ return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
+ case "ConfigFrontmatterError":
+ return `Failed to parse frontmatter in ${data.path}:\n${data.message}`
+ case "ConfigInvalidError": {
+ const issues = Array.isArray(data.issues)
+ ? data.issues.map(
+ (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
+ )
+ : []
+ return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
+ "\n",
+ )
+ }
+ case "UnknownError":
+ return String(data.message)
+ default:
+ return data.message ? String(data.message) : JSON.stringify(data, null, 2)
+ }
+}
+
+function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
+ if (!error) return "Unknown error"
+
+ if (isInitError(error)) {
+ const message = formatInitError(error)
+ if (depth > 0 && parentMessage === message) return ""
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+ return indent + message
+ }
+
+ if (error instanceof Error) {
+ const isDuplicate = depth > 0 && parentMessage === error.message
+ const parts: string[] = []
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+
+ if (!isDuplicate) {
+ // Stack already includes error name and message, so prefer it
+ parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
+ } else if (error.stack) {
+ // Duplicate message - only show the stack trace lines (skip message)
+ const trace = error.stack.split("\n").slice(1).join("\n").trim()
+ if (trace) {
+ parts.push(trace)
+ }
+ }
+
+ if (error.cause) {
+ const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
+ if (causeResult) {
+ parts.push(causeResult)
+ }
+ }
+
+ return parts.join("\n\n")
+ }
+
+ if (typeof error === "string") {
+ if (depth > 0 && parentMessage === error) return ""
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+ return indent + error
+ }
+
+ const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+ return indent + JSON.stringify(error, null, 2)
+}
+
+function formatError(error: unknown): string {
+ return formatErrorChain(error, 0)
+}
+
+interface ErrorPageProps {
+ error: unknown
+}
+
+export const ErrorPage: Component = (props) => {
+ const platform = usePlatform()
+ return (
+
+
+
+
+
Something went wrong
+
An error occurred while loading the application.
+
+
+
+
+ Please report this error to the OpenCode team
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
new file mode 100644
index 000000000..7cd2916e8
--- /dev/null
+++ b/packages/app/src/pages/home.tsx
@@ -0,0 +1,93 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Logo } from "@opencode-ai/ui/logo"
+import { useLayout } from "@/context/layout"
+import { useNavigate } from "@solidjs/router"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Icon } from "@opencode-ai/ui/icon"
+import { usePlatform } from "@/context/platform"
+import { DateTime } from "luxon"
+
+export default function Home() {
+ const sync = useGlobalSync()
+ const layout = useLayout()
+ const platform = usePlatform()
+ const navigate = useNavigate()
+ const homedir = createMemo(() => sync.data.path.home)
+
+ function openProject(directory: string) {
+ layout.projects.open(directory)
+ navigate(`/${base64Encode(directory)}`)
+ }
+
+ async function chooseProject() {
+ const result = await platform.openDirectoryPickerDialog?.({
+ title: "Open project",
+ multiple: true,
+ })
+ if (Array.isArray(result)) {
+ for (const directory of result) {
+ openProject(directory)
+ }
+ } else if (result) {
+ openProject(result)
+ }
+ }
+
+ return (
+
+
+
+ 0}>
+
+
+
Recent projects
+
+
+
+
+
+ (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+ .slice(0, 5)}
+ >
+ {(project) => (
+
+ )}
+
+
+
+
+
+
+
+
+
No recent projects
+
Get started by opening a local project
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
new file mode 100644
index 000000000..489899f88
--- /dev/null
+++ b/packages/app/src/pages/layout.tsx
@@ -0,0 +1,904 @@
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onCleanup,
+ onMount,
+ ParentProps,
+ Show,
+ Switch,
+ type JSX,
+} from "solid-js"
+import { DateTime } from "luxon"
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
+import { Avatar } from "@opencode-ai/ui/avatar"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { getFilename } from "@opencode-ai/util/path"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Session } from "@opencode-ai/sdk/v2/client"
+import { usePlatform } from "@/context/platform"
+import { createStore, produce } from "solid-js/store"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ createSortable,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { useProviders } from "@/hooks/use-providers"
+import { showToast, Toast } from "@opencode-ai/ui/toast"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useNotification } from "@/context/notification"
+import { Binary } from "@opencode-ai/util/binary"
+import { Header } from "@/components/header"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectProvider } from "@/components/dialog-select-provider"
+import { useCommand } from "@/context/command"
+import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+
+export default function Layout(props: ParentProps) {
+ const [store, setStore] = createStore({
+ lastSession: {} as { [directory: string]: string },
+ activeDraggable: undefined as string | undefined,
+ mobileSidebarOpen: false,
+ mobileProjectsExpanded: {} as Record,
+ })
+
+ const mobileSidebar = {
+ open: () => store.mobileSidebarOpen,
+ show: () => setStore("mobileSidebarOpen", true),
+ hide: () => setStore("mobileSidebarOpen", false),
+ toggle: () => setStore("mobileSidebarOpen", (x) => !x),
+ }
+
+ const mobileProjects = {
+ expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
+ expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
+ collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
+ }
+
+ let scrollContainerRef: HTMLDivElement | undefined
+ const xlQuery = window.matchMedia("(min-width: 1280px)")
+ const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
+ const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
+ xlQuery.addEventListener("change", handleViewportChange)
+ onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
+
+ const params = useParams()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
+ const layout = useLayout()
+ const platform = usePlatform()
+ const notification = useNotification()
+ const navigate = useNavigate()
+ const providers = useProviders()
+ const dialog = useDialog()
+ const command = useCommand()
+
+ onMount(async () => {
+ if (platform.checkUpdate && platform.update && platform.restart) {
+ const { updateAvailable, version } = await platform.checkUpdate()
+ if (updateAvailable) {
+ showToast({
+ persistent: true,
+ icon: "download",
+ title: "Update available",
+ description: `A new version of OpenCode (${version}) is now available to install.`,
+ actions: [
+ {
+ label: "Install and restart",
+ onClick: async () => {
+ await platform.update!()
+ await platform.restart!()
+ },
+ },
+ {
+ label: "Not yet",
+ onClick: "dismiss",
+ },
+ ],
+ })
+ }
+ }
+ })
+
+ function flattenSessions(sessions: Session[]): Session[] {
+ const childrenMap = new Map()
+ for (const session of sessions) {
+ if (session.parentID) {
+ const children = childrenMap.get(session.parentID) ?? []
+ children.push(session)
+ childrenMap.set(session.parentID, children)
+ }
+ }
+ const result: Session[] = []
+ function visit(session: Session) {
+ result.push(session)
+ for (const child of childrenMap.get(session.id) ?? []) {
+ visit(child)
+ }
+ }
+ for (const session of sessions) {
+ if (!session.parentID) visit(session)
+ }
+ return result
+ }
+
+ function scrollToSession(sessionId: string) {
+ if (!scrollContainerRef) return
+ const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
+ if (element) {
+ element.scrollIntoView({ block: "center", behavior: "smooth" })
+ }
+ }
+
+ function projectSessions(directory: string) {
+ if (!directory) return []
+ const sessions = globalSync
+ .child(directory)[0]
+ .session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+ return flattenSessions(sessions ?? [])
+ }
+
+ const currentSessions = createMemo(() => {
+ if (!params.dir) return []
+ const directory = base64Decode(params.dir)
+ return projectSessions(directory)
+ })
+
+ function navigateSessionByOffset(offset: number) {
+ const projects = layout.projects.list()
+ if (projects.length === 0) return
+
+ const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
+ const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
+
+ if (projectIndex === -1) {
+ const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
+ if (targetProject) navigateToProject(targetProject.worktree)
+ return
+ }
+
+ const sessions = currentSessions()
+ const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
+
+ let targetIndex: number
+ if (sessionIndex === -1) {
+ targetIndex = offset > 0 ? 0 : sessions.length - 1
+ } else {
+ targetIndex = sessionIndex + offset
+ }
+
+ if (targetIndex >= 0 && targetIndex < sessions.length) {
+ const session = sessions[targetIndex]
+ navigateToSession(session)
+ queueMicrotask(() => scrollToSession(session.id))
+ return
+ }
+
+ const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
+ const nextProject = projects[nextProjectIndex]
+ if (!nextProject) return
+
+ const nextProjectSessions = projectSessions(nextProject.worktree)
+ if (nextProjectSessions.length === 0) {
+ navigateToProject(nextProject.worktree)
+ return
+ }
+
+ const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+ navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
+ queueMicrotask(() => scrollToSession(targetSession.id))
+ }
+
+ async function archiveSession(session: Session) {
+ const [store, setStore] = globalSync.child(session.directory)
+ const sessions = store.session ?? []
+ const index = sessions.findIndex((s) => s.id === session.id)
+ const nextSession = sessions[index + 1] ?? sessions[index - 1]
+
+ await globalSDK.client.session.update({
+ directory: session.directory,
+ sessionID: session.id,
+ time: { archived: Date.now() },
+ })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ if (session.id === params.id) {
+ if (nextSession) {
+ navigate(`/${params.dir}/session/${nextSession.id}`)
+ } else {
+ navigate(`/${params.dir}/session`)
+ }
+ }
+ }
+
+ command.register(() => [
+ {
+ id: "sidebar.toggle",
+ title: "Toggle sidebar",
+ category: "View",
+ keybind: "mod+b",
+ onSelect: () => layout.sidebar.toggle(),
+ },
+ ...(platform.openDirectoryPickerDialog
+ ? [
+ {
+ id: "project.open",
+ title: "Open project",
+ category: "Project",
+ keybind: "mod+o",
+ onSelect: () => chooseProject(),
+ },
+ ]
+ : []),
+ {
+ id: "provider.connect",
+ title: "Connect provider",
+ category: "Provider",
+ onSelect: () => connectProvider(),
+ },
+ {
+ id: "session.previous",
+ title: "Previous session",
+ category: "Session",
+ keybind: "alt+arrowup",
+ onSelect: () => navigateSessionByOffset(-1),
+ },
+ {
+ id: "session.next",
+ title: "Next session",
+ category: "Session",
+ keybind: "alt+arrowdown",
+ onSelect: () => navigateSessionByOffset(1),
+ },
+ {
+ id: "session.archive",
+ title: "Archive session",
+ category: "Session",
+ keybind: "mod+shift+backspace",
+ disabled: !params.dir || !params.id,
+ onSelect: () => {
+ const session = currentSessions().find((s) => s.id === params.id)
+ if (session) archiveSession(session)
+ },
+ },
+ ])
+
+ function connectProvider() {
+ dialog.show(() => )
+ }
+
+ function navigateToProject(directory: string | undefined) {
+ if (!directory) return
+ const lastSession = store.lastSession[directory]
+ navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
+ mobileSidebar.hide()
+ }
+
+ function navigateToSession(session: Session | undefined) {
+ if (!session) return
+ navigate(`/${params.dir}/session/${session?.id}`)
+ mobileSidebar.hide()
+ }
+
+ function openProject(directory: string, navigate = true) {
+ layout.projects.open(directory)
+ if (navigate) navigateToProject(directory)
+ }
+
+ function closeProject(directory: string) {
+ const index = layout.projects.list().findIndex((x) => x.worktree === directory)
+ const next = layout.projects.list()[index + 1]
+ layout.projects.close(directory)
+ if (next) navigateToProject(next.worktree)
+ else navigate("/")
+ }
+
+ async function chooseProject() {
+ const result = await platform.openDirectoryPickerDialog?.({
+ title: "Open project",
+ multiple: true,
+ })
+ if (Array.isArray(result)) {
+ for (const directory of result) {
+ openProject(directory, false)
+ }
+ navigateToProject(result[0])
+ } else if (result) {
+ openProject(result)
+ }
+ }
+
+ createEffect(() => {
+ if (!params.dir || !params.id) return
+ const directory = base64Decode(params.dir)
+ setStore("lastSession", directory, params.id)
+ notification.session.markViewed(params.id)
+ })
+
+ createEffect(() => {
+ if (isLargeViewport()) {
+ const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
+ document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+ } else {
+ document.documentElement.style.setProperty("--dialog-left-margin", "0px")
+ }
+ })
+
+ function getDraggableId(event: unknown): string | undefined {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+ }
+
+ function handleDragStart(event: unknown) {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ function handleDragOver(event: DragEvent) {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const projects = layout.projects.list()
+ const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
+ const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
+ if (fromIndex !== toIndex && toIndex !== -1) {
+ layout.projects.move(draggable.id.toString(), toIndex)
+ }
+ }
+ }
+
+ function handleDragEnd() {
+ setStore("activeDraggable", undefined)
+ }
+
+ const ProjectAvatar = (props: {
+ project: LocalProject
+ class?: string
+ expandable?: boolean
+ notify?: boolean
+ }): JSX.Element => {
+ const notification = useNotification()
+ const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const name = createMemo(() => getFilename(props.project.worktree))
+ const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
+ const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+ return (
+
+
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
+ }
+ />
+
+
+
+ 0 && props.notify}>
+
+
+
+ )
+ }
+
+ const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
+ const name = createMemo(() => getFilename(props.project.worktree))
+ const current = createMemo(() => base64Decode(params.dir ?? ""))
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+
+ const SessionItem = (props: {
+ session: Session
+ slug: string
+ project: LocalProject
+ depth?: number
+ childrenMap: Map
+ mobile?: boolean
+ }): JSX.Element => {
+ const notification = useNotification()
+ const depth = props.depth ?? 0
+ const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
+ const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
+ const notifications = createMemo(() => notification.session.unseen(props.session.id))
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const isWorking = createMemo(() => {
+ if (props.session.id === params.id) return false
+ const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
+ return status?.type === "busy" || status?.type === "retry"
+ })
+ return (
+ <>
+
+
+ {(child) => (
+
+ )}
+
+ >
+ )
+ }
+
+ const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
+ const sortable = createSortable(props.project.worktree)
+ const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
+ const slug = createMemo(() => base64Encode(props.project.worktree))
+ const name = createMemo(() => getFilename(props.project.worktree))
+ const [store, setProjectStore] = globalSync.child(props.project.worktree)
+ const sessions = createMemo(() =>
+ store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
+ )
+ const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
+ const childSessionsByParent = createMemo(() => {
+ const map = new Map()
+ for (const session of sessions()) {
+ if (session.parentID) {
+ const children = map.get(session.parentID) ?? []
+ children.push(session)
+ map.set(session.parentID, children)
+ }
+ }
+ return map
+ })
+ const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
+ const loadMoreSessions = async () => {
+ setProjectStore("limit", (limit) => limit + 5)
+ await globalSync.project.loadSessions(props.project.worktree)
+ }
+ const isExpanded = createMemo(() =>
+ props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
+ )
+ const handleOpenChange = (open: boolean) => {
+ if (props.mobile) {
+ if (open) mobileProjects.expand(props.project.worktree)
+ else mobileProjects.collapse(props.project.worktree)
+ } else {
+ if (open) layout.projects.expand(props.project.worktree)
+ else layout.projects.collapse(props.project.worktree)
+ }
+ }
+ return (
+ // @ts-ignore
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ const ProjectDragOverlay = (): JSX.Element => {
+ const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
+ return (
+
+ {(p) => (
+
+ )}
+
+ )
+ }
+
+ const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+ const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+ return (
+ <>
+
+
+
+ Toggle sidebar
+ {command.keybind("sidebar.toggle")}
+
+ }
+ inactive={expanded()}
+ >
+
+
+
+
+
+
+
+ p.worktree)}>
+
+ {(project) => }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open project
+
+ {command.keybind("project.open")}
+
+
+ }
+ inactive={expanded()}
+ >
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
{
+ if (e.target === e.currentTarget) mobileSidebar.hide()
+ }}
+ />
+
e.stopPropagation()}
+ >
+
+
+
+
+
{props.children}
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
new file mode 100644
index 000000000..42e43232a
--- /dev/null
+++ b/packages/app/src/pages/session.tsx
@@ -0,0 +1,927 @@
+import {
+ For,
+ onCleanup,
+ onMount,
+ Show,
+ Match,
+ Switch,
+ createResource,
+ createMemo,
+ createEffect,
+ on,
+ createRenderEffect,
+ batch,
+} from "solid-js"
+
+import { Dynamic } from "solid-js/web"
+import { useLocal, type LocalFile } from "@/context/local"
+import { createStore } from "solid-js/store"
+import { PromptInput } from "@/components/prompt-input"
+import { DateTime } from "luxon"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useCodeComponent } from "@opencode-ai/ui/context/code"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
+import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ createSortable,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import type { JSX } from "solid-js"
+import { useSync } from "@/context/sync"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLayout } from "@/context/layout"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { Terminal } from "@/components/terminal"
+import { checksum } from "@opencode-ai/util/encode"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { useCommand } from "@/context/command"
+import { useNavigate, useParams } from "@solidjs/router"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+import { useSDK } from "@/context/sdk"
+import { usePrompt } from "@/context/prompt"
+import { extractPromptFromParts } from "@/utils/prompt"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+
+export default function Page() {
+ const layout = useLayout()
+ const local = useLocal()
+ const sync = useSync()
+ const terminal = useTerminal()
+ const dialog = useDialog()
+ const codeComponent = useCodeComponent()
+ const command = useCommand()
+ const params = useParams()
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const prompt = usePrompt()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const revertMessageID = createMemo(() => info()?.revert?.messageID)
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+ const userMessages = createMemo(() =>
+ messages()
+ .filter((m) => m.role === "user")
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ )
+ const visibleUserMessages = createMemo(() => {
+ const revert = revertMessageID()
+ if (!revert) return userMessages()
+ return userMessages().filter((m) => m.id < revert)
+ })
+ const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
+
+ const [store, setStore] = createStore({
+ clickTimer: undefined as number | undefined,
+ activeDraggable: undefined as string | undefined,
+ activeTerminalDraggable: undefined as string | undefined,
+ userInteracted: false,
+ stepsExpanded: true,
+ mobileStepsExpanded: {} as Record
,
+ messageId: undefined as string | undefined,
+ })
+
+ const activeMessage = createMemo(() => {
+ if (!store.messageId) return lastUserMessage()
+ // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
+ const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
+ return found ?? lastUserMessage()
+ })
+ const setActiveMessage = (message: UserMessage | undefined) => {
+ setStore("messageId", message?.id)
+ }
+
+ function navigateMessageByOffset(offset: number) {
+ const msgs = visibleUserMessages()
+ if (msgs.length === 0) return
+
+ const current = activeMessage()
+ const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
+
+ let targetIndex: number
+ if (currentIndex === -1) {
+ targetIndex = offset > 0 ? 0 : msgs.length - 1
+ } else {
+ targetIndex = currentIndex + offset
+ }
+
+ if (targetIndex < 0 || targetIndex >= msgs.length) return
+
+ setActiveMessage(msgs[targetIndex])
+ }
+
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+
+ let inputRef!: HTMLDivElement
+
+ createEffect(() => {
+ if (!params.id) return
+ sync.session.sync(params.id)
+ })
+
+ createEffect(() => {
+ if (layout.terminal.opened()) {
+ if (terminal.all().length === 0) {
+ terminal.new()
+ }
+ }
+ })
+
+ createEffect(
+ on(
+ () => visibleUserMessages().at(-1)?.id,
+ (lastId, prevLastId) => {
+ if (lastId && prevLastId && lastId > prevLastId) {
+ setStore("messageId", undefined)
+ }
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ params.id
+ const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
+ batch(() => {
+ setStore("userInteracted", false)
+ setStore("stepsExpanded", status.type !== "idle")
+ })
+ })
+
+ const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
+ const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
+
+ createRenderEffect((prev) => {
+ const isWorking = working()
+ if (!prev && isWorking) {
+ setStore("stepsExpanded", true)
+ }
+ if (prev && !isWorking && !store.userInteracted) {
+ setStore("stepsExpanded", false)
+ }
+ return isWorking
+ }, working())
+
+ command.register(() => [
+ {
+ id: "session.new",
+ title: "New session",
+ description: "Create a new session",
+ category: "Session",
+ keybind: "mod+shift+s",
+ slash: "new",
+ onSelect: () => navigate(`/${params.dir}/session`),
+ },
+ {
+ id: "file.open",
+ title: "Open file",
+ description: "Search and open a file",
+ category: "File",
+ keybind: "mod+p",
+ slash: "open",
+ onSelect: () => dialog.show(() => ),
+ },
+ // {
+ // id: "theme.toggle",
+ // title: "Toggle theme",
+ // description: "Switch between themes",
+ // category: "View",
+ // keybind: "ctrl+t",
+ // slash: "theme",
+ // onSelect: () => {
+ // const currentTheme = localStorage.getItem("theme") ?? "oc-1"
+ // const themes = ["oc-1", "oc-2-paper"]
+ // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
+ // localStorage.setItem("theme", nextTheme)
+ // document.documentElement.setAttribute("data-theme", nextTheme)
+ // },
+ // },
+ {
+ id: "terminal.toggle",
+ title: "Toggle terminal",
+ description: "Show or hide the terminal",
+ category: "View",
+ keybind: "ctrl+`",
+ slash: "terminal",
+ onSelect: () => layout.terminal.toggle(),
+ },
+ {
+ id: "review.toggle",
+ title: "Toggle review",
+ description: "Show or hide the review panel",
+ category: "View",
+ keybind: "mod+b",
+ slash: "review",
+ onSelect: () => layout.review.toggle(),
+ },
+ {
+ id: "terminal.new",
+ title: "New terminal",
+ description: "Create a new terminal tab",
+ category: "Terminal",
+ keybind: "ctrl+shift+`",
+ onSelect: () => terminal.new(),
+ },
+ {
+ id: "steps.toggle",
+ title: "Toggle steps",
+ description: "Show or hide the steps",
+ category: "View",
+ keybind: "mod+e",
+ slash: "steps",
+ disabled: !params.id,
+ onSelect: () => setStore("stepsExpanded", (x) => !x),
+ },
+ {
+ id: "message.previous",
+ title: "Previous message",
+ description: "Go to the previous user message",
+ category: "Session",
+ keybind: "mod+arrowup",
+ disabled: !params.id,
+ onSelect: () => navigateMessageByOffset(-1),
+ },
+ {
+ id: "message.next",
+ title: "Next message",
+ description: "Go to the next user message",
+ category: "Session",
+ keybind: "mod+arrowdown",
+ disabled: !params.id,
+ onSelect: () => navigateMessageByOffset(1),
+ },
+ {
+ id: "model.choose",
+ title: "Choose model",
+ description: "Select a different model",
+ category: "Model",
+ keybind: "mod+'",
+ slash: "model",
+ onSelect: () => dialog.show(() => ),
+ },
+ {
+ id: "agent.cycle",
+ title: "Cycle agent",
+ description: "Switch to the next agent",
+ category: "Agent",
+ keybind: "mod+.",
+ slash: "agent",
+ onSelect: () => local.agent.move(1),
+ },
+ {
+ id: "agent.cycle.reverse",
+ title: "Cycle agent backwards",
+ description: "Switch to the previous agent",
+ category: "Agent",
+ keybind: "shift+mod+.",
+ onSelect: () => local.agent.move(-1),
+ },
+ {
+ id: "session.undo",
+ title: "Undo",
+ description: "Undo the last message",
+ category: "Session",
+ slash: "undo",
+ disabled: !params.id || visibleUserMessages().length === 0,
+ onSelect: async () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ if (status()?.type !== "idle") {
+ await sdk.client.session.abort({ sessionID }).catch(() => {})
+ }
+ const revert = info()?.revert?.messageID
+ // Find the last user message that's not already reverted
+ const message = userMessages().findLast((x) => !revert || x.id < revert)
+ if (!message) return
+ await sdk.client.session.revert({ sessionID, messageID: message.id })
+ // Restore the prompt from the reverted message
+ const parts = sync.data.part[message.id]
+ if (parts) {
+ const restored = extractPromptFromParts(parts)
+ prompt.set(restored)
+ }
+ // Navigate to the message before the reverted one (which will be the new last visible message)
+ const priorMessage = userMessages().findLast((x) => x.id < message.id)
+ setActiveMessage(priorMessage)
+ },
+ },
+ {
+ id: "session.redo",
+ title: "Redo",
+ description: "Redo the last undone message",
+ category: "Session",
+ slash: "redo",
+ disabled: !params.id || !info()?.revert?.messageID,
+ onSelect: async () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ const revertMessageID = info()?.revert?.messageID
+ if (!revertMessageID) return
+ const nextMessage = userMessages().find((x) => x.id > revertMessageID)
+ if (!nextMessage) {
+ // Full unrevert - restore all messages and navigate to last
+ await sdk.client.session.unrevert({ sessionID })
+ prompt.reset()
+ // Navigate to the last message (the one that was at the revert point)
+ const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
+ setActiveMessage(lastMsg)
+ return
+ }
+ // Partial redo - move forward to next message
+ await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+ // Navigate to the message before the new revert point
+ const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
+ setActiveMessage(priorMsg)
+ },
+ },
+ ])
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const activeElement = document.activeElement as HTMLElement | undefined
+ if (activeElement) {
+ const isProtected = activeElement.closest("[data-prevent-autofocus]")
+ const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
+ if (isProtected || isInput) return
+ }
+ if (dialog.active) return
+
+ if (activeElement === inputRef) {
+ if (event.key === "Escape") inputRef?.blur()
+ return
+ }
+
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
+ inputRef?.focus()
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKeyDown)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKeyDown)
+ })
+
+ const resetClickTimer = () => {
+ if (!store.clickTimer) return
+ clearTimeout(store.clickTimer)
+ setStore("clickTimer", undefined)
+ }
+
+ const startClickTimer = () => {
+ const newClickTimer = setTimeout(() => {
+ setStore("clickTimer", undefined)
+ }, 300)
+ setStore("clickTimer", newClickTimer as unknown as number)
+ }
+
+ const handleTabClick = async (tab: string) => {
+ if (store.clickTimer) {
+ resetClickTimer()
+ } else {
+ if (tab.startsWith("file://")) {
+ local.file.open(tab.replace("file://", ""))
+ }
+ startClickTimer()
+ }
+ }
+
+ const handleDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const currentTabs = tabs().all()
+ const fromIndex = currentTabs?.indexOf(draggable.id.toString())
+ const toIndex = currentTabs?.indexOf(droppable.id.toString())
+ if (fromIndex !== toIndex && toIndex !== undefined) {
+ tabs().move(draggable.id.toString(), toIndex)
+ }
+ }
+ }
+
+ const handleDragEnd = () => {
+ setStore("activeDraggable", undefined)
+ }
+
+ const handleTerminalDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeTerminalDraggable", id)
+ }
+
+ const handleTerminalDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const terminals = terminal.all()
+ const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+ const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
+ if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
+ terminal.move(draggable.id.toString(), toIndex)
+ }
+ }
+ }
+
+ const handleTerminalDragEnd = () => {
+ setStore("activeTerminalDraggable", undefined)
+ }
+
+ const SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
+ const sortable = createSortable(props.terminal.id)
+ return (
+ // @ts-ignore
+
+
+ 1 && (
+ terminal.close(props.terminal.id)} />
+ )
+ }
+ >
+ {props.terminal.title}
+
+
+
+ )
+ }
+
+ const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
+ return (
+
+
+
+ {props.file.name}
+
+
+
+
+ M
+
+
+ A
+
+
+ D
+
+
+
+
+ )
+ }
+
+ const SortableTab = (props: {
+ tab: string
+ onTabClick: (tab: string) => void
+ onTabClose: (tab: string) => void
+ }): JSX.Element => {
+ const sortable = createSortable(props.tab)
+ const [file] = createResource(
+ () => props.tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ // @ts-ignore
+
+
+
+ props.onTabClose(props.tab)} />
+
+ }
+ hideCloseButton
+ onClick={() => props.onTabClick(props.tab)}
+ >
+
+ {(f) => }
+
+
+
+
+ )
+ }
+
+ const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
+
+ const mobileWorking = createMemo(() => status().type !== "idle")
+ const mobileAutoScroll = createAutoScroll({
+ working: mobileWorking,
+ onUserInteracted: () => setStore("userInteracted", true),
+ })
+
+ const MobileTurns = () => (
+
+ )
+
+ const NewSessionView = () => (
+
+
New session
+
+
+
+ {getDirectory(sync.data.path.directory)}
+ {getFilename(sync.data.path.directory)}
+
+
+
+ {(project) => (
+
+
+
+ Last modified
+
+ {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+
+
+
+ )}
+
+
+ )
+
+ const DesktopSessionContent = () => (
+
+
+
+
+
+ setStore("stepsExpanded", (x) => !x)}
+ onUserInteracted={() => setStore("userInteracted", true)}
+ classes={{
+ root: "pb-20 flex-1 min-w-0",
+ content: "pb-20",
+ container:
+ "w-full " +
+ (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
+ }}
+ />
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+
+
+
+
+
+
+ 0}>
+
+
+
+ Session
+
+
+ {diffs().length} Files Changed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Review
+
+
+ {info()?.summary?.files ?? 0}
+
+
+
+
+
+
+
+
+ {(tab) => }
+
+
+
+
+ Open file
+ {command.keybind("file.open")}
+
+ }
+ class="flex items-center"
+ >
+ dialog.show(() => )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {(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) => }
+
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ t.id)}>
+ {(pty) => }
+
+
+
+ New terminal
+ {command.keybind("terminal.new")}
+
+ }
+ class="flex items-center"
+ >
+
+
+
+
+
+ {(pty) => (
+
+ terminal.clone(pty.id)} />
+
+ )}
+
+
+
+
+ {(draggedId) => {
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+
+ {(t) => (
+
+ {t().title}
+
+ )}
+
+ )
+ }}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/sst-env.d.ts b/packages/app/src/sst-env.d.ts
new file mode 100644
index 000000000..47a8fbec7
--- /dev/null
+++ b/packages/app/src/sst-env.d.ts
@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+///
+interface ImportMetaEnv {
+
+}
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
\ No newline at end of file
diff --git a/packages/app/src/utils/dom.ts b/packages/app/src/utils/dom.ts
new file mode 100644
index 000000000..4f3724c7c
--- /dev/null
+++ b/packages/app/src/utils/dom.ts
@@ -0,0 +1,51 @@
+export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
+ const r = document.createRange()
+ r.selectNodeContents(lineElement)
+ r.setEnd(targetNode, offset)
+ return r.toString().length
+}
+
+export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
+ let remaining = Math.max(0, charIndex)
+ let lastText: Node | null = null
+ let lastLen = 0
+ let node: Node | null
+ while ((node = walker.nextNode())) {
+ const len = node.textContent?.length || 0
+ lastText = node
+ lastLen = len
+ if (remaining <= len) return { node, offset: remaining }
+ remaining -= len
+ }
+ if (lastText) return { node: lastText, offset: lastLen }
+ if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
+ return null
+}
+
+export function getSelectionInContainer(
+ container: HTMLElement,
+): { sl: number; sch: number; el: number; ech: number } | null {
+ const s = window.getSelection()
+ if (!s || s.rangeCount === 0) return null
+ const r = s.getRangeAt(0)
+ const sc = r.startContainer
+ const ec = r.endContainer
+ const getLineElement = (n: Node) =>
+ (n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
+ const sle = getLineElement(sc)
+ const ele = getLineElement(ec)
+ if (!sle || !ele) return null
+ if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
+ const cc = container.querySelector("code") as HTMLElement | null
+ if (!cc) return null
+ const lines = Array.from(cc.querySelectorAll(".line"))
+ const sli = lines.indexOf(sle as Element)
+ const eli = lines.indexOf(ele as Element)
+ if (sli === -1 || eli === -1) return null
+ const sl = sli + 1
+ const el = eli + 1
+ const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
+ const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
+ return { sl, sch, el, ech }
+}
diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts
new file mode 100644
index 000000000..fa27cf4c5
--- /dev/null
+++ b/packages/app/src/utils/id.ts
@@ -0,0 +1,99 @@
+import z from "zod"
+
+const prefixes = {
+ session: "ses",
+ message: "msg",
+ permission: "per",
+ user: "usr",
+ part: "prt",
+ pty: "pty",
+} as const
+
+const LENGTH = 26
+let lastTimestamp = 0
+let counter = 0
+
+type Prefix = keyof typeof prefixes
+export namespace Identifier {
+ export function schema(prefix: Prefix) {
+ return z.string().startsWith(prefixes[prefix])
+ }
+
+ export function ascending(prefix: Prefix, given?: string) {
+ return generateID(prefix, false, given)
+ }
+
+ export function descending(prefix: Prefix, given?: string) {
+ return generateID(prefix, true, given)
+ }
+}
+
+function generateID(prefix: Prefix, descending: boolean, given?: string): string {
+ if (!given) {
+ return create(prefix, descending)
+ }
+
+ if (!given.startsWith(prefixes[prefix])) {
+ throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
+ }
+
+ return given
+}
+
+function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
+ const currentTimestamp = timestamp ?? Date.now()
+
+ if (currentTimestamp !== lastTimestamp) {
+ lastTimestamp = currentTimestamp
+ counter = 0
+ }
+
+ counter += 1
+
+ let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
+
+ if (descending) {
+ now = ~now
+ }
+
+ const timeBytes = new Uint8Array(6)
+ for (let i = 0; i < 6; i += 1) {
+ timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
+ }
+
+ return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ let hex = ""
+ for (let i = 0; i < bytes.length; i += 1) {
+ hex += bytes[i].toString(16).padStart(2, "0")
+ }
+ return hex
+}
+
+function randomBase62(length: number): string {
+ const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+ const bytes = getRandomBytes(length)
+ let result = ""
+ for (let i = 0; i < length; i += 1) {
+ result += chars[bytes[i] % 62]
+ }
+ return result
+}
+
+function getRandomBytes(length: number): Uint8Array {
+ const bytes = new Uint8Array(length)
+ const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
+
+ if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
+ cryptoObj.getRandomValues(bytes)
+ return bytes
+ }
+
+ for (let i = 0; i < length; i += 1) {
+ bytes[i] = Math.floor(Math.random() * 256)
+ }
+
+ return bytes
+}
diff --git a/packages/app/src/utils/index.ts b/packages/app/src/utils/index.ts
new file mode 100644
index 000000000..d87053269
--- /dev/null
+++ b/packages/app/src/utils/index.ts
@@ -0,0 +1 @@
+export * from "./dom"
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
new file mode 100644
index 000000000..12b334f9f
--- /dev/null
+++ b/packages/app/src/utils/persist.ts
@@ -0,0 +1,26 @@
+import { usePlatform } from "@/context/platform"
+import { makePersisted } from "@solid-primitives/storage"
+import { createResource, type Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+type InitType = Promise | string | null
+type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor]
+
+export function persisted(key: string, store: [Store, SetStoreFunction]): PersistedWithReady {
+ const platform = usePlatform()
+ const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
+
+ // Create a resource that resolves when the store is initialized
+ // This integrates with Suspense and provides a ready signal
+ const isAsync = init instanceof Promise
+ const [ready] = createResource(
+ () => init,
+ async (initValue) => {
+ if (initValue instanceof Promise) await initValue
+ return true
+ },
+ { initialValue: !isAsync },
+ )
+
+ return [state, setState, init, () => ready() === true]
+}
diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts
new file mode 100644
index 000000000..45c5ce1f3
--- /dev/null
+++ b/packages/app/src/utils/prompt.ts
@@ -0,0 +1,47 @@
+import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
+import type { Prompt, FileAttachmentPart } from "@/context/prompt"
+
+/**
+ * Extract prompt content from message parts for restoring into the prompt input.
+ * This is used by undo to restore the original user prompt.
+ */
+export function extractPromptFromParts(parts: Part[]): Prompt {
+ const result: Prompt = []
+ let position = 0
+
+ for (const part of parts) {
+ if (part.type === "text") {
+ const textPart = part as TextPart
+ if (!textPart.synthetic && textPart.text) {
+ result.push({
+ type: "text",
+ content: textPart.text,
+ start: position,
+ end: position + textPart.text.length,
+ })
+ position += textPart.text.length
+ }
+ } else if (part.type === "file") {
+ const filePart = part as FilePart
+ if (filePart.source?.type === "file") {
+ const path = filePart.source.path
+ const content = "@" + path
+ const attachment: FileAttachmentPart = {
+ type: "file",
+ path,
+ content,
+ start: position,
+ end: position + content.length,
+ }
+ result.push(attachment)
+ position += content.length
+ }
+ }
+ }
+
+ if (result.length === 0) {
+ result.push({ type: "text", content: "", start: 0, end: 0 })
+ }
+
+ return result
+}
diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx
new file mode 100644
index 000000000..a634be4b4
--- /dev/null
+++ b/packages/app/src/utils/solid-dnd.tsx
@@ -0,0 +1,55 @@
+import { useDragDropContext } from "@thisbeyond/solid-dnd"
+import { JSXElement } from "solid-js"
+import type { Transformer } from "@thisbeyond/solid-dnd"
+
+export const getDraggableId = (event: unknown): string | undefined => {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+}
+
+export const ConstrainDragXAxis = (): JSXElement => {
+ const context = useDragDropContext()
+ if (!context) return <>>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-x-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, x: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <>>
+}
+
+export const ConstrainDragYAxis = (): JSXElement => {
+ const context = useDragDropContext()
+ if (!context) return <>>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-y-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, y: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <>>
+}
diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts
new file mode 100644
index 000000000..921e0a159
--- /dev/null
+++ b/packages/app/src/utils/speech.ts
@@ -0,0 +1,302 @@
+import { createSignal, onCleanup } from "solid-js"
+
+// Minimal types to avoid relying on non-standard DOM typings
+type RecognitionResult = {
+ 0: { transcript: string }
+ isFinal: boolean
+}
+
+type RecognitionEvent = {
+ results: RecognitionResult[]
+ resultIndex: number
+}
+
+interface Recognition {
+ continuous: boolean
+ interimResults: boolean
+ lang: string
+ start: () => void
+ stop: () => void
+ onresult: ((e: RecognitionEvent) => void) | null
+ onerror: ((e: { error: string }) => void) | null
+ onend: (() => void) | null
+ onstart: (() => void) | null
+}
+
+const COMMIT_DELAY = 250
+
+const appendSegment = (base: string, addition: string) => {
+ const trimmed = addition.trim()
+ if (!trimmed) return base
+ if (!base) return trimmed
+ const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
+ return `${base}${needsSpace ? " " : ""}${trimmed}`
+}
+
+const extractSuffix = (committed: string, hypothesis: string) => {
+ const cleanHypothesis = hypothesis.trim()
+ if (!cleanHypothesis) return ""
+ const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
+ const hypothesisTokens = cleanHypothesis.split(/\s+/)
+ let index = 0
+ while (
+ index < baseTokens.length &&
+ index < hypothesisTokens.length &&
+ baseTokens[index] === hypothesisTokens[index]
+ ) {
+ index += 1
+ }
+ if (index < baseTokens.length) return ""
+ return hypothesisTokens.slice(index).join(" ")
+}
+
+export function createSpeechRecognition(opts?: {
+ lang?: string
+ onFinal?: (text: string) => void
+ onInterim?: (text: string) => void
+}) {
+ const hasSupport =
+ typeof window !== "undefined" &&
+ Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
+
+ const [isRecording, setIsRecording] = createSignal(false)
+ const [committed, setCommitted] = createSignal("")
+ const [interim, setInterim] = createSignal("")
+
+ let recognition: Recognition | undefined
+ let shouldContinue = false
+ let committedText = ""
+ let sessionCommitted = ""
+ let pendingHypothesis = ""
+ let lastInterimSuffix = ""
+ let shrinkCandidate: string | undefined
+ let commitTimer: number | undefined
+
+ const cancelPendingCommit = () => {
+ if (commitTimer === undefined) return
+ clearTimeout(commitTimer)
+ commitTimer = undefined
+ }
+
+ const commitSegment = (segment: string) => {
+ const nextCommitted = appendSegment(committedText, segment)
+ if (nextCommitted === committedText) return
+ committedText = nextCommitted
+ setCommitted(committedText)
+ if (opts?.onFinal) opts.onFinal(segment.trim())
+ }
+
+ const promotePending = () => {
+ if (!pendingHypothesis) return
+ const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
+ if (!suffix) {
+ pendingHypothesis = ""
+ return
+ }
+ sessionCommitted = appendSegment(sessionCommitted, suffix)
+ commitSegment(suffix)
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ }
+
+ const applyInterim = (suffix: string, hypothesis: string) => {
+ cancelPendingCommit()
+ pendingHypothesis = hypothesis
+ lastInterimSuffix = suffix
+ shrinkCandidate = undefined
+ setInterim(suffix)
+ if (opts?.onInterim) {
+ opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
+ }
+ if (!suffix) return
+ const snapshot = hypothesis
+ commitTimer = window.setTimeout(() => {
+ if (pendingHypothesis !== snapshot) return
+ const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
+ if (!currentSuffix) return
+ sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
+ commitSegment(currentSuffix)
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ }, COMMIT_DELAY)
+ }
+
+ if (hasSupport) {
+ const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
+
+ recognition = new Ctor()
+ recognition.continuous = false
+ recognition.interimResults = true
+ recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
+
+ recognition.onresult = (event: RecognitionEvent) => {
+ if (!event.results.length) return
+
+ let aggregatedFinal = ""
+ let latestHypothesis = ""
+
+ for (let i = 0; i < event.results.length; i += 1) {
+ const result = event.results[i]
+ const transcript = (result[0]?.transcript || "").trim()
+ if (!transcript) continue
+ if (result.isFinal) {
+ aggregatedFinal = appendSegment(aggregatedFinal, transcript)
+ } else {
+ latestHypothesis = transcript
+ }
+ }
+
+ if (aggregatedFinal) {
+ cancelPendingCommit()
+ const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
+ if (finalSuffix) {
+ sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
+ commitSegment(finalSuffix)
+ }
+ pendingHypothesis = ""
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ return
+ }
+
+ cancelPendingCommit()
+
+ if (!latestHypothesis) {
+ shrinkCandidate = undefined
+ applyInterim("", "")
+ return
+ }
+
+ const suffix = extractSuffix(sessionCommitted, latestHypothesis)
+
+ if (!suffix) {
+ if (!lastInterimSuffix) {
+ shrinkCandidate = undefined
+ applyInterim("", latestHypothesis)
+ return
+ }
+ if (shrinkCandidate === "") {
+ applyInterim("", latestHypothesis)
+ return
+ }
+ shrinkCandidate = ""
+ pendingHypothesis = latestHypothesis
+ return
+ }
+
+ if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
+ if (shrinkCandidate === suffix) {
+ applyInterim(suffix, latestHypothesis)
+ return
+ }
+ shrinkCandidate = suffix
+ pendingHypothesis = latestHypothesis
+ return
+ }
+
+ shrinkCandidate = undefined
+ applyInterim(suffix, latestHypothesis)
+ }
+
+ recognition.onerror = (e: { error: string }) => {
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ if (e.error === "no-speech" && shouldContinue) {
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ setTimeout(() => {
+ try {
+ recognition?.start()
+ } catch {}
+ }, 150)
+ return
+ }
+ shouldContinue = false
+ setIsRecording(false)
+ }
+
+ recognition.onstart = () => {
+ sessionCommitted = ""
+ pendingHypothesis = ""
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ setIsRecording(true)
+ }
+
+ recognition.onend = () => {
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setIsRecording(false)
+ if (shouldContinue) {
+ setTimeout(() => {
+ try {
+ recognition?.start()
+ } catch {}
+ }, 150)
+ }
+ }
+ }
+
+ const start = () => {
+ if (!recognition) return
+ shouldContinue = true
+ sessionCommitted = ""
+ pendingHypothesis = ""
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ try {
+ recognition.start()
+ } catch {}
+ }
+
+ const stop = () => {
+ if (!recognition) return
+ shouldContinue = false
+ promotePending()
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ try {
+ recognition.stop()
+ } catch {}
+ }
+
+ onCleanup(() => {
+ shouldContinue = false
+ promotePending()
+ cancelPendingCommit()
+ lastInterimSuffix = ""
+ shrinkCandidate = undefined
+ setInterim("")
+ if (opts?.onInterim) opts.onInterim("")
+ try {
+ recognition?.stop()
+ } catch {}
+ })
+
+ return {
+ isSupported: () => hasSupport,
+ isRecording,
+ committed,
+ interim,
+ start,
+ stop,
+ }
+}
diff --git a/packages/app/sst-env.d.ts b/packages/app/sst-env.d.ts
new file mode 100644
index 000000000..b6a7e9066
--- /dev/null
+++ b/packages/app/sst-env.d.ts
@@ -0,0 +1,9 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+///
+
+import "sst"
+export {}
\ No newline at end of file
diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json
new file mode 100644
index 000000000..db04f79ca
--- /dev/null
+++ b/packages/app/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "composite": true,
+ "target": "ESNext",
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "allowJs": true,
+ "strict": true,
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "outDir": "node_modules/.ts-dist",
+ "isolatedModules": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "exclude": ["dist", "ts-dist"]
+}
diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts
new file mode 100644
index 000000000..57071a894
--- /dev/null
+++ b/packages/app/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vite"
+import desktopPlugin from "./vite"
+
+export default defineConfig({
+ plugins: [desktopPlugin] as any,
+ server: {
+ host: "0.0.0.0",
+ allowedHosts: true,
+ port: 3000,
+ },
+ build: {
+ target: "esnext",
+ sourcemap: true,
+ },
+})
diff --git a/packages/app/vite.js b/packages/app/vite.js
new file mode 100644
index 000000000..6b8fd6137
--- /dev/null
+++ b/packages/app/vite.js
@@ -0,0 +1,26 @@
+import solidPlugin from "vite-plugin-solid"
+import tailwindcss from "@tailwindcss/vite"
+import { fileURLToPath } from "url"
+
+/**
+ * @type {import("vite").PluginOption}
+ */
+export default [
+ {
+ name: "opencode-desktop:config",
+ config() {
+ return {
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ worker: {
+ format: "es",
+ },
+ }
+ },
+ },
+ tailwindcss(),
+ solidPlugin(),
+]
diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore
deleted file mode 100644
index 4a20d55a7..000000000
--- a/packages/desktop/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-src/assets/theme.css
diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md
deleted file mode 100644
index 3137bddc2..000000000
--- a/packages/desktop/AGENTS.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Agent Guidelines for @opencode/app
-
-## Build/Test Commands
-
-- **Development**: `bun run dev` (starts Vite dev server on port 3000)
-- **Build**: `bun run build` (production build)
-- **Preview**: `bun run serve` (preview production build)
-- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
-- **Testing**: Do not create or run automated tests
-
-## Code Style
-
-- **Framework**: SolidJS with TypeScript
-- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
-- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
-- **Components**: Use function declarations, splitProps for component props
-- **Types**: Define interfaces for component props, avoid `any` type
-- **CSS**: TailwindCSS with custom CSS variables theme system
-- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
-- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
-
-## Key Dependencies
-
-- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
-- TailwindCSS 4.x with @tailwindcss/vite
-- Custom theme system with CSS variables
-
-No special rules files found.
diff --git a/packages/desktop/README.md b/packages/desktop/README.md
deleted file mode 100644
index 6a1764536..000000000
--- a/packages/desktop/README.md
+++ /dev/null
@@ -1,34 +0,0 @@
-## Usage
-
-Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
-
-This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
-
-```bash
-$ npm install # or pnpm install or yarn install
-```
-
-### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
-
-## Available Scripts
-
-In the project directory, you can run:
-
-### `npm run dev` or `npm start`
-
-Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
-
-The page will reload if you make edits.
-
-### `npm run build`
-
-Builds the app for production to the `dist` folder.
-It correctly bundles Solid in production mode and optimizes the build for the best performance.
-
-The build is minified and the filenames include the hashes.
-Your app is ready to be deployed!
-
-## Deployment
-
-You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
diff --git a/packages/desktop/bunfig.toml b/packages/desktop/bunfig.toml
deleted file mode 100644
index 363990451..000000000
--- a/packages/desktop/bunfig.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[test]
-preload = ["./happydom.ts"]
diff --git a/packages/desktop/happydom.ts b/packages/desktop/happydom.ts
deleted file mode 100644
index de726718f..000000000
--- a/packages/desktop/happydom.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { GlobalRegistrator } from "@happy-dom/global-registrator"
-
-GlobalRegistrator.register()
-
-const originalGetContext = HTMLCanvasElement.prototype.getContext
-// @ts-expect-error - we're overriding with a simplified mock
-HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) {
- if (contextType === "2d") {
- return {
- canvas: this,
- fillStyle: "#000000",
- strokeStyle: "#000000",
- font: "12px monospace",
- textAlign: "start",
- textBaseline: "alphabetic",
- globalAlpha: 1,
- globalCompositeOperation: "source-over",
- imageSmoothingEnabled: true,
- lineWidth: 1,
- lineCap: "butt",
- lineJoin: "miter",
- miterLimit: 10,
- shadowBlur: 0,
- shadowColor: "rgba(0, 0, 0, 0)",
- shadowOffsetX: 0,
- shadowOffsetY: 0,
- fillRect: () => {},
- strokeRect: () => {},
- clearRect: () => {},
- fillText: () => {},
- strokeText: () => {},
- measureText: (text: string) => ({ width: text.length * 8 }),
- drawImage: () => {},
- save: () => {},
- restore: () => {},
- scale: () => {},
- rotate: () => {},
- translate: () => {},
- transform: () => {},
- setTransform: () => {},
- resetTransform: () => {},
- createLinearGradient: () => ({ addColorStop: () => {} }),
- createRadialGradient: () => ({ addColorStop: () => {} }),
- createPattern: () => null,
- beginPath: () => {},
- closePath: () => {},
- moveTo: () => {},
- lineTo: () => {},
- bezierCurveTo: () => {},
- quadraticCurveTo: () => {},
- arc: () => {},
- arcTo: () => {},
- ellipse: () => {},
- rect: () => {},
- fill: () => {},
- stroke: () => {},
- clip: () => {},
- isPointInPath: () => false,
- isPointInStroke: () => false,
- getTransform: () => ({}),
- getImageData: () => ({
- data: new Uint8ClampedArray(0),
- width: 0,
- height: 0,
- }),
- putImageData: () => {},
- createImageData: () => ({
- data: new Uint8ClampedArray(0),
- width: 0,
- height: 0,
- }),
- } as unknown as CanvasRenderingContext2D
- }
- return originalGetContext.call(this, contextType as "2d", _options)
-}
diff --git a/packages/desktop/index.html b/packages/desktop/index.html
deleted file mode 100644
index 9803517a0..000000000
--- a/packages/desktop/index.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
- OpenCode
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
deleted file mode 100644
index 7665f6d0c..000000000
--- a/packages/desktop/package.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "name": "@opencode-ai/desktop",
- "version": "1.0.190",
- "description": "",
- "type": "module",
- "exports": {
- ".": "./src/index.ts",
- "./vite": "./vite.js"
- },
- "scripts": {
- "typecheck": "tsgo -b",
- "start": "vite",
- "dev": "vite",
- "build": "vite build",
- "serve": "vite preview"
- },
- "license": "MIT",
- "devDependencies": {
- "@happy-dom/global-registrator": "20.0.11",
- "@tailwindcss/vite": "catalog:",
- "@tsconfig/bun": "1.0.9",
- "@types/bun": "catalog:",
- "@types/luxon": "catalog:",
- "@types/node": "catalog:",
- "@typescript/native-preview": "catalog:",
- "typescript": "catalog:",
- "vite": "catalog:",
- "vite-plugin-icons-spritesheet": "3.0.1",
- "vite-plugin-solid": "catalog:"
- },
- "dependencies": {
- "@kobalte/core": "catalog:",
- "@opencode-ai/sdk": "workspace:*",
- "@opencode-ai/ui": "workspace:*",
- "@opencode-ai/util": "workspace:*",
- "@shikijs/transformers": "3.9.2",
- "@solid-primitives/active-element": "2.1.3",
- "@solid-primitives/audio": "1.4.2",
- "@solid-primitives/event-bus": "1.1.2",
- "@solid-primitives/media": "2.3.3",
- "@solid-primitives/resize-observer": "2.1.3",
- "@solid-primitives/scroll": "2.1.3",
- "@solid-primitives/storage": "catalog:",
- "@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",
- "remeda": "catalog:",
- "shiki": "3.9.2",
- "solid-js": "catalog:",
- "solid-list": "catalog:",
- "tailwindcss": "catalog:",
- "virtua": "catalog:",
- "zod": "catalog:"
- }
-}
diff --git a/packages/desktop/public/apple-touch-icon.png b/packages/desktop/public/apple-touch-icon.png
deleted file mode 120000
index fb6e8b170..000000000
--- a/packages/desktop/public/apple-touch-icon.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/apple-touch-icon.png
\ No newline at end of file
diff --git a/packages/desktop/public/favicon-96x96.png b/packages/desktop/public/favicon-96x96.png
deleted file mode 120000
index 155c5ed2f..000000000
--- a/packages/desktop/public/favicon-96x96.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/favicon-96x96.png
\ No newline at end of file
diff --git a/packages/desktop/public/favicon.ico b/packages/desktop/public/favicon.ico
deleted file mode 120000
index 1c90f01b1..000000000
--- a/packages/desktop/public/favicon.ico
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/favicon.ico
\ No newline at end of file
diff --git a/packages/desktop/public/favicon.svg b/packages/desktop/public/favicon.svg
deleted file mode 120000
index 80804d257..000000000
--- a/packages/desktop/public/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/favicon.svg
\ No newline at end of file
diff --git a/packages/desktop/public/site.webmanifest b/packages/desktop/public/site.webmanifest
deleted file mode 120000
index a116d7879..000000000
--- a/packages/desktop/public/site.webmanifest
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/site.webmanifest
\ No newline at end of file
diff --git a/packages/desktop/public/social-share-zen.png b/packages/desktop/public/social-share-zen.png
deleted file mode 120000
index 02f205fc5..000000000
--- a/packages/desktop/public/social-share-zen.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/images/social-share-zen.png
\ No newline at end of file
diff --git a/packages/desktop/public/social-share.png b/packages/desktop/public/social-share.png
deleted file mode 120000
index 88bf2d4c6..000000000
--- a/packages/desktop/public/social-share.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/images/social-share.png
\ No newline at end of file
diff --git a/packages/desktop/public/web-app-manifest-192x192.png b/packages/desktop/public/web-app-manifest-192x192.png
deleted file mode 120000
index 8cfdf8ca5..000000000
--- a/packages/desktop/public/web-app-manifest-192x192.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/web-app-manifest-192x192.png
\ No newline at end of file
diff --git a/packages/desktop/public/web-app-manifest-512x512.png b/packages/desktop/public/web-app-manifest-512x512.png
deleted file mode 120000
index 4165998e6..000000000
--- a/packages/desktop/public/web-app-manifest-512x512.png
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/assets/favicon/web-app-manifest-512x512.png
\ No newline at end of file
diff --git a/packages/desktop/src/addons/serialize.test.ts b/packages/desktop/src/addons/serialize.test.ts
deleted file mode 100644
index ad165f43f..000000000
--- a/packages/desktop/src/addons/serialize.test.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-import { describe, test, expect, beforeAll, afterEach } from "bun:test"
-import { Terminal, Ghostty } from "ghostty-web"
-import { SerializeAddon } from "./serialize"
-
-let ghostty: Ghostty
-beforeAll(async () => {
- ghostty = await Ghostty.load()
-})
-
-const terminals: Terminal[] = []
-
-afterEach(() => {
- for (const term of terminals) {
- term.dispose()
- }
- terminals.length = 0
- document.body.innerHTML = ""
-})
-
-function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } {
- const container = document.createElement("div")
- document.body.appendChild(container)
-
- const term = new Terminal({ cols, rows, ghostty })
- const addon = new SerializeAddon()
- term.loadAddon(addon)
- term.open(container)
- terminals.push(term)
-
- return { term, addon, container }
-}
-
-function writeAndWait(term: Terminal, data: string): Promise {
- return new Promise((resolve) => {
- term.write(data, resolve)
- })
-}
-
-describe("SerializeAddon", () => {
- describe("ANSI color preservation", () => {
- test("should preserve text attributes (bold, italic, underline)", async () => {
- const { term, addon } = createTerminal()
-
- const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m"
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- expect(origLine!.getCell(0)!.isBold()).toBe(1)
- expect(origLine!.getCell(5)!.isItalic()).toBe(1)
- expect(origLine!.getCell(12)!.isUnderline()).toBe(1)
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const line = term2.buffer.active.getLine(0)
-
- const boldCell = line!.getCell(0)
- expect(boldCell!.getChars()).toBe("B")
- expect(boldCell!.isBold()).toBe(1)
-
- const italicCell = line!.getCell(5)
- expect(italicCell!.getChars()).toBe("I")
- expect(italicCell!.isItalic()).toBe(1)
-
- const underCell = line!.getCell(12)
- expect(underCell!.getChars()).toBe("U")
- expect(underCell!.isUnderline()).toBe(1)
- })
-
- test("should preserve basic 16-color foreground colors", async () => {
- const { term, addon } = createTerminal()
-
- const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL"
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origRedFg = origLine!.getCell(0)!.getFgColor()
- const origGreenFg = origLine!.getCell(3)!.getFgColor()
- const origBlueFg = origLine!.getCell(8)!.getFgColor()
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const line = term2.buffer.active.getLine(0)
- expect(line).toBeDefined()
-
- const redCell = line!.getCell(0)
- expect(redCell!.getChars()).toBe("R")
- expect(redCell!.getFgColor()).toBe(origRedFg)
-
- const greenCell = line!.getCell(3)
- expect(greenCell!.getChars()).toBe("G")
- expect(greenCell!.getFgColor()).toBe(origGreenFg)
-
- const blueCell = line!.getCell(8)
- expect(blueCell!.getChars()).toBe("B")
- expect(blueCell!.getFgColor()).toBe(origBlueFg)
- })
-
- test("should preserve 256-color palette colors", async () => {
- const { term, addon } = createTerminal()
-
- const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL"
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origRedFg = origLine!.getCell(0)!.getFgColor()
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const line = term2.buffer.active.getLine(0)
- const redCell = line!.getCell(0)
- expect(redCell!.getChars()).toBe("R")
- expect(redCell!.getFgColor()).toBe(origRedFg)
- })
-
- test("should preserve RGB/truecolor colors", async () => {
- const { term, addon } = createTerminal()
-
- const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL"
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origRgbFg = origLine!.getCell(0)!.getFgColor()
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const line = term2.buffer.active.getLine(0)
- const rgbCell = line!.getCell(0)
- expect(rgbCell!.getChars()).toBe("R")
- expect(rgbCell!.getFgColor()).toBe(origRgbFg)
- })
-
- test("should preserve background colors", async () => {
- const { term, addon } = createTerminal()
-
- const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL"
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origRedBg = origLine!.getCell(0)!.getBgColor()
- const origGreenBg = origLine!.getCell(6)!.getBgColor()
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const line = term2.buffer.active.getLine(0)
-
- const redBgCell = line!.getCell(0)
- expect(redBgCell!.getChars()).toBe("R")
- expect(redBgCell!.getBgColor()).toBe(origRedBg)
-
- const greenBgCell = line!.getCell(6)
- expect(greenBgCell!.getChars()).toBe("G")
- expect(greenBgCell!.getBgColor()).toBe(origGreenBg)
- })
-
- test("should handle combined colors and attributes", async () => {
- const { term, addon } = createTerminal()
-
- const input =
- "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL "
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origFg = origLine!.getCell(0)!.getFgColor()
- const origBg = origLine!.getCell(0)!.getBgColor()
- expect(origLine!.getCell(0)!.isBold()).toBe(1)
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
- const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "")
-
- expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true)
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, cleanSerialized)
-
- const line = term2.buffer.active.getLine(0)
- const comboCell = line!.getCell(0)
-
- expect(comboCell!.getChars()).toBe("C")
- expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m")
- })
- })
-
- describe("round-trip serialization", () => {
- test("should not produce ECH sequences", async () => {
- const { term, addon } = createTerminal()
-
- await writeAndWait(term, "\x1b[31mHello\x1b[0m World")
-
- const serialized = addon.serialize()
-
- const hasECH = /\x1b\[\d+X/.test(serialized)
- expect(hasECH).toBe(false)
- })
-
- test("multi-line content should not have garbage characters", async () => {
- const { term, addon } = createTerminal()
-
- const content = [
- "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
- "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
- "total 42",
- ].join("\r\n")
-
- await writeAndWait(term, content)
-
- const serialized = addon.serialize()
-
- expect(/\x1b\[\d+X/.test(serialized)).toBe(false)
-
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- for (let row = 0; row < 3; row++) {
- const line = term2.buffer.active.getLine(row)?.translateToString(true)
- expect(line?.includes("𑼝")).toBe(false)
- }
-
- expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
- expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
- expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
- })
-
- test("serialized output written to new terminal should match original colors", async () => {
- const { term, addon } = createTerminal(40, 5)
-
- const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! "
- await writeAndWait(term, input)
-
- const origLine = term.buffer.active.getLine(0)
- const origHelloFg = origLine!.getCell(0)!.getFgColor()
- const origWorldFg = origLine!.getCell(6)!.getFgColor()
-
- const serialized = addon.serialize({ range: { start: 0, end: 0 } })
-
- const { term: term2 } = createTerminal(40, 5)
- terminals.push(term2)
- await writeAndWait(term2, serialized)
-
- const newLine = term2.buffer.active.getLine(0)
-
- expect(newLine!.getCell(0)!.getChars()).toBe("H")
- expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg)
-
- expect(newLine!.getCell(6)!.getChars()).toBe("W")
- expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg)
-
- expect(newLine!.getCell(11)!.getChars()).toBe("!")
- })
- })
-})
diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts
deleted file mode 100644
index cb1ff8442..000000000
--- a/packages/desktop/src/addons/serialize.ts
+++ /dev/null
@@ -1,595 +0,0 @@
-/**
- * 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) {}
-
- private _isRealContent(codepoint: number): boolean {
- if (codepoint === 0) return false
- if (codepoint >= 0xf000) return false
- return true
- }
-
- private _findLastContentColumn(line: IBufferLine): number {
- let lastContent = -1
- for (let col = 0; col < line.length; col++) {
- const cell = line.getCell(col)
- if (cell && this._isRealContent(cell.getCode())) {
- lastContent = col
- }
- }
- return lastContent + 1
- }
-
- 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 maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
- const endLineColumn = Math.min(maxColumn, 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 _firstRow: number = 0
- private _lastCursorRow: number = 0
- private _lastCursorCol: number = 0
- private _lastContentCursorRow: number = 0
- private _lastContentCursorCol: number = 0
-
- constructor(
- buffer: IBuffer,
- private readonly _terminal: ITerminalCore,
- ) {
- super(buffer)
- this._cursorStyle = 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 {
- let rowSeparator = ""
-
- if (!isLastRow) {
- const nextLine = this._buffer.getLine(row + 1)
-
- if (!nextLine?.isWrapped) {
- rowSeparator = "\r\n"
- 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 (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)
- }
- }
- if (fgChanged) {
- const color = cell.getFgColor()
- const mode = cell.getFgColorMode()
- if (mode === 2 || mode === 3 || mode === -1) {
- 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 || mode === 3 || mode === -1) {
- 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)
- }
- }
- }
- }
-
- return sgrSeq
- }
-
- private _isAttributeDefault(cell: IBufferCell): boolean {
- const mode = cell.getFgColorMode()
- const bgMode = cell.getBgColorMode()
-
- if (mode === 0 && bgMode === 0) {
- return (
- !cell.isBold() &&
- !cell.isItalic() &&
- !cell.isUnderline() &&
- !cell.isBlink() &&
- !cell.isInverse() &&
- !cell.isInvisible() &&
- !cell.isDim() &&
- !cell.isStrikethrough()
- )
- }
-
- const fgColor = cell.getFgColor()
- const bgColor = cell.getBgColor()
- const nullCell = this._buffer.getNullCell()
- const nullFg = nullCell.getFgColor()
- const nullBg = nullCell.getBgColor()
-
- return (
- fgColor === nullFg &&
- bgColor === nullBg &&
- !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 codepoint = cell.getCode()
- const isGarbage = codepoint >= 0xf000
- const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
-
- const sgrSeq = this._diffStyle(cell, this._cursorStyle)
-
- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
-
- if (styleChanged) {
- if (this._nullCellCount > 0) {
- 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
- }
- }
-
- if (isEmptyCell) {
- this._nullCellCount += cell.getWidth()
- } else {
- if (this._nullCellCount > 0) {
- 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) {
- const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
- const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
- const cursorCol = this._buffer.cursorX + 1
- 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 normalBuffer = buffer.normal || buffer.active
- const altBuffer = buffer.alternate
-
- if (!normalBuffer) {
- return ""
- }
-
- let content = options?.range
- ? this._serializeBufferByRange(normalBuffer, options.range, true)
- : this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
-
- if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
- 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/app.tsx b/packages/desktop/src/app.tsx
deleted file mode 100644
index 11216643e..000000000
--- a/packages/desktop/src/app.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import "@/index.css"
-import { ErrorBoundary, Show } from "solid-js"
-import { Router, Route, Navigate } from "@solidjs/router"
-import { MetaProvider } from "@solidjs/meta"
-import { Font } from "@opencode-ai/ui/font"
-import { MarkedProvider } from "@opencode-ai/ui/context/marked"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
-import { Diff } from "@opencode-ai/ui/diff"
-import { Code } from "@opencode-ai/ui/code"
-import { GlobalSyncProvider } from "@/context/global-sync"
-import { LayoutProvider } from "@/context/layout"
-import { GlobalSDKProvider } from "@/context/global-sdk"
-import { TerminalProvider } from "@/context/terminal"
-import { PromptProvider } from "@/context/prompt"
-import { NotificationProvider } from "@/context/notification"
-import { DialogProvider } from "@opencode-ai/ui/context/dialog"
-import { CommandProvider } from "@/context/command"
-import Layout from "@/pages/layout"
-import Home from "@/pages/home"
-import DirectoryLayout from "@/pages/directory-layout"
-import Session from "@/pages/session"
-import { ErrorPage } from "./pages/error"
-import { iife } from "@opencode-ai/util/iife"
-
-declare global {
- interface Window {
- __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
- }
-}
-
-const url = iife(() => {
- const param = new URLSearchParams(document.location.search).get("url")
- if (param) return param
-
- if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
- if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
- if (import.meta.env.DEV)
- return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
-
- return "http://localhost:4096"
-})
-
-export function App() {
- return (
-
-
- }>
-
-
-
-
-
-
-
-
- (
-
- {props.children}
-
- )}
- >
-
-
- } />
- (
-
-
-
-
-
-
-
- )}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx
deleted file mode 100644
index 789a5d3b7..000000000
--- a/packages/desktop/src/components/dialog-connect-provider.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
-import { Button } from "@opencode-ai/ui/button"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
-import { List, type ListRef } from "@opencode-ai/ui/list"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { Spinner } from "@opencode-ai/ui/spinner"
-import { TextField } from "@opencode-ai/ui/text-field"
-import { showToast } from "@opencode-ai/ui/toast"
-import { iife } from "@opencode-ai/util/iife"
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { Link } from "@/components/link"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
-import { DialogSelectModel } from "./dialog-select-model"
-import { DialogSelectProvider } from "./dialog-select-provider"
-
-export function DialogConnectProvider(props: { provider: string }) {
- const dialog = useDialog()
- const globalSync = useGlobalSync()
- const globalSDK = useGlobalSDK()
- const platform = usePlatform()
- const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
- const methods = createMemo(
- () =>
- globalSync.data.provider_auth[props.provider] ?? [
- {
- type: "api",
- label: "API key",
- },
- ],
- )
- const [store, setStore] = createStore({
- methodIndex: undefined as undefined | number,
- authorization: undefined as undefined | ProviderAuthAuthorization,
- state: "pending" as undefined | "pending" | "complete" | "error",
- error: undefined as string | undefined,
- })
-
- const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
-
- async function selectMethod(index: number) {
- const method = methods()[index]
- setStore(
- produce((draft) => {
- draft.methodIndex = index
- draft.authorization = undefined
- draft.state = undefined
- draft.error = undefined
- }),
- )
-
- if (method.type === "oauth") {
- setStore("state", "pending")
- const start = Date.now()
- await globalSDK.client.provider.oauth
- .authorize(
- {
- providerID: props.provider,
- method: index,
- },
- { throwOnError: true },
- )
- .then((x) => {
- const elapsed = Date.now() - start
- const delay = 1000 - elapsed
-
- if (delay > 0) {
- setTimeout(() => {
- setStore("state", "complete")
- setStore("authorization", x.data!)
- }, delay)
- return
- }
- setStore("state", "complete")
- setStore("authorization", x.data!)
- })
- .catch((e) => {
- setStore("state", "error")
- setStore("error", String(e))
- })
- }
- }
-
- let listRef: ListRef | undefined
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
- return
- }
- if (e.key === "Escape") return
- listRef?.onKeyDown(e)
- }
-
- onMount(() => {
- if (methods().length === 1) {
- selectMethod(0)
- }
- document.addEventListener("keydown", handleKey)
- onCleanup(() => {
- document.removeEventListener("keydown", handleKey)
- })
- })
-
- async function complete() {
- await globalSDK.client.global.dispose()
- dialog.close()
- showToast({
- variant: "success",
- icon: "circle-check",
- title: `${provider().name} connected`,
- description: `${provider().name} models are now available to use.`,
- })
- }
-
- function goBack() {
- if (methods().length === 1) {
- dialog.show(() => )
- return
- }
- if (store.authorization) {
- setStore("authorization", undefined)
- setStore("methodIndex", undefined)
- return
- }
- if (store.methodIndex) {
- setStore("methodIndex", undefined)
- return
- }
- dialog.show(() => )
- }
-
- return (
- }>
-
-
-
-
-
-
- Login with Claude Pro/Max
-
- Connect {provider().name}
-
-
-
-
-
-
- Select login method for {provider().name}.
-
-
{
- listRef = ref
- }}
- items={methods}
- key={(m) => m?.label}
- onSelect={async (method, index) => {
- if (!method) return
- selectMethod(index)
- }}
- >
- {(i) => (
-
- )}
-
-
-
-
-
-
-
- Authorization in progress...
-
-
-
-
-
-
-
- Authorization failed: {store.error}
-
-
-
-
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
-
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const apiKey = formData.get("apiKey") as string
-
- if (!apiKey?.trim()) {
- setFormStore("error", "API key is required")
- return
- }
-
- setFormStore("error", undefined)
- await globalSDK.client.auth.set({
- providerID: props.provider,
- auth: {
- type: "api",
- key: apiKey,
- },
- })
- await complete()
- }
-
- return (
-
-
-
-
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for coding
- agents.
-
-
- With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
-
-
- Visit{" "}
-
- opencode.ai/zen
- {" "}
- to collect your API key.
-
-
-
-
-
- Enter your {provider().name} API key to connect your account and use {provider().name} models
- in OpenCode.
-
-
-
-
-
- )
- })}
-
-
-
-
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
-
- onMount(() => {
- if (store.authorization?.method === "code" && store.authorization?.url) {
- platform.openLink(store.authorization.url)
- }
- })
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
-
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const code = formData.get("code") as string
-
- if (!code?.trim()) {
- setFormStore("error", "Authorization code is required")
- return
- }
-
- setFormStore("error", undefined)
- const { error } = await globalSDK.client.provider.oauth.callback({
- providerID: props.provider,
- method: store.methodIndex,
- code,
- })
- if (!error) {
- await complete()
- return
- }
- setFormStore("error", "Invalid authorization code")
- }
-
- return (
-
-
- Visit this link to collect your authorization
- code to connect your account and use {provider().name} models in OpenCode.
-
-
-
- )
- })}
-
-
- {iife(() => {
- const code = createMemo(() => {
- const instructions = store.authorization?.instructions
- if (instructions?.includes(":")) {
- return instructions?.split(":")[1]?.trim()
- }
- return instructions
- })
-
- onMount(async () => {
- const result = await globalSDK.client.provider.oauth.callback({
- providerID: props.provider,
- method: store.methodIndex,
- })
- if (result.error) {
- // TODO: show error
- dialog.close()
- return
- }
- await complete()
- })
-
- return (
-
-
- Visit this link and enter the code below to
- connect your account and use {provider().name} models in OpenCode.
-
-
-
-
- Waiting for authorization...
-
-
- )
- })}
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx
deleted file mode 100644
index 66d125288..000000000
--- a/packages/desktop/src/components/dialog-manage-models.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { Switch } from "@opencode-ai/ui/switch"
-import type { Component } from "solid-js"
-import { useLocal } from "@/context/local"
-import { popularProviders } from "@/hooks/use-providers"
-
-export const DialogManageModels: Component = () => {
- const local = useLocal()
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx
deleted file mode 100644
index b27afdc8b..000000000
--- a/packages/desktop/src/components/dialog-select-file.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { List } from "@opencode-ai/ui/list"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
-import { createMemo } from "solid-js"
-import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
-
-export function DialogSelectFile() {
- const layout = useLayout()
- const local = useLocal()
- const dialog = useDialog()
- const params = useParams()
- const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
- const tabs = createMemo(() => layout.tabs(sessionKey()))
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx
deleted file mode 100644
index 24ec8092d..000000000
--- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { Button } from "@opencode-ai/ui/button"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
-import { List, type ListRef } from "@opencode-ai/ui/list"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { Tag } from "@opencode-ai/ui/tag"
-import { type Component, onCleanup, onMount, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
-
-export const DialogSelectModelUnpaid: Component = () => {
- const local = useLocal()
- const dialog = useDialog()
- const providers = useProviders()
-
- let listRef: ListRef | undefined
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") return
- listRef?.onKeyDown(e)
- }
-
- onMount(() => {
- document.addEventListener("keydown", handleKey)
- onCleanup(() => {
- document.removeEventListener("keydown", handleKey)
- })
- })
-
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx
deleted file mode 100644
index 54783386a..000000000
--- a/packages/desktop/src/components/dialog-select-model.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Component, createMemo, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { popularProviders } from "@/hooks/use-providers"
-import { Button } from "@opencode-ai/ui/button"
-import { Tag } from "@opencode-ai/ui/tag"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogManageModels } from "./dialog-manage-models"
-
-export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
- const local = useLocal()
- const dialog = useDialog()
-
- const models = createMemo(() =>
- local.model
- .list()
- .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
- .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
- )
-
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx
deleted file mode 100644
index 5bbde5d41..000000000
--- a/packages/desktop/src/components/dialog-select-provider.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Component, Show } from "solid-js"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-import { Tag } from "@opencode-ai/ui/tag"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-
-export const DialogSelectProvider: Component = () => {
- const dialog = useDialog()
- const providers = useProviders()
-
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx
deleted file mode 100644
index 0841c71d1..000000000
--- a/packages/desktop/src/components/file-tree.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useLocal, type LocalFile } from "@/context/local"
-import { Collapsible } from "@opencode-ai/ui/collapsible"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
-import { Dynamic } from "solid-js/web"
-
-export default function FileTree(props: {
- path: string
- class?: string
- nodeClass?: string
- level?: number
- onFileClick?: (file: LocalFile) => void
-}) {
- const local = useLocal()
- const level = props.level ?? 0
-
- const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
- {
- const evt = e as globalThis.DragEvent
- evt.dataTransfer!.effectAllowed = "copy"
- evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
-
- // Create custom drag image without margins
- const dragImage = document.createElement("div")
- dragImage.className =
- "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
- dragImage.style.position = "absolute"
- dragImage.style.top = "-1000px"
-
- // Copy only the icon and text content without padding
- const icon = e.currentTarget.querySelector("svg")
- const text = e.currentTarget.querySelector("span")
- if (icon && text) {
- dragImage.innerHTML = icon.outerHTML + text.outerHTML
- }
-
- document.body.appendChild(dragImage)
- evt.dataTransfer!.setDragImage(dragImage, 0, 12)
- setTimeout(() => document.body.removeChild(dragImage), 0)
- }}
- {...p}
- >
- {p.children}
-
- {p.node.name}
-
-
-
-
-
- )
-
- return (
-
-
- {(node) => (
-
-
-
- (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
- >
-
-
-
-
-
-
-
-
-
-
-
-
- props.onFileClick?.(node)}>
-
-
-
-
-
-
- )}
-
-
- )
-}
diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx
deleted file mode 100644
index ec7cdfa25..000000000
--- a/packages/desktop/src/components/header.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { useGlobalSync } from "@/context/global-sync"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { useLayout } from "@/context/layout"
-import { Session } from "@opencode-ai/sdk/v2/client"
-import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
-import { Mark } from "@opencode-ai/ui/logo"
-import { Popover } from "@opencode-ai/ui/popover"
-import { Select } from "@opencode-ai/ui/select"
-import { TextField } from "@opencode-ai/ui/text-field"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { base64Decode } from "@opencode-ai/util/encode"
-import { useCommand } from "@/context/command"
-import { getFilename } from "@opencode-ai/util/path"
-import { A, useParams } from "@solidjs/router"
-import { createMemo, createResource, Show } from "solid-js"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { iife } from "@opencode-ai/util/iife"
-
-export function Header(props: {
- navigateToProject: (directory: string) => void
- navigateToSession: (session: Session | undefined) => void
- onMobileMenuToggle?: () => void
-}) {
- const globalSync = useGlobalSync()
- const globalSDK = useGlobalSDK()
- const layout = useLayout()
- const params = useParams()
- const command = useCommand()
-
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx
deleted file mode 100644
index e13c31330..000000000
--- a/packages/desktop/src/components/link.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { ComponentProps, splitProps } from "solid-js"
-import { usePlatform } from "@/context/platform"
-
-export interface LinkProps extends ComponentProps<"button"> {
- href: string
-}
-
-export function Link(props: LinkProps) {
- const platform = usePlatform()
- const [local, rest] = splitProps(props, ["href", "children"])
-
- return (
-
- )
-}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
deleted file mode 100644
index 94d4ae97e..000000000
--- a/packages/desktop/src/components/prompt-input.tsx
+++ /dev/null
@@ -1,1153 +0,0 @@
-import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
-import { createStore, produce } from "solid-js/store"
-import { createFocusSignal } from "@solid-primitives/active-element"
-import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
-import { useLayout } from "@/context/layout"
-import { useSDK } from "@/context/sdk"
-import { useNavigate, useParams } from "@solidjs/router"
-import { useSync } from "@/context/sync"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Select } from "@opencode-ai/ui/select"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectModel } from "@/components/dialog-select-model"
-import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
-import { useProviders } from "@/hooks/use-providers"
-import { useCommand } from "@/context/command"
-import { persisted } from "@/utils/persist"
-import { Identifier } from "@/utils/id"
-import { SessionContextUsage } from "@/components/session-context-usage"
-
-const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
-const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
-
-interface PromptInputProps {
- class?: string
- ref?: (el: HTMLDivElement) => void
-}
-
-const PLACEHOLDERS = [
- "Fix a TODO in the codebase",
- "What is the tech stack of this project?",
- "Fix broken tests",
- "Explain how authentication works",
- "Find and fix security vulnerabilities",
- "Add unit tests for the user service",
- "Refactor this function to be more readable",
- "What does this error mean?",
- "Help me debug this issue",
- "Generate API documentation",
- "Optimize database queries",
- "Add input validation",
- "Create a new component for...",
- "How do I deploy this project?",
- "Review my code for best practices",
- "Add error handling to this function",
- "Explain this regex pattern",
- "Convert this to TypeScript",
- "Add logging throughout the codebase",
- "What dependencies are outdated?",
- "Help me write a migration script",
- "Implement caching for this endpoint",
- "Add pagination to this list",
- "Create a CLI command for...",
- "How do environment variables work here?",
-]
-
-interface SlashCommand {
- id: string
- trigger: string
- title: string
- description?: string
- keybind?: string
- type: "builtin" | "custom"
-}
-
-export const PromptInput: Component = (props) => {
- const navigate = useNavigate()
- const sdk = useSDK()
- const sync = useSync()
- const local = useLocal()
- const prompt = usePrompt()
- const layout = useLayout()
- const params = useParams()
- const dialog = useDialog()
- const providers = useProviders()
- const command = useCommand()
- let editorRef!: HTMLDivElement
- let fileInputRef!: HTMLInputElement
-
- const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
- const tabs = createMemo(() => layout.tabs(sessionKey()))
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
- const status = createMemo(
- () =>
- sync.data.session_status[params.id ?? ""] ?? {
- type: "idle",
- },
- )
- const working = createMemo(() => status()?.type !== "idle")
-
- const [store, setStore] = createStore<{
- popover: "file" | "slash" | null
- historyIndex: number
- savedPrompt: Prompt | null
- placeholder: number
- dragging: boolean
- imageAttachments: ImageAttachmentPart[]
- mode: "normal" | "shell"
- applyingHistory: boolean
- userHasEdited: boolean
- }>({
- popover: null,
- historyIndex: -1,
- savedPrompt: null,
- placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
- dragging: false,
- imageAttachments: [],
- mode: "normal",
- applyingHistory: false,
- userHasEdited: false,
- })
-
- const MAX_HISTORY = 100
- const [history, setHistory] = persisted(
- "prompt-history.v1",
- createStore<{
- entries: Prompt[]
- }>({
- entries: [],
- }),
- )
- const [shellHistory, setShellHistory] = persisted(
- "prompt-history-shell.v1",
- createStore<{
- entries: Prompt[]
- }>({
- entries: [],
- }),
- )
-
- const clonePromptParts = (prompt: Prompt): Prompt =>
- prompt.map((part) => {
- if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
- return {
- ...part,
- selection: part.selection ? { ...part.selection } : undefined,
- }
- })
-
- const promptLength = (prompt: Prompt) =>
- prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
-
- const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
- const length = position === "start" ? 0 : promptLength(p)
- setStore("applyingHistory", true)
- setStore("userHasEdited", false)
- prompt.set(p, length)
- requestAnimationFrame(() => {
- editorRef.focus()
- setCursorPosition(editorRef, length)
- setStore("applyingHistory", false)
- })
- }
-
- const getCaretState = () => {
- const selection = window.getSelection()
- const textLength = promptLength(prompt.current())
- if (!selection || selection.rangeCount === 0) {
- return { collapsed: false, cursorPosition: 0, textLength }
- }
- const anchorNode = selection.anchorNode
- if (!anchorNode || !editorRef.contains(anchorNode)) {
- return { collapsed: false, cursorPosition: 0, textLength }
- }
- return {
- collapsed: selection.isCollapsed,
- cursorPosition: getCursorPosition(editorRef),
- textLength,
- }
- }
-
- createEffect(() => {
- params.id
- editorRef.focus()
- if (params.id) return
- const interval = setInterval(() => {
- setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
- }, 6500)
- onCleanup(() => clearInterval(interval))
- })
-
- const isFocused = createFocusSignal(() => editorRef)
-
- const addImageAttachment = async (file: File) => {
- if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
-
- const reader = new FileReader()
- reader.onload = () => {
- const dataUrl = reader.result as string
- const attachment: ImageAttachmentPart = {
- type: "image",
- id: crypto.randomUUID(),
- filename: file.name,
- mime: file.type,
- dataUrl,
- }
- setStore(
- produce((draft) => {
- draft.imageAttachments.push(attachment)
- }),
- )
- }
- reader.readAsDataURL(file)
- }
-
- const removeImageAttachment = (id: string) => {
- setStore(
- produce((draft) => {
- draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
- }),
- )
- }
-
- const handlePaste = async (event: ClipboardEvent) => {
- const clipboardData = event.clipboardData
- if (!clipboardData) return
-
- const items = Array.from(clipboardData.items)
- const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
-
- if (imageItems.length > 0) {
- event.preventDefault()
- event.stopPropagation()
- for (const item of imageItems) {
- const file = item.getAsFile()
- if (file) await addImageAttachment(file)
- }
- return
- }
-
- event.preventDefault()
- event.stopPropagation()
- const plainText = clipboardData.getData("text/plain") ?? ""
- addPart({ type: "text", content: plainText, start: 0, end: 0 })
- }
-
- const handleDragOver = (event: DragEvent) => {
- event.preventDefault()
- const hasFiles = event.dataTransfer?.types.includes("Files")
- if (hasFiles) {
- setStore("dragging", true)
- }
- }
-
- const handleDragLeave = (event: DragEvent) => {
- const related = event.relatedTarget as Node | null
- const form = event.currentTarget as HTMLElement
- if (!related || !form.contains(related)) {
- setStore("dragging", false)
- }
- }
-
- const handleDrop = async (event: DragEvent) => {
- event.preventDefault()
- setStore("dragging", false)
-
- const files = event.dataTransfer?.files
- if (!files) return
-
- for (const file of Array.from(files)) {
- if (ACCEPTED_FILE_TYPES.includes(file.type)) {
- await addImageAttachment(file)
- }
- }
- }
-
- onMount(() => {
- editorRef.addEventListener("paste", handlePaste)
- })
- onCleanup(() => {
- editorRef.removeEventListener("paste", handlePaste)
- })
-
- createEffect(() => {
- if (isFocused()) {
- handleInput()
- } else {
- setStore("popover", null)
- }
- })
-
- const handleFileSelect = (path: string | undefined) => {
- if (!path) return
- addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
- }
-
- const { flat, active, onInput, onKeyDown } = useFilteredList({
- items: local.file.searchFilesAndDirectories,
- key: (x) => x,
- onSelect: handleFileSelect,
- })
-
- const slashCommands = createMemo(() => {
- const builtin = command.options
- .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
- .map((opt) => ({
- id: opt.id,
- trigger: opt.slash!,
- title: opt.title,
- description: opt.description,
- keybind: opt.keybind,
- type: "builtin" as const,
- }))
-
- const custom = sync.data.command.map((cmd) => ({
- id: `custom.${cmd.name}`,
- trigger: cmd.name,
- title: cmd.name,
- description: cmd.description,
- type: "custom" as const,
- }))
-
- return [...custom, ...builtin]
- })
-
- const handleSlashSelect = (cmd: SlashCommand | undefined) => {
- if (!cmd) return
- setStore("popover", null)
-
- if (cmd.type === "custom") {
- const text = `/${cmd.trigger} `
- editorRef.innerHTML = ""
- editorRef.textContent = text
- prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
- requestAnimationFrame(() => {
- editorRef.focus()
- const range = document.createRange()
- const sel = window.getSelection()
- range.selectNodeContents(editorRef)
- range.collapse(false)
- sel?.removeAllRanges()
- sel?.addRange(range)
- })
- return
- }
-
- editorRef.innerHTML = ""
- prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
- command.trigger(cmd.id, "slash")
- }
-
- const {
- flat: slashFlat,
- active: slashActive,
- onInput: slashOnInput,
- onKeyDown: slashOnKeyDown,
- } = useFilteredList({
- items: slashCommands,
- key: (x) => x?.id,
- filterKeys: ["trigger", "title", "description"],
- onSelect: handleSlashSelect,
- })
-
- createEffect(
- on(
- () => prompt.current(),
- (currentParts) => {
- const domParts = parseFromDOM()
- if (isPromptEqual(currentParts, domParts)) return
-
- const selection = window.getSelection()
- let cursorPosition: number | null = null
- if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
- cursorPosition = getCursorPosition(editorRef)
- }
-
- editorRef.innerHTML = ""
- currentParts.forEach((part) => {
- if (part.type === "text") {
- editorRef.appendChild(document.createTextNode(part.content))
- } else if (part.type === "file") {
- const pill = document.createElement("span")
- pill.textContent = part.content
- pill.setAttribute("data-type", "file")
- pill.setAttribute("data-path", part.path)
- pill.setAttribute("contenteditable", "false")
- pill.style.userSelect = "text"
- pill.style.cursor = "default"
- editorRef.appendChild(pill)
- }
- })
-
- if (cursorPosition !== null) {
- setCursorPosition(editorRef, cursorPosition)
- }
- },
- ),
- )
-
- const parseFromDOM = (): Prompt => {
- const newParts: Prompt = []
- let position = 0
-
- const pushText = (content: string) => {
- if (!content) return
- newParts.push({ type: "text", content, start: position, end: position + content.length })
- position += content.length
- }
-
- const rangeText = (range: Range) => {
- const fragment = range.cloneContents()
- const container = document.createElement("div")
- container.append(fragment)
- return container.innerText
- }
-
- const files = Array.from(editorRef.querySelectorAll("[data-type=file]"))
- let last: HTMLElement | undefined
-
- files.forEach((file) => {
- const before = document.createRange()
- before.selectNodeContents(editorRef)
- if (last) before.setStartAfter(last)
- before.setEndBefore(file)
- pushText(rangeText(before))
-
- const content = file.textContent ?? ""
- newParts.push({
- type: "file",
- path: file.dataset.path!,
- content,
- start: position,
- end: position + content.length,
- })
- position += content.length
- last = file
- })
-
- const after = document.createRange()
- after.selectNodeContents(editorRef)
- if (last) after.setStartAfter(last)
- pushText(rangeText(after))
-
- if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
- return newParts
- }
-
- const handleInput = () => {
- const rawParts = parseFromDOM()
- const cursorPosition = getCursorPosition(editorRef)
- const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
- const trimmed = rawText.replace(/\u200B/g, "").trim()
- const hasNonText = rawParts.some((part) => part.type !== "text")
- const shouldReset = trimmed.length === 0 && !hasNonText
-
- if (shouldReset) {
- setStore("popover", null)
- setStore("userHasEdited", false)
- if (store.historyIndex >= 0 && !store.applyingHistory) {
- setStore("historyIndex", -1)
- setStore("savedPrompt", null)
- }
- if (prompt.dirty()) {
- prompt.set(DEFAULT_PROMPT, 0)
- }
- return
- }
-
- const shellMode = store.mode === "shell"
-
- if (!shellMode) {
- const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
- const slashMatch = rawText.match(/^\/(\S*)$/)
-
- if (atMatch) {
- onInput(atMatch[1])
- setStore("popover", "file")
- } else if (slashMatch) {
- slashOnInput(slashMatch[1])
- setStore("popover", "slash")
- } else {
- setStore("popover", null)
- }
- } else {
- setStore("popover", null)
- }
-
- if (store.historyIndex >= 0 && !store.applyingHistory) {
- setStore("historyIndex", -1)
- setStore("savedPrompt", null)
- }
-
- if (!store.applyingHistory) {
- setStore("userHasEdited", true)
- }
-
- prompt.set(rawParts, cursorPosition)
- }
-
- const addPart = (part: ContentPart) => {
- const selection = window.getSelection()
- if (!selection || selection.rangeCount === 0) return
-
- const cursorPosition = getCursorPosition(editorRef)
- const currentPrompt = prompt.current()
- const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
- const textBeforeCursor = rawText.substring(0, cursorPosition)
- const atMatch = textBeforeCursor.match(/@(\S*)$/)
-
- if (part.type === "file") {
- const pill = document.createElement("span")
- pill.textContent = part.content
- pill.setAttribute("data-type", "file")
- pill.setAttribute("data-path", part.path)
- pill.setAttribute("contenteditable", "false")
- pill.style.userSelect = "text"
- pill.style.cursor = "default"
-
- const gap = document.createTextNode(" ")
- const range = selection.getRangeAt(0)
-
- if (atMatch) {
- let runningLength = 0
-
- const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
- let currentNode = walker.nextNode()
- while (currentNode) {
- const textContent = currentNode.textContent || ""
- if (runningLength + textContent.length >= atMatch.index!) {
- const localStart = atMatch.index! - runningLength
- const localEnd = cursorPosition - runningLength
- if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
- range.setStart(currentNode, localStart)
- range.setEnd(currentNode, Math.min(localEnd, textContent.length))
- break
- }
- }
- runningLength += textContent.length
- currentNode = walker.nextNode()
- }
- }
-
- range.deleteContents()
- range.insertNode(gap)
- range.insertNode(pill)
- range.setStartAfter(gap)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
- } else if (part.type === "text") {
- const textNode = document.createTextNode(part.content)
- const range = selection.getRangeAt(0)
- range.deleteContents()
- range.insertNode(textNode)
- range.setStartAfter(textNode)
- range.collapse(true)
- selection.removeAllRanges()
- selection.addRange(range)
- }
-
- handleInput()
- setStore("popover", null)
- }
-
- const abort = () =>
- sdk.client.session.abort({
- sessionID: params.id!,
- })
-
- const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
- const text = prompt
- .map((p) => ("content" in p ? p.content : ""))
- .join("")
- .trim()
- if (!text) return
-
- const entry = clonePromptParts(prompt)
- const currentHistory = mode === "shell" ? shellHistory : history
- const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const lastEntry = currentHistory.entries[0]
- if (lastEntry) {
- const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
- if (lastText === text) return
- }
-
- setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
- }
-
- const navigateHistory = (direction: "up" | "down") => {
- if (store.userHasEdited) return false
-
- const entries = store.mode === "shell" ? shellHistory.entries : history.entries
- const current = store.historyIndex
-
- if (direction === "up") {
- if (entries.length === 0) return false
- if (current === -1) {
- setStore("savedPrompt", clonePromptParts(prompt.current()))
- setStore("historyIndex", 0)
- applyHistoryPrompt(entries[0], "start")
- return true
- }
- if (current < entries.length - 1) {
- const next = current + 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "start")
- return true
- }
- return false
- }
-
- if (current > 0) {
- const next = current - 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "end")
- return true
- }
- if (current === 0) {
- setStore("historyIndex", -1)
- const saved = store.savedPrompt
- if (saved) {
- applyHistoryPrompt(saved, "end")
- setStore("savedPrompt", null)
- return true
- }
- applyHistoryPrompt(DEFAULT_PROMPT, "end")
- return true
- }
-
- return false
- }
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "!" && store.mode === "normal") {
- const cursorPosition = getCursorPosition(editorRef)
- if (cursorPosition === 0) {
- setStore("mode", "shell")
- setStore("popover", null)
- event.preventDefault()
- return
- }
- }
- if (store.mode === "shell") {
- const { collapsed, cursorPosition, textLength } = getCaretState()
- if (event.key === "Escape") {
- setStore("mode", "normal")
- event.preventDefault()
- return
- }
- if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
- setStore("mode", "normal")
- event.preventDefault()
- return
- }
- }
-
- if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
- if (store.popover === "file") {
- onKeyDown(event)
- } else {
- slashOnKeyDown(event)
- }
- event.preventDefault()
- return
- }
-
- if (event.key === "ArrowUp" || event.key === "ArrowDown") {
- if (event.altKey || event.ctrlKey || event.metaKey) return
- const { collapsed } = getCaretState()
- if (!collapsed) return
-
- const cursorPosition = getCursorPosition(editorRef)
- const textLength = promptLength(prompt.current())
- const textContent = editorRef.textContent ?? ""
- const isEmpty = textContent.trim() === "" || textLength <= 1
- const hasNewlines = textContent.includes("\n")
- const inHistory = store.historyIndex >= 0
- const atStart = cursorPosition <= (isEmpty ? 1 : 0)
- const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
- const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
- const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
-
- if (event.key === "ArrowUp") {
- if (!allowUp) return
- if (navigateHistory("up")) {
- event.preventDefault()
- }
- return
- }
-
- if (!allowDown) return
- if (navigateHistory("down")) {
- event.preventDefault()
- }
- return
- }
-
- if (event.key === "Enter" && !event.shiftKey) {
- handleSubmit(event)
- }
- if (event.key === "Escape") {
- if (store.popover) {
- setStore("popover", null)
- } else if (working()) {
- abort()
- }
- }
- }
-
- const handleSubmit = async (event: Event) => {
- event.preventDefault()
- const currentPrompt = prompt.current()
- const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
- const hasImageAttachments = store.imageAttachments.length > 0
- if (text.trim().length === 0 && !hasImageAttachments) {
- if (working()) abort()
- return
- }
-
- addToHistory(currentPrompt, store.mode)
- setStore("historyIndex", -1)
- setStore("savedPrompt", null)
- setStore("userHasEdited", false)
-
- let existing = info()
- if (!existing) {
- const created = await sdk.client.session.create()
- existing = created.data ?? undefined
- if (existing) navigate(existing.id)
- }
- if (!existing) return
-
- const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
- const attachments = currentPrompt.filter(
- (part) => part.type === "file",
- ) as import("@/context/prompt").FileAttachmentPart[]
-
- const fileAttachmentParts = attachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: "text/plain",
- url: `file://${absolute}${query}`,
- filename: getFilename(attachment.path),
- source: {
- type: "file" as const,
- text: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- path: absolute,
- },
- }
- })
-
- const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- }))
-
- const isShellMode = store.mode === "shell"
- tabs().setActive(undefined)
- editorRef.innerHTML = ""
- prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
- setStore("imageAttachments", [])
- setStore("mode", "normal")
-
- const model = {
- modelID: local.model.current()!.id,
- providerID: local.model.current()!.provider.id,
- }
- const agent = local.agent.current()!.name
-
- if (isShellMode) {
- sdk.client.session.shell({
- sessionID: existing.id,
- agent,
- model,
- command: text,
- })
- return
- }
-
- if (text.startsWith("/")) {
- const [cmdName, ...args] = text.split(" ")
- const commandName = cmdName.slice(1)
- const customCommand = sync.data.command.find((c) => c.name === commandName)
- if (customCommand) {
- sdk.client.session.command({
- sessionID: existing.id,
- command: commandName,
- arguments: args.join(" "),
- agent,
- model: `${model.providerID}/${model.modelID}`,
- })
- return
- }
- }
-
- const messageID = Identifier.ascending("message")
- const textPart = {
- id: Identifier.ascending("part"),
- type: "text" as const,
- text,
- }
- const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
- const optimisticParts = requestParts.map((part) => ({
- ...part,
- sessionID: existing.id,
- messageID,
- }))
-
- sync.session.addOptimisticMessage({
- sessionID: existing.id,
- messageID,
- parts: optimisticParts,
- agent,
- model,
- })
-
- sdk.client.session.prompt({
- sessionID: existing.id,
- agent,
- model,
- messageID,
- parts: requestParts,
- })
- }
-
- return (
-
-
-
-
{
- editorRef = el
- props.ref?.(el)
- }}
- contenteditable="true"
- onInput={handleInput}
- onKeyDown={handleKeyDown}
- classList={{
- "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
- "[&>[data-type=file]]:text-icon-info-active": true,
- "font-mono!": store.mode === "shell",
- }}
- />
-
-
- {store.mode === "shell"
- ? "Enter shell command..."
- : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
-
-
-
-
-
-
-
-
-
- Shell
- esc to exit
-
-
-
-
- Cycle agent
- {command.keybind("agent.cycle")}
-
- }
- >
-
- }
- >
-
-
-
-
-
-
-
-
{
- const file = e.currentTarget.files?.[0]
- if (file) addImageAttachment(file)
- e.currentTarget.value = ""
- }}
- />
-
-
- fileInputRef.click()}
- />
-
-
-
-
-
- Stop
- ESC
-
-
-
-
- Send
-
-
-
-
- }
- >
-
-
-
-
-
-
- )
-}
-
-function getCursorPosition(parent: HTMLElement): number {
- const selection = window.getSelection()
- if (!selection || selection.rangeCount === 0) return 0
- const range = selection.getRangeAt(0)
- const preCaretRange = range.cloneRange()
- preCaretRange.selectNodeContents(parent)
- preCaretRange.setEnd(range.startContainer, range.startOffset)
- return preCaretRange.toString().length
-}
-
-function setCursorPosition(parent: HTMLElement, position: number) {
- let remaining = position
- let node = parent.firstChild
- while (node) {
- const length = node.textContent ? node.textContent.length : 0
- const isText = node.nodeType === Node.TEXT_NODE
- const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
-
- if (isText && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- range.setStart(node, remaining)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- if (isFile && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- range.setStartAfter(node)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- remaining -= length
- node = node.nextSibling
- }
-
- const fallbackRange = document.createRange()
- const fallbackSelection = window.getSelection()
- const last = parent.lastChild
- if (last && last.nodeType === Node.TEXT_NODE) {
- const len = last.textContent ? last.textContent.length : 0
- fallbackRange.setStart(last, len)
- }
- if (!last || last.nodeType !== Node.TEXT_NODE) {
- fallbackRange.selectNodeContents(parent)
- }
- fallbackRange.collapse(false)
- fallbackSelection?.removeAllRanges()
- fallbackSelection?.addRange(fallbackRange)
-}
diff --git a/packages/desktop/src/components/session-context-usage.tsx b/packages/desktop/src/components/session-context-usage.tsx
deleted file mode 100644
index 5474005c7..000000000
--- a/packages/desktop/src/components/session-context-usage.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { createMemo, Show } from "solid-js"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
-import { useSync } from "@/context/sync"
-import { useParams } from "@solidjs/router"
-import { AssistantMessage } from "@opencode-ai/sdk/v2"
-
-export function SessionContextUsage() {
- const sync = useSync()
- const params = useParams()
- const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-
- const cost = createMemo(() => {
- const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(total)
- })
-
- const context = createMemo(() => {
- const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
- if (!last) return
- const total =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
- const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
- return {
- tokens: total.toLocaleString(),
- percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
- }
- })
-
- return (
-
- {(ctx) => (
-
-
- Tokens
- {ctx().tokens}
-
-
- Usage
- {ctx().percentage ?? 0}%
-
-
- Cost
- {cost()}
-
-
- }
- placement="top"
- >
-
-
{`${ctx().percentage ?? 0}%`}
-
-
-
- )}
-
- )
-}
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
deleted file mode 100644
index c05ddfbf6..000000000
--- a/packages/desktop/src/components/terminal.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
-import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
-import { useSDK } from "@/context/sdk"
-import { SerializeAddon } from "@/addons/serialize"
-import { LocalPTY } from "@/context/terminal"
-import { usePrefersDark } from "@solid-primitives/media"
-
-export interface TerminalProps extends ComponentProps<"div"> {
- pty: LocalPTY
- onSubmit?: () => void
- onCleanup?: (pty: LocalPTY) => void
- onConnectError?: (error: unknown) => void
-}
-
-export const Terminal = (props: TerminalProps) => {
- const sdk = useSDK()
- let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
- let ws: WebSocket
- let term: Term
- let ghostty: Ghostty
- let serializeAddon: SerializeAddon
- let fitAddon: FitAddon
- let handleResize: () => void
- const prefersDark = usePrefersDark()
-
- onMount(async () => {
- ghostty = await Ghostty.load()
-
- ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
- term = new Term({
- cursorBlink: true,
- fontSize: 14,
- fontFamily: "IBM Plex Mono, monospace",
- allowTransparency: true,
- theme: prefersDark()
- ? {
- background: "#191515",
- foreground: "#d4d4d4",
- cursor: "#d4d4d4",
- }
- : {
- background: "#fcfcfc",
- foreground: "#211e1e",
- cursor: "#211e1e",
- },
- scrollback: 10_000,
- ghostty,
- })
- term.attachCustomKeyEventHandler((event) => {
- // allow for ctrl-` to toggle terminal in parent
- if (event.ctrlKey && event.key.toLowerCase() === "`") {
- event.preventDefault()
- return true
- }
- return false
- })
-
- fitAddon = new FitAddon()
- serializeAddon = new SerializeAddon()
- term.loadAddon(serializeAddon)
- term.loadAddon(fitAddon)
-
- term.open(container)
-
- if (local.pty.buffer) {
- if (local.pty.rows && local.pty.cols) {
- term.resize(local.pty.cols, local.pty.rows)
- }
- term.reset()
- term.write(local.pty.buffer)
- if (local.pty.scrollY) {
- term.scrollToLine(local.pty.scrollY)
- }
- fitAddon.fit()
- }
-
- container.focus()
-
- fitAddon.observeResize()
- handleResize = () => fitAddon.fit()
- window.addEventListener("resize", handleResize)
- term.onResize(async (size) => {
- if (ws && ws.readyState === WebSocket.OPEN) {
- await sdk.client.pty.update({
- ptyID: local.pty.id,
- size: {
- cols: size.cols,
- rows: size.rows,
- },
- })
- }
- })
- term.onData((data) => {
- if (ws && ws.readyState === WebSocket.OPEN) {
- 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({
- ptyID: local.pty.id,
- size: {
- cols: term.cols,
- rows: term.rows,
- },
- })
- })
- ws.addEventListener("message", (event) => {
- term.write(event.data)
- })
- ws.addEventListener("error", (error) => {
- console.error("WebSocket error:", error)
- props.onConnectError?.(error)
- })
- ws.addEventListener("close", () => {
- console.log("WebSocket disconnected")
- })
- })
-
- onCleanup(() => {
- if (handleResize) {
- window.removeEventListener("resize", handleResize)
- }
- 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 (
-
- )
-}
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
deleted file mode 100644
index f91a1cf05..000000000
--- a/packages/desktop/src/context/command.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
-
-const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
-
-export type KeybindConfig = string
-
-export interface Keybind {
- key: string
- ctrl: boolean
- meta: boolean
- shift: boolean
- alt: boolean
-}
-
-export interface CommandOption {
- id: string
- title: string
- description?: string
- category?: string
- keybind?: KeybindConfig
- slash?: string
- suggested?: boolean
- disabled?: boolean
- onSelect?: (source?: "palette" | "keybind" | "slash") => void
-}
-
-export function parseKeybind(config: string): Keybind[] {
- if (!config || config === "none") return []
-
- return config.split(",").map((combo) => {
- const parts = combo.trim().toLowerCase().split("+")
- const keybind: Keybind = {
- key: "",
- ctrl: false,
- meta: false,
- shift: false,
- alt: false,
- }
-
- for (const part of parts) {
- switch (part) {
- case "ctrl":
- case "control":
- keybind.ctrl = true
- break
- case "meta":
- case "cmd":
- case "command":
- keybind.meta = true
- break
- case "mod":
- if (IS_MAC) keybind.meta = true
- else keybind.ctrl = true
- break
- case "alt":
- case "option":
- keybind.alt = true
- break
- case "shift":
- keybind.shift = true
- break
- default:
- keybind.key = part
- break
- }
- }
-
- return keybind
- })
-}
-
-export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
- const eventKey = event.key.toLowerCase()
-
- for (const kb of keybinds) {
- const keyMatch = kb.key === eventKey
- const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
- const metaMatch = kb.meta === (event.metaKey || false)
- const shiftMatch = kb.shift === (event.shiftKey || false)
- const altMatch = kb.alt === (event.altKey || false)
-
- if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
- return true
- }
- }
-
- return false
-}
-
-export function formatKeybind(config: string): string {
- if (!config || config === "none") return ""
-
- const keybinds = parseKeybind(config)
- if (keybinds.length === 0) return ""
-
- const kb = keybinds[0]
- const parts: string[] = []
-
- if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
- if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
- if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
- if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
-
- if (kb.key) {
- const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
- parts.push(displayKey)
- }
-
- return IS_MAC ? parts.join("") : parts.join("+")
-}
-
-function DialogCommand(props: { options: CommandOption[] }) {
- const dialog = useDialog()
-
- return (
-
- )
-}
-
-export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
- name: "Command",
- init: () => {
- const [registrations, setRegistrations] = createSignal[]>([])
- const [suspendCount, setSuspendCount] = createSignal(0)
- const dialog = useDialog()
-
- const options = createMemo(() => {
- const all = registrations().flatMap((x) => x())
- const suggested = all.filter((x) => x.suggested && !x.disabled)
- return [
- ...suggested.map((x) => ({
- ...x,
- id: "suggested." + x.id,
- category: "Suggested",
- })),
- ...all,
- ]
- })
-
- const suspended = () => suspendCount() > 0
-
- const showPalette = () => {
- if (!dialog.active) {
- dialog.show(() => !x.disabled)} />)
- }
- }
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (suspended()) return
-
- const paletteKeybinds = parseKeybind("mod+shift+p")
- if (matchKeybind(paletteKeybinds, event)) {
- event.preventDefault()
- showPalette()
- return
- }
-
- for (const option of options()) {
- if (option.disabled) continue
- if (!option.keybind) continue
-
- const keybinds = parseKeybind(option.keybind)
- if (matchKeybind(keybinds, event)) {
- event.preventDefault()
- option.onSelect?.("keybind")
- return
- }
- }
- }
-
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
-
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
-
- return {
- register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setRegistrations((arr) => [results, ...arr])
- onCleanup(() => {
- setRegistrations((arr) => arr.filter((x) => x !== results))
- })
- },
- trigger(id: string, source?: "palette" | "keybind" | "slash") {
- for (const option of options()) {
- if (option.id === id || option.id === "suggested." + id) {
- option.onSelect?.(source)
- return
- }
- }
- },
- keybind(id: string) {
- const option = options().find((x) => x.id === id || x.id === "suggested." + id)
- if (!option?.keybind) return ""
- return formatKeybind(option.keybind)
- },
- show: showPalette,
- keybinds(enabled: boolean) {
- setSuspendCount((count) => count + (enabled ? -1 : 1))
- },
- suspended,
- get options() {
- return options()
- },
- }
- },
-})
diff --git a/packages/desktop/src/context/global-sdk.tsx b/packages/desktop/src/context/global-sdk.tsx
deleted file mode 100644
index 3732ca085..000000000
--- a/packages/desktop/src/context/global-sdk.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { usePlatform } from "./platform"
-
-export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
- name: "GlobalSDK",
- init: (props: { url: string }) => {
- const eventSdk = createOpencodeClient({
- baseUrl: props.url,
- // signal: AbortSignal.timeout(1000 * 60 * 10),
- })
- const emitter = createGlobalEmitter<{
- [key: string]: Event
- }>()
-
- eventSdk.global.event().then(async (events) => {
- for await (const event of events.stream) {
- // console.log("event", event)
- emitter.emit(event.directory ?? "global", event.payload)
- }
- })
-
- const platform = usePlatform()
- const sdk = createOpencodeClient({
- baseUrl: props.url,
- signal: AbortSignal.timeout(1000 * 60 * 10),
- fetch: platform.fetch,
- throwOnError: true,
- })
-
- return { url: props.url, client: sdk, event: emitter }
- },
-})
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
deleted file mode 100644
index ae40555d6..000000000
--- a/packages/desktop/src/context/global-sync.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import {
- type Message,
- type Agent,
- type Session,
- type Part,
- type Config,
- type Path,
- type File,
- type FileNode,
- type Project,
- type FileDiff,
- type Todo,
- type SessionStatus,
- type ProviderListResponse,
- type ProviderAuthResponse,
- type Command,
- createOpencodeClient,
-} from "@opencode-ai/sdk/v2/client"
-import { createStore, produce, reconcile } from "solid-js/store"
-import { Binary } from "@opencode-ai/util/binary"
-import { retry } from "@opencode-ai/util/retry"
-import { useGlobalSDK } from "./global-sdk"
-import { ErrorPage, type InitError } from "../pages/error"
-import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
-import { showToast } from "@opencode-ai/ui/toast"
-import { getFilename } from "@opencode-ai/util/path"
-
-type State = {
- ready: boolean
- agent: Agent[]
- command: Command[]
- project: string
- provider: ProviderListResponse
- config: Config
- path: Path
- session: Session[]
- session_status: {
- [sessionID: string]: SessionStatus
- }
- session_diff: {
- [sessionID: string]: FileDiff[]
- }
- todo: {
- [sessionID: string]: Todo[]
- }
- limit: number
- message: {
- [sessionID: string]: Message[]
- }
- part: {
- [messageID: string]: Part[]
- }
- node: FileNode[]
- changes: File[]
-}
-
-function createGlobalSync() {
- const globalSDK = useGlobalSDK()
- const [globalStore, setGlobalStore] = createStore<{
- ready: boolean
- error?: InitError
- path: Path
- project: Project[]
- provider: ProviderListResponse
- provider_auth: ProviderAuthResponse
- children: Record
- }>({
- ready: false,
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- project: [],
- provider: { all: [], connected: [], default: {} },
- provider_auth: {},
- children: {},
- })
-
- const children: Record>> = {}
- function child(directory: string) {
- if (!directory) console.error("No directory provided")
- if (!children[directory]) {
- setGlobalStore("children", directory, {
- project: "",
- provider: { all: [], connected: [], default: {} },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- ready: false,
- agent: [],
- command: [],
- session: [],
- session_status: {},
- session_diff: {},
- todo: {},
- limit: 5,
- message: {},
- part: {},
- node: [],
- changes: [],
- })
- children[directory] = createStore(globalStore.children[directory])
- bootstrapInstance(directory)
- }
- return children[directory]
- }
-
- async function loadSessions(directory: string) {
- const [store, setStore] = child(directory)
- globalSDK.client.session
- .list({ directory })
- .then((x) => {
- const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
- const nonArchived = (x.data ?? [])
- .slice()
- .filter((s) => !s.time.archived)
- .sort((a, b) => a.id.localeCompare(b.id))
- // Include up to the limit, plus any updated in the last 4 hours
- const sessions = nonArchived.filter((s, i) => {
- if (i < store.limit) return true
- const updated = new Date(s.time.updated).getTime()
- return updated > fourHoursAgo
- })
- setStore("session", sessions)
- })
- .catch((err) => {
- console.error("Failed to load sessions", err)
- const project = getFilename(directory)
- showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
- })
- }
-
- async function bootstrapInstance(directory: string) {
- if (!directory) return
- const [, setStore] = child(directory)
- const sdk = createOpencodeClient({
- baseUrl: globalSDK.url,
- directory,
- throwOnError: true,
- })
- const load = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
- path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
- agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
- command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
- session: () => loadSessions(directory),
- status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
- config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
- changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
- node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
- }
- await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
- .then(() => setStore("ready", true))
- .catch((e) => setGlobalStore("error", e))
- }
-
- globalSDK.event.listen((e) => {
- const directory = e.name
- const event = e.details
-
- if (directory === "global") {
- switch (event?.type) {
- case "global.disposed": {
- bootstrap()
- break
- }
- case "project.updated": {
- const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
- if (result.found) {
- setGlobalStore("project", result.index, reconcile(event.properties))
- return
- }
- setGlobalStore(
- "project",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- }
- return
- }
-
- const [store, setStore] = child(directory)
- switch (event.type) {
- case "server.instance.disposed": {
- bootstrapInstance(directory)
- break
- }
- case "session.updated": {
- const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
- if (event.properties.info.time.archived) {
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- break
- }
- if (result.found) {
- setStore("session", result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "session.diff":
- setStore("session_diff", event.properties.sessionID, event.properties.diff)
- break
- case "todo.updated":
- setStore("todo", event.properties.sessionID, event.properties.todos)
- break
- case "session.status": {
- setStore("session_status", event.properties.sessionID, event.properties.status)
- break
- }
- case "message.updated": {
- const messages = store.message[event.properties.info.sessionID]
- if (!messages) {
- setStore("message", event.properties.info.sessionID, [event.properties.info])
- break
- }
- const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
- if (result.found) {
- setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "message",
- event.properties.info.sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "message.removed": {
- const messages = store.message[event.properties.sessionID]
- if (!messages) break
- const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
- if (result.found) {
- setStore(
- "message",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- break
- }
- case "message.part.updated": {
- const part = event.properties.part
- const parts = store.part[part.messageID]
- if (!parts) {
- setStore("part", part.messageID, [part])
- break
- }
- const result = Binary.search(parts, part.id, (p) => p.id)
- if (result.found) {
- setStore("part", part.messageID, result.index, reconcile(part))
- break
- }
- setStore(
- "part",
- part.messageID,
- produce((draft) => {
- draft.splice(result.index, 0, part)
- }),
- )
- break
- }
- case "message.part.removed": {
- const parts = store.part[event.properties.messageID]
- if (!parts) break
- const result = Binary.search(parts, event.properties.partID, (p) => p.id)
- if (result.found) {
- setStore(
- "part",
- event.properties.messageID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- break
- }
- }
- })
-
- async function bootstrap() {
- const health = await globalSDK.client.global.health().then((x) => x.data)
- if (!health?.healthy) {
- setGlobalStore(
- "error",
- new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
- )
- return
- }
-
- return Promise.all([
- retry(() =>
- globalSDK.client.path.get().then((x) => {
- setGlobalStore("path", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.project.list().then(async (x) => {
- setGlobalStore(
- "project",
- x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
- )
- }),
- ),
- retry(() =>
- globalSDK.client.provider.list().then((x) => {
- setGlobalStore("provider", x.data ?? {})
- }),
- ),
- retry(() =>
- globalSDK.client.provider.auth().then((x) => {
- setGlobalStore("provider_auth", x.data ?? {})
- }),
- ),
- ])
- .then(() => setGlobalStore("ready", true))
- .catch((e) => setGlobalStore("error", e))
- }
-
- onMount(() => {
- bootstrap()
- })
-
- return {
- data: globalStore,
- get ready() {
- return globalStore.ready
- },
- get error() {
- return globalStore.error
- },
- child,
- bootstrap,
- project: {
- loadSessions,
- },
- }
-}
-
-const GlobalSyncContext = createContext>()
-
-export function GlobalSyncProvider(props: ParentProps) {
- const value = createGlobalSync()
- return (
-
-
-
-
-
- {props.children}
-
-
- )
-}
-
-export function useGlobalSync() {
- const context = useContext(GlobalSyncContext)
- if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
- return context
-}
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
deleted file mode 100644
index c6ba5fef5..000000000
--- a/packages/desktop/src/context/layout.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import { createStore, produce } from "solid-js/store"
-import { batch, createMemo, onMount } from "solid-js"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useGlobalSync } from "./global-sync"
-import { useGlobalSDK } from "./global-sdk"
-import { Project } from "@opencode-ai/sdk/v2"
-import { persisted } from "@/utils/persist"
-
-const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
-export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
-
-export function getAvatarColors(key?: string) {
- if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
- return {
- background: `var(--avatar-background-${key})`,
- foreground: `var(--avatar-text-${key})`,
- }
- }
- return {
- background: "var(--surface-info-base)",
- foreground: "var(--text-base)",
- }
-}
-
-type SessionTabs = {
- active?: string
- all: string[]
-}
-
-export type LocalProject = Partial & { worktree: string; expanded: boolean }
-
-export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
- name: "Layout",
- init: () => {
- const globalSdk = useGlobalSDK()
- const globalSync = useGlobalSync()
- const [store, setStore, _, ready] = persisted(
- "layout.v3",
- createStore({
- projects: [] as { worktree: string; expanded: boolean }[],
- sidebar: {
- opened: false,
- width: 280,
- },
- terminal: {
- opened: false,
- height: 280,
- },
- review: {
- opened: true,
- },
- session: {
- width: 600,
- },
- sessionTabs: {} as Record,
- }),
- )
-
- const usedColors = new Set()
-
- function pickAvailableColor(): AvatarColorKey {
- const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
- if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
- return available[Math.floor(Math.random() * available.length)]
- }
-
- function enrich(project: { worktree: string; expanded: boolean }) {
- const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
- return [
- {
- ...project,
- ...(metadata ?? {}),
- },
- ]
- }
-
- function colorize(project: LocalProject) {
- if (project.icon?.color) return project
- const color = pickAvailableColor()
- usedColors.add(color)
- project.icon = { ...project.icon, color }
- if (project.id) {
- globalSdk.client.project.update({ projectID: project.id, icon: { color } })
- }
- return project
- }
-
- const enriched = createMemo(() => store.projects.flatMap(enrich))
- const list = createMemo(() => enriched().flatMap(colorize))
-
- onMount(() => {
- Promise.all(
- store.projects.map((project) => {
- return globalSync.project.loadSessions(project.worktree)
- }),
- )
- })
-
- return {
- ready,
- projects: {
- list,
- open(directory: string) {
- if (store.projects.find((x) => x.worktree === directory)) {
- return
- }
- globalSync.project.loadSessions(directory)
- setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
- },
- close(directory: string) {
- setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
- },
- expand(directory: string) {
- const index = store.projects.findIndex((x) => x.worktree === directory)
- if (index !== -1) setStore("projects", index, "expanded", true)
- },
- collapse(directory: string) {
- const index = store.projects.findIndex((x) => x.worktree === directory)
- if (index !== -1) setStore("projects", index, "expanded", false)
- },
- move(directory: string, toIndex: number) {
- setStore("projects", (projects) => {
- const fromIndex = projects.findIndex((x) => x.worktree === directory)
- if (fromIndex === -1 || fromIndex === toIndex) return projects
- const result = [...projects]
- const [item] = result.splice(fromIndex, 1)
- result.splice(toIndex, 0, item)
- return result
- })
- },
- },
- sidebar: {
- opened: createMemo(() => store.sidebar.opened),
- open() {
- setStore("sidebar", "opened", true)
- },
- close() {
- setStore("sidebar", "opened", false)
- },
- toggle() {
- setStore("sidebar", "opened", (x) => !x)
- },
- width: createMemo(() => store.sidebar.width),
- resize(width: number) {
- setStore("sidebar", "width", width)
- },
- },
- terminal: {
- opened: createMemo(() => 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: {
- opened: createMemo(() => store.review?.opened ?? true),
- open() {
- setStore("review", "opened", true)
- },
- close() {
- setStore("review", "opened", false)
- },
- toggle() {
- setStore("review", "opened", (x) => !x)
- },
- },
- session: {
- width: createMemo(() => store.session?.width ?? 600),
- resize(width: number) {
- if (!store.session) {
- setStore("session", { width })
- } else {
- setStore("session", "width", width)
- }
- },
- },
- tabs(sessionKey: string) {
- const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
- return {
- tabs,
- active: createMemo(() => tabs().active),
- all: createMemo(() => tabs().all),
- setActive(tab: string | undefined) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [], active: tab })
- } else {
- setStore("sessionTabs", sessionKey, "active", tab)
- }
- },
- setAll(all: string[]) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all, active: undefined })
- } else {
- setStore("sessionTabs", sessionKey, "all", all)
- }
- },
- async open(tab: string) {
- const current = store.sessionTabs[sessionKey] ?? { all: [] }
- if (tab !== "review") {
- if (!current.all.includes(tab)) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
- } else {
- setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
- setStore("sessionTabs", sessionKey, "active", tab)
- }
- return
- }
- }
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [], active: tab })
- } else {
- setStore("sessionTabs", sessionKey, "active", tab)
- }
- },
- close(tab: string) {
- const current = store.sessionTabs[sessionKey]
- if (!current) return
- batch(() => {
- setStore(
- "sessionTabs",
- sessionKey,
- "all",
- current.all.filter((x) => x !== tab),
- )
- if (current.active === tab) {
- const index = current.all.findIndex((f) => f === tab)
- const previous = current.all[Math.max(0, index - 1)]
- setStore("sessionTabs", sessionKey, "active", previous)
- }
- })
- },
- move(tab: string, to: number) {
- const current = store.sessionTabs[sessionKey]
- if (!current) return
- const index = current.all.findIndex((f) => f === tab)
- if (index === -1) return
- setStore(
- "sessionTabs",
- sessionKey,
- "all",
- produce((opened) => {
- opened.splice(to, 0, opened.splice(index, 1)[0])
- }),
- )
- },
- }
- },
- }
- },
-})
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
deleted file mode 100644
index 69807a2f4..000000000
--- a/packages/desktop/src/context/local.tsx
+++ /dev/null
@@ -1,548 +0,0 @@
-import { createStore, produce, reconcile } from "solid-js/store"
-import { batch, createEffect, createMemo } from "solid-js"
-import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
-import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useSDK } from "./sdk"
-import { useSync } from "./sync"
-import { base64Encode } from "@opencode-ai/util/encode"
-import { useProviders } from "@/hooks/use-providers"
-import { DateTime } from "luxon"
-import { persisted } from "@/utils/persist"
-
-export type LocalFile = FileNode &
- Partial<{
- loaded: boolean
- pinned: boolean
- expanded: boolean
- content: FileContent
- selection: { startLine: number; startChar: number; endLine: number; endChar: number }
- scrollTop: number
- view: "raw" | "diff-unified" | "diff-split"
- folded: string[]
- selectedChange: number
- status: FileStatus
- }>
-export type TextSelection = LocalFile["selection"]
-export type View = LocalFile["view"]
-
-export type LocalModel = Omit & {
- provider: Provider
- latest?: boolean
-}
-export type ModelKey = { providerID: string; modelID: string }
-
-export type FileContext = { type: "file"; path: string; selection?: TextSelection }
-export type ContextItem = FileContext
-
-export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
- name: "Local",
- init: () => {
- const sdk = useSDK()
- const sync = useSync()
- const providers = useProviders()
-
- function isModelValid(model: ModelKey) {
- const provider = providers.all().find((x) => x.id === model.providerID)
- return (
- !!provider?.models[model.modelID] &&
- providers
- .connected()
- .map((p) => p.id)
- .includes(model.providerID)
- )
- }
-
- function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
- for (const modelFn of modelFns) {
- const model = modelFn()
- if (!model) continue
- if (isModelValid(model)) return model
- }
- }
-
- // Automatically update model when agent changes
- createEffect(() => {
- const value = agent.current()
- if (value.model) {
- if (isModelValid(value.model))
- model.set({
- providerID: value.model.providerID,
- modelID: value.model.modelID,
- })
- // else
- // toast.show({
- // type: "warning",
- // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
- // duration: 3000,
- // })
- }
- })
-
- const agent = (() => {
- const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
- const [store, setStore] = createStore<{
- current: string
- }>({
- current: list()[0].name,
- })
- return {
- list,
- current() {
- return list().find((x) => x.name === store.current)!
- },
- set(name: string | undefined) {
- setStore("current", name ?? list()[0].name)
- },
- move(direction: 1 | -1) {
- let next = list().findIndex((x) => x.name === store.current) + direction
- if (next < 0) next = list().length - 1
- if (next >= list().length) next = 0
- const value = list()[next]
- setStore("current", value.name)
- if (value.model)
- model.set({
- providerID: value.model.providerID,
- modelID: value.model.modelID,
- })
- },
- }
- })()
-
- const model = (() => {
- const [store, setStore, _, modelReady] = persisted(
- "model.v1",
- createStore<{
- user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
- recent: ModelKey[]
- }>({
- user: [],
- recent: [],
- }),
- )
-
- const [ephemeral, setEphemeral] = createStore<{
- model: Record
- }>({
- model: {},
- })
-
- const available = createMemo(() =>
- providers.connected().flatMap((p) =>
- Object.values(p.models).map((m) => ({
- ...m,
- provider: p,
- })),
- ),
- )
-
- const latest = createMemo(() =>
- pipe(
- available(),
- filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
- groupBy((x) => x.provider.id),
- mapValues((models) =>
- pipe(
- models,
- groupBy((x) => x.family),
- values(),
- (groups) =>
- groups.flatMap((g) => {
- const first = firstBy(g, [(x) => x.release_date, "desc"])
- return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
- }),
- ),
- ),
- values(),
- flat(),
- ),
- )
-
- const list = createMemo(() =>
- available().map((m) => ({
- ...m,
- name: m.name.replace("(latest)", "").trim(),
- latest: m.name.includes("(latest)"),
- })),
- )
-
- const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
-
- const fallbackModel = createMemo(() => {
- if (sync.data.config.model) {
- const [providerID, modelID] = sync.data.config.model.split("/")
- if (isModelValid({ providerID, modelID })) {
- return {
- providerID,
- modelID,
- }
- }
- }
-
- for (const item of store.recent) {
- if (isModelValid(item)) {
- return item
- }
- }
-
- for (const p of providers.connected()) {
- if (p.id in providers.default()) {
- return {
- providerID: p.id,
- modelID: providers.default()[p.id],
- }
- }
- }
-
- throw new Error("No default model found")
- })
-
- const current = createMemo(() => {
- const a = agent.current()
- const key = getFirstValidModel(
- () => ephemeral.model[a.name],
- () => a.model,
- fallbackModel,
- )!
- return find(key)
- })
-
- const recent = createMemo(() => store.recent.map(find).filter(Boolean))
-
- const cycle = (direction: 1 | -1) => {
- const recentList = recent()
- const currentModel = current()
- if (!currentModel) return
-
- const index = recentList.findIndex(
- (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
- )
- if (index === -1) return
-
- let next = index + direction
- if (next < 0) next = recentList.length - 1
- if (next >= recentList.length) next = 0
-
- const val = recentList[next]
- if (!val) return
-
- model.set({
- providerID: val.provider.id,
- modelID: val.id,
- })
- }
-
- function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
- const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
- if (index >= 0) {
- setStore("user", index, { visibility })
- } else {
- setStore("user", store.user.length, { ...model, visibility })
- }
- }
-
- return {
- ready: modelReady,
- current,
- recent,
- list,
- cycle,
- set(model: ModelKey | undefined, options?: { recent?: boolean }) {
- batch(() => {
- setEphemeral("model", agent.current().name, model ?? fallbackModel())
- if (model) updateVisibility(model, "show")
- if (options?.recent && model) {
- const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
- if (uniq.length > 5) uniq.pop()
- setStore("recent", uniq)
- }
- })
- },
- visible(model: ModelKey) {
- const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
- return (
- user?.visibility !== "hide" &&
- (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
- user?.visibility === "show")
- )
- },
- setVisibility(model: ModelKey, visible: boolean) {
- updateVisibility(model, visible ? "show" : "hide")
- },
- }
- })()
-
- const file = (() => {
- const [store, setStore] = createStore<{
- node: Record
- }>({
- node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
- })
-
- const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
- const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
-
- // createEffect((prev: FileStatus[]) => {
- // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
- // for (const p of removed) {
- // setStore(
- // "node",
- // p.path,
- // produce((draft) => {
- // draft.status = undefined
- // draft.view = "raw"
- // }),
- // )
- // load(p.path)
- // }
- // for (const p of sync.data.changes) {
- // if (store.node[p.path] === undefined) {
- // fetch(p.path).then(() => {
- // if (store.node[p.path] === undefined) return
- // setStore("node", p.path, "status", p)
- // })
- // } else {
- // setStore("node", p.path, "status", p)
- // }
- // }
- // return sync.data.changes
- // }, sync.data.changes)
-
- const changed = (path: string) => {
- const node = store.node[path]
- if (node?.status) return true
- const set = changeset()
- if (set.has(path)) return true
- for (const p of set) {
- if (p.startsWith(path ? path + "/" : "")) return true
- }
- return false
- }
-
- // const resetNode = (path: string) => {
- // setStore("node", path, {
- // loaded: undefined,
- // pinned: undefined,
- // content: undefined,
- // selection: undefined,
- // scrollTop: undefined,
- // folded: undefined,
- // view: undefined,
- // selectedChange: undefined,
- // })
- // }
-
- const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
-
- const load = async (path: string) => {
- const relativePath = relative(path)
- await sdk.client.file.read({ path: relativePath }).then((x) => {
- if (!store.node[relativePath]) return
- setStore(
- "node",
- relativePath,
- produce((draft) => {
- draft.loaded = true
- draft.content = x.data
- }),
- )
- })
- }
-
- const fetch = async (path: string) => {
- const relativePath = relative(path)
- const parent = relativePath.split("/").slice(0, -1).join("/")
- if (parent) {
- await list(parent)
- }
- }
-
- const init = async (path: string) => {
- const relativePath = relative(path)
- if (!store.node[relativePath]) await fetch(path)
- if (store.node[relativePath]?.loaded) return
- return load(relativePath)
- }
-
- const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
- const relativePath = relative(path)
- if (!store.node[relativePath]) await fetch(path)
- // setStore("opened", (x) => {
- // if (x.includes(relativePath)) return x
- // return [
- // ...opened()
- // .filter((x) => x.pinned)
- // .map((x) => x.path),
- // relativePath,
- // ]
- // })
- // setStore("active", relativePath)
- context.addActive()
- if (options?.pinned) setStore("node", path, "pinned", true)
- if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
- if (store.node[relativePath]?.loaded) return
- return load(relativePath)
- }
-
- const list = async (path: string) => {
- return sdk.client.file.list({ path: path + "/" }).then((x) => {
- setStore(
- "node",
- produce((draft) => {
- x.data!.forEach((node) => {
- if (node.path in draft) return
- draft[node.path] = node
- })
- }),
- )
- })
- }
-
- const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
- const searchFilesAndDirectories = (query: string) =>
- sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
-
- sdk.event.listen((e) => {
- const event = e.details
- switch (event.type) {
- case "file.watcher.updated":
- const relativePath = relative(event.properties.file)
- if (relativePath.startsWith(".git/")) return
- if (store.node[relativePath]) load(relativePath)
- break
- }
- })
-
- return {
- node: async (path: string) => {
- if (!store.node[path] || !store.node[path].loaded) {
- await init(path)
- }
- return store.node[path]
- },
- update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
- open,
- load,
- init,
- expand(path: string) {
- setStore("node", path, "expanded", true)
- if (store.node[path]?.loaded) return
- setStore("node", path, "loaded", true)
- list(path)
- },
- collapse(path: string) {
- setStore("node", path, "expanded", false)
- },
- select(path: string, selection: TextSelection | undefined) {
- setStore("node", path, "selection", selection)
- },
- scroll(path: string, scrollTop: number) {
- setStore("node", path, "scrollTop", scrollTop)
- },
- view(path: string): View {
- const n = store.node[path]
- return n && n.view ? n.view : "raw"
- },
- setView(path: string, view: View) {
- setStore("node", path, "view", view)
- },
- unfold(path: string, key: string) {
- setStore("node", path, "folded", (xs) => {
- const a = xs ?? []
- if (a.includes(key)) return a
- return [...a, key]
- })
- },
- fold(path: string, key: string) {
- setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
- },
- folded(path: string) {
- const n = store.node[path]
- return n && n.folded ? n.folded : []
- },
- changeIndex(path: string) {
- return store.node[path]?.selectedChange
- },
- setChangeIndex(path: string, index: number | undefined) {
- setStore("node", path, "selectedChange", index)
- },
- changes,
- changed,
- children(path: string) {
- return Object.values(store.node).filter(
- (x) =>
- x.path.startsWith(path) &&
- x.path !== path &&
- !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
- )
- },
- searchFiles,
- searchFilesAndDirectories,
- relative,
- }
- })()
-
- const context = (() => {
- const [store, setStore] = createStore<{
- activeTab: boolean
- files: string[]
- activeFile?: string
- items: (ContextItem & { key: string })[]
- }>({
- activeTab: true,
- files: [],
- items: [],
- })
- const files = createMemo(() => store.files.map((x) => file.node(x)))
- const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
-
- return {
- all() {
- return store.items
- },
- // active() {
- // return store.activeTab ? file.active() : undefined
- // },
- addActive() {
- setStore("activeTab", true)
- },
- removeActive() {
- setStore("activeTab", false)
- },
- add(item: ContextItem) {
- let key = item.type
- switch (item.type) {
- case "file":
- key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
- break
- }
- if (store.items.find((x) => x.key === key)) return
- setStore("items", (x) => [...x, { key, ...item }])
- },
- remove(key: string) {
- setStore("items", (x) => x.filter((x) => x.key !== key))
- },
- files,
- openFile(path: string) {
- file.init(path).then(() => {
- setStore("files", (x) => [...x, path])
- setStore("activeFile", path)
- })
- },
- activeFile,
- setActiveFile(path: string | undefined) {
- setStore("activeFile", path)
- },
- }
- })()
-
- const result = {
- slug: createMemo(() => base64Encode(sdk.directory)),
- model,
- agent,
- file,
- context,
- }
- return result
- },
-})
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
deleted file mode 100644
index 2b258ebd6..000000000
--- a/packages/desktop/src/context/notification.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { createStore } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useGlobalSDK } from "./global-sdk"
-import { useGlobalSync } from "./global-sync"
-import { Binary } from "@opencode-ai/util/binary"
-import { EventSessionError } from "@opencode-ai/sdk/v2"
-import { makeAudioPlayer } from "@solid-primitives/audio"
-import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
-import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
-import { persisted } from "@/utils/persist"
-
-type NotificationBase = {
- directory?: string
- session?: string
- metadata?: any
- time: number
- viewed: boolean
-}
-
-type TurnCompleteNotification = NotificationBase & {
- type: "turn-complete"
-}
-
-type ErrorNotification = NotificationBase & {
- type: "error"
- error: EventSessionError["properties"]["error"]
-}
-
-export type Notification = TurnCompleteNotification | ErrorNotification
-
-export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
- name: "Notification",
- init: () => {
- let idlePlayer: ReturnType | undefined
- let errorPlayer: ReturnType | undefined
-
- try {
- idlePlayer = makeAudioPlayer(idleSound)
- errorPlayer = makeAudioPlayer(errorSound)
- } catch (err) {
- console.log("Failed to load audio", err)
- }
-
- const globalSDK = useGlobalSDK()
- const globalSync = useGlobalSync()
-
- const [store, setStore, _, ready] = persisted(
- "notification.v1",
- createStore({
- list: [] as Notification[],
- }),
- )
-
- globalSDK.event.listen((e) => {
- const directory = e.name
- const event = e.details
- const base = {
- directory,
- time: Date.now(),
- viewed: false,
- }
- switch (event.type) {
- case "session.idle": {
- const sessionID = event.properties.sessionID
- const [syncStore] = globalSync.child(directory)
- const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
- const isChild = match.found && syncStore.session[match.index].parentID
- if (isChild) break
- try {
- idlePlayer?.play()
- } catch {}
- setStore("list", store.list.length, {
- ...base,
- type: "turn-complete",
- session: sessionID,
- })
- break
- }
- case "session.error": {
- const sessionID = event.properties.sessionID
- if (sessionID) {
- const [syncStore] = globalSync.child(directory)
- const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
- const isChild = match.found && syncStore.session[match.index].parentID
- if (isChild) break
- }
- try {
- errorPlayer?.play()
- } catch {}
- setStore("list", store.list.length, {
- ...base,
- type: "error",
- session: sessionID ?? "global",
- error: "error" in event.properties ? event.properties.error : undefined,
- })
- break
- }
- }
- })
-
- return {
- ready,
- session: {
- all(session: string) {
- return store.list.filter((n) => n.session === session)
- },
- unseen(session: string) {
- return store.list.filter((n) => n.session === session && !n.viewed)
- },
- markViewed(session: string) {
- setStore("list", (n) => n.session === session, "viewed", true)
- },
- },
- project: {
- all(directory: string) {
- return store.list.filter((n) => n.directory === directory)
- },
- unseen(directory: string) {
- return store.list.filter((n) => n.directory === directory && !n.viewed)
- },
- markViewed(directory: string) {
- setStore("list", (n) => n.directory === directory, "viewed", true)
- },
- },
- }
- },
-})
diff --git a/packages/desktop/src/context/platform.tsx b/packages/desktop/src/context/platform.tsx
deleted file mode 100644
index 73d4c7f3e..000000000
--- a/packages/desktop/src/context/platform.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
-
-export type Platform = {
- /** Platform discriminator */
- platform: "web" | "tauri"
-
- /** Open a URL in the default browser */
- openLink(url: string): void
-
- /** Restart the app */
- restart(): Promise
-
- /** Open native directory picker dialog (Tauri only) */
- openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise
-
- /** Open native file picker dialog (Tauri only) */
- openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise
-
- /** Save file picker dialog (Tauri only) */
- saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise
-
- /** Storage mechanism, defaults to localStorage */
- storage?: (name?: string) => SyncStorage | AsyncStorage
-
- /** Check for updates (Tauri only) */
- checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
-
- /** Install updates (Tauri only) */
- update?(): Promise
-
- /** Fetch override */
- fetch?: typeof fetch
-}
-
-export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
- name: "Platform",
- init: (props: { value: Platform }) => {
- return props.value
- },
-})
diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx
deleted file mode 100644
index 8d3590cd9..000000000
--- a/packages/desktop/src/context/prompt.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { createStore } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createMemo } from "solid-js"
-import { useParams } from "@solidjs/router"
-import { TextSelection } from "./local"
-import { persisted } from "@/utils/persist"
-
-interface PartBase {
- content: string
- start: number
- end: number
-}
-
-export interface TextPart extends PartBase {
- type: "text"
-}
-
-export interface FileAttachmentPart extends PartBase {
- type: "file"
- path: string
- selection?: TextSelection
-}
-
-export interface ImageAttachmentPart {
- type: "image"
- id: string
- filename: string
- mime: string
- dataUrl: string
-}
-
-export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
-export type Prompt = ContentPart[]
-
-export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
-
-export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
- if (promptA.length !== promptB.length) return false
- for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
- if (partA.type !== partB.type) return false
- if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
- return false
- }
- if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
- return false
- }
- if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
- return false
- }
- }
- return true
-}
-
-function cloneSelection(selection?: TextSelection) {
- if (!selection) return undefined
- return { ...selection }
-}
-
-function clonePart(part: ContentPart): ContentPart {
- if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
- return {
- ...part,
- selection: cloneSelection(part.selection),
- }
-}
-
-function clonePrompt(prompt: Prompt): Prompt {
- return prompt.map(clonePart)
-}
-
-export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
- name: "Prompt",
- init: () => {
- const params = useParams()
- const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
-
- const [store, setStore, _, ready] = persisted(
- name(),
- createStore<{
- prompt: Prompt
- cursor?: number
- }>({
- prompt: clonePrompt(DEFAULT_PROMPT),
- cursor: undefined,
- }),
- )
-
- return {
- ready,
- current: createMemo(() => store.prompt),
- cursor: createMemo(() => store.cursor),
- dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
- set(prompt: Prompt, cursorPosition?: number) {
- const next = clonePrompt(prompt)
- batch(() => {
- setStore("prompt", next)
- if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
- })
- },
- reset() {
- batch(() => {
- setStore("prompt", clonePrompt(DEFAULT_PROMPT))
- setStore("cursor", 0)
- })
- },
- }
- },
-})
diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx
deleted file mode 100644
index 4d1c797c9..000000000
--- a/packages/desktop/src/context/sdk.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { useGlobalSDK } from "./global-sdk"
-import { usePlatform } from "./platform"
-
-export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
- name: "SDK",
- init: (props: { directory: string }) => {
- const platform = usePlatform()
- const globalSDK = useGlobalSDK()
- const sdk = createOpencodeClient({
- baseUrl: globalSDK.url,
- signal: AbortSignal.timeout(1000 * 60 * 10),
- fetch: platform.fetch,
- directory: props.directory,
- throwOnError: true,
- })
-
- const emitter = createGlobalEmitter<{
- [key in Event["type"]]: Extract
- }>()
-
- globalSDK.event.on(props.directory, async (event) => {
- emitter.emit(event.type, event)
- })
-
- return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
- },
-})
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
deleted file mode 100644
index 941b8b629..000000000
--- a/packages/desktop/src/context/sync.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { produce } from "solid-js/store"
-import { createMemo } from "solid-js"
-import { Binary } from "@opencode-ai/util/binary"
-import { retry } from "@opencode-ai/util/retry"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useGlobalSync } from "./global-sync"
-import { useSDK } from "./sdk"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
-
-export const { use: useSync, provider: SyncProvider } = createSimpleContext({
- name: "Sync",
- init: () => {
- const globalSync = useGlobalSync()
- const sdk = useSDK()
- const [store, setStore] = globalSync.child(sdk.directory)
- const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
-
- return {
- data: store,
- set: setStore,
- get ready() {
- return store.ready
- },
- get project() {
- const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
- if (match.found) return globalSync.data.project[match.index]
- return undefined
- },
- session: {
- get(sessionID: string) {
- const match = Binary.search(store.session, sessionID, (s) => s.id)
- if (match.found) return store.session[match.index]
- return undefined
- },
- addOptimisticMessage(input: {
- sessionID: string
- messageID: string
- parts: Part[]
- agent: string
- model: { providerID: string; modelID: string }
- }) {
- const message: Message = {
- id: input.messageID,
- sessionID: input.sessionID,
- role: "user",
- time: { created: Date.now() },
- agent: input.agent,
- model: input.model,
- }
- setStore(
- produce((draft) => {
- const messages = draft.message[input.sessionID]
- if (!messages) {
- draft.message[input.sessionID] = [message]
- } else {
- const result = Binary.search(messages, input.messageID, (m) => m.id)
- messages.splice(result.index, 0, message)
- }
- draft.part[input.messageID] = input.parts.slice()
- }),
- )
- },
- async sync(sessionID: string, _isRetry = false) {
- const [session, messages, todo, diff] = await Promise.all([
- retry(() => sdk.client.session.get({ sessionID })),
- retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
- retry(() => sdk.client.session.todo({ sessionID })),
- retry(() => sdk.client.session.diff({ sessionID })),
- ])
- setStore(
- produce((draft) => {
- const match = Binary.search(draft.session, sessionID, (s) => s.id)
- if (match.found) draft.session[match.index] = session.data!
- if (!match.found) draft.session.splice(match.index, 0, session.data!)
- draft.todo[sessionID] = todo.data ?? []
- draft.message[sessionID] = messages
- .data!.map((x) => x.info)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- for (const message of messages.data!) {
- draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
- }
- draft.session_diff[sessionID] = diff.data ?? []
- }),
- )
- },
- fetch: async (count = 10) => {
- setStore("limit", (x) => x + count)
- await sdk.client.session.list().then((x) => {
- const sessions = (x.data ?? [])
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- .slice(0, store.limit)
- setStore("session", sessions)
- })
- },
- more: createMemo(() => store.session.length >= store.limit),
- archive: async (sessionID: string) => {
- await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
- setStore(
- produce((draft) => {
- const match = Binary.search(draft.session, sessionID, (s) => s.id)
- if (match.found) draft.session.splice(match.index, 1)
- }),
- )
- },
- },
- absolute,
- get directory() {
- return store.path.directory
- },
- }
- },
-})
diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx
deleted file mode 100644
index 6f7c11dea..000000000
--- a/packages/desktop/src/context/terminal.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { createStore, produce } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createMemo } from "solid-js"
-import { useParams } from "@solidjs/router"
-import { useSDK } from "./sdk"
-import { persisted } from "@/utils/persist"
-
-export type LocalPTY = {
- id: string
- title: string
- rows?: number
- cols?: number
- buffer?: string
- scrollY?: number
-}
-
-export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
- name: "Terminal",
- init: () => {
- const sdk = useSDK()
- const params = useParams()
- const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
-
- const [store, setStore, _, ready] = persisted(
- name(),
- createStore<{
- active?: string
- all: LocalPTY[]
- }>({
- all: [],
- }),
- )
-
- return {
- ready,
- all: createMemo(() => Object.values(store.all)),
- active: createMemo(() => store.active),
- new() {
- sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
- const id = pty.data?.id
- if (!id) return
- setStore("all", [
- ...store.all,
- {
- id,
- title: pty.data?.title ?? "Terminal",
- },
- ])
- setStore("active", id)
- })
- },
- update(pty: Partial & { id: string }) {
- setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
- sdk.client.pty.update({
- ptyID: pty.id,
- title: pty.title,
- size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
- })
- },
- async clone(id: string) {
- const index = store.all.findIndex((x) => x.id === id)
- const pty = store.all[index]
- if (!pty) return
- const clone = await sdk.client.pty.create({
- title: pty.title,
- })
- if (!clone.data) return
- setStore("all", index, {
- ...pty,
- ...clone.data,
- })
- if (store.active === pty.id) {
- setStore("active", clone.data.id)
- }
- },
- open(id: string) {
- setStore("active", id)
- },
- async close(id: string) {
- batch(() => {
- setStore(
- "all",
- store.all.filter((x) => x.id !== id),
- )
- if (store.active === id) {
- const index = store.all.findIndex((f) => f.id === id)
- const previous = store.all[Math.max(0, index - 1)]
- setStore("active", previous?.id)
- }
- })
- await sdk.client.pty.remove({ ptyID: id })
- },
- move(id: string, to: number) {
- const index = store.all.findIndex((f) => f.id === id)
- if (index === -1) return
- setStore(
- "all",
- produce((all) => {
- all.splice(to, 0, all.splice(index, 1)[0])
- }),
- )
- },
- }
- },
-})
diff --git a/packages/desktop/src/custom-elements.d.ts b/packages/desktop/src/custom-elements.d.ts
deleted file mode 120000
index e4ea0d6ce..000000000
--- a/packages/desktop/src/custom-elements.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-../../ui/src/custom-elements.d.ts
\ No newline at end of file
diff --git a/packages/desktop/src/entry.tsx b/packages/desktop/src/entry.tsx
deleted file mode 100644
index ecbce9815..000000000
--- a/packages/desktop/src/entry.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// @refresh reload
-import { render } from "solid-js/web"
-import { App } from "@/app"
-import { Platform, PlatformProvider } from "@/context/platform"
-
-const root = document.getElementById("root")
-if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
- throw new Error(
- "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
- )
-}
-
-const platform: Platform = {
- platform: "web",
- openLink(url: string) {
- window.open(url, "_blank")
- },
- restart: async () => {
- window.location.reload()
- },
-}
-
-render(
- () => (
-
-
-
- ),
- root!,
-)
diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts
deleted file mode 100644
index ad575e93b..000000000
--- a/packages/desktop/src/env.d.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-interface ImportMetaEnv {
- readonly VITE_OPENCODE_SERVER_HOST: string
- readonly VITE_OPENCODE_SERVER_PORT: string
-}
-
-interface ImportMeta {
- readonly env: ImportMetaEnv
-}
diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts
deleted file mode 100644
index 4a73fa055..000000000
--- a/packages/desktop/src/hooks/use-providers.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useGlobalSync } from "@/context/global-sync"
-import { base64Decode } from "@opencode-ai/util/encode"
-import { useParams } from "@solidjs/router"
-import { createMemo } from "solid-js"
-
-export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
-
-export function useProviders() {
- const globalSync = useGlobalSync()
- const params = useParams()
- const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
- const providers = createMemo(() => {
- if (currentDirectory()) {
- const [projectStore] = globalSync.child(currentDirectory())
- return projectStore.provider
- }
- return globalSync.data.provider
- })
- const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id)))
- const paid = createMemo(() =>
- connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
- )
- const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
- return {
- all: createMemo(() => providers().all),
- default: createMemo(() => providers().default),
- popular,
- connected,
- paid,
- }
-}
diff --git a/packages/desktop/src/index.css b/packages/desktop/src/index.css
deleted file mode 100644
index e40f0842b..000000000
--- a/packages/desktop/src/index.css
+++ /dev/null
@@ -1,7 +0,0 @@
-@import "@opencode-ai/ui/styles/tailwind";
-
-:root {
- a {
- cursor: default;
- }
-}
diff --git a/packages/desktop/src/index.ts b/packages/desktop/src/index.ts
deleted file mode 100644
index cf5be9f51..000000000
--- a/packages/desktop/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { PlatformProvider, type Platform } from "./context/platform"
-export { App } from "./app"
diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx
deleted file mode 100644
index c909a373d..000000000
--- a/packages/desktop/src/pages/directory-layout.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { createMemo, Show, type ParentProps } from "solid-js"
-import { useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
-import { SyncProvider, useSync } from "@/context/sync"
-import { LocalProvider } from "@/context/local"
-import { base64Decode } from "@opencode-ai/util/encode"
-import { DataProvider } from "@opencode-ai/ui/context"
-import { iife } from "@opencode-ai/util/iife"
-
-export default function Layout(props: ParentProps) {
- const params = useParams()
- const directory = createMemo(() => {
- return base64Decode(params.dir!)
- })
- return (
-
-
-
- {iife(() => {
- const sync = useSync()
- return (
-
- {props.children}
-
- )
- })}
-
-
-
- )
-}
diff --git a/packages/desktop/src/pages/error.tsx b/packages/desktop/src/pages/error.tsx
deleted file mode 100644
index 9914279ad..000000000
--- a/packages/desktop/src/pages/error.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { TextField } from "@opencode-ai/ui/text-field"
-import { Logo } from "@opencode-ai/ui/logo"
-import { Button } from "@opencode-ai/ui/button"
-import { Component } from "solid-js"
-import { usePlatform } from "@/context/platform"
-import { Icon } from "@opencode-ai/ui/icon"
-
-export type InitError = {
- name: string
- data: Record
-}
-
-function isInitError(error: unknown): error is InitError {
- return (
- typeof error === "object" &&
- error !== null &&
- "name" in error &&
- "data" in error &&
- typeof (error as InitError).data === "object"
- )
-}
-
-function formatInitError(error: InitError): string {
- const data = error.data
- switch (error.name) {
- case "MCPFailed":
- return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
- case "ProviderModelNotFoundError": {
- const { providerID, modelID, suggestions } = data as {
- providerID: string
- modelID: string
- suggestions?: string[]
- }
- return [
- `Model not found: ${providerID}/${modelID}`,
- ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
- `Check your config (opencode.json) provider/model names`,
- ].join("\n")
- }
- case "ProviderInitError":
- return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
- case "ConfigJsonError":
- return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
- case "ConfigDirectoryTypoError":
- return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
- case "ConfigFrontmatterError":
- return `Failed to parse frontmatter in ${data.path}:\n${data.message}`
- case "ConfigInvalidError": {
- const issues = Array.isArray(data.issues)
- ? data.issues.map(
- (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
- )
- : []
- return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
- "\n",
- )
- }
- case "UnknownError":
- return String(data.message)
- default:
- return data.message ? String(data.message) : JSON.stringify(data, null, 2)
- }
-}
-
-function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
- if (!error) return "Unknown error"
-
- if (isInitError(error)) {
- const message = formatInitError(error)
- if (depth > 0 && parentMessage === message) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
- return indent + message
- }
-
- if (error instanceof Error) {
- const isDuplicate = depth > 0 && parentMessage === error.message
- const parts: string[] = []
- const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
-
- if (!isDuplicate) {
- // Stack already includes error name and message, so prefer it
- parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
- } else if (error.stack) {
- // Duplicate message - only show the stack trace lines (skip message)
- const trace = error.stack.split("\n").slice(1).join("\n").trim()
- if (trace) {
- parts.push(trace)
- }
- }
-
- if (error.cause) {
- const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
- if (causeResult) {
- parts.push(causeResult)
- }
- }
-
- return parts.join("\n\n")
- }
-
- if (typeof error === "string") {
- if (depth > 0 && parentMessage === error) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
- return indent + error
- }
-
- const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
- return indent + JSON.stringify(error, null, 2)
-}
-
-function formatError(error: unknown): string {
- return formatErrorChain(error, 0)
-}
-
-interface ErrorPageProps {
- error: unknown
-}
-
-export const ErrorPage: Component = (props) => {
- const platform = usePlatform()
- return (
-
-
-
-
-
Something went wrong
-
An error occurred while loading the application.
-
-
-
-
- Please report this error to the OpenCode team
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
deleted file mode 100644
index 7cd2916e8..000000000
--- a/packages/desktop/src/pages/home.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useGlobalSync } from "@/context/global-sync"
-import { createMemo, For, Match, Show, Switch } from "solid-js"
-import { Button } from "@opencode-ai/ui/button"
-import { Logo } from "@opencode-ai/ui/logo"
-import { useLayout } from "@/context/layout"
-import { useNavigate } from "@solidjs/router"
-import { base64Encode } from "@opencode-ai/util/encode"
-import { Icon } from "@opencode-ai/ui/icon"
-import { usePlatform } from "@/context/platform"
-import { DateTime } from "luxon"
-
-export default function Home() {
- const sync = useGlobalSync()
- const layout = useLayout()
- const platform = usePlatform()
- const navigate = useNavigate()
- const homedir = createMemo(() => sync.data.path.home)
-
- function openProject(directory: string) {
- layout.projects.open(directory)
- navigate(`/${base64Encode(directory)}`)
- }
-
- async function chooseProject() {
- const result = await platform.openDirectoryPickerDialog?.({
- title: "Open project",
- multiple: true,
- })
- if (Array.isArray(result)) {
- for (const directory of result) {
- openProject(directory)
- }
- } else if (result) {
- openProject(result)
- }
- }
-
- return (
-
-
-
- 0}>
-
-
-
Recent projects
-
-
-
-
-
- (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
- .slice(0, 5)}
- >
- {(project) => (
-
- )}
-
-
-
-
-
-
-
-
-
No recent projects
-
Get started by opening a local project
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
deleted file mode 100644
index 489899f88..000000000
--- a/packages/desktop/src/pages/layout.tsx
+++ /dev/null
@@ -1,904 +0,0 @@
-import {
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- Switch,
- type JSX,
-} from "solid-js"
-import { DateTime } from "luxon"
-import { A, useNavigate, useParams } from "@solidjs/router"
-import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
-import { useGlobalSync } from "@/context/global-sync"
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { Avatar } from "@opencode-ai/ui/avatar"
-import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
-import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { Collapsible } from "@opencode-ai/ui/collapsible"
-import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { Spinner } from "@opencode-ai/ui/spinner"
-import { getFilename } from "@opencode-ai/util/path"
-import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Session } from "@opencode-ai/sdk/v2/client"
-import { usePlatform } from "@/context/platform"
-import { createStore, produce } from "solid-js/store"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
-} from "@thisbeyond/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
-import { useProviders } from "@/hooks/use-providers"
-import { showToast, Toast } from "@opencode-ai/ui/toast"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { useNotification } from "@/context/notification"
-import { Binary } from "@opencode-ai/util/binary"
-import { Header } from "@/components/header"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectProvider } from "@/components/dialog-select-provider"
-import { useCommand } from "@/context/command"
-import { ConstrainDragXAxis } from "@/utils/solid-dnd"
-
-export default function Layout(props: ParentProps) {
- const [store, setStore] = createStore({
- lastSession: {} as { [directory: string]: string },
- activeDraggable: undefined as string | undefined,
- mobileSidebarOpen: false,
- mobileProjectsExpanded: {} as Record,
- })
-
- const mobileSidebar = {
- open: () => store.mobileSidebarOpen,
- show: () => setStore("mobileSidebarOpen", true),
- hide: () => setStore("mobileSidebarOpen", false),
- toggle: () => setStore("mobileSidebarOpen", (x) => !x),
- }
-
- const mobileProjects = {
- expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
- expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
- collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
- }
-
- let scrollContainerRef: HTMLDivElement | undefined
- const xlQuery = window.matchMedia("(min-width: 1280px)")
- const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
- const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
- xlQuery.addEventListener("change", handleViewportChange)
- onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
-
- const params = useParams()
- const globalSDK = useGlobalSDK()
- const globalSync = useGlobalSync()
- const layout = useLayout()
- const platform = usePlatform()
- const notification = useNotification()
- const navigate = useNavigate()
- const providers = useProviders()
- const dialog = useDialog()
- const command = useCommand()
-
- onMount(async () => {
- if (platform.checkUpdate && platform.update && platform.restart) {
- const { updateAvailable, version } = await platform.checkUpdate()
- if (updateAvailable) {
- showToast({
- persistent: true,
- icon: "download",
- title: "Update available",
- description: `A new version of OpenCode (${version}) is now available to install.`,
- actions: [
- {
- label: "Install and restart",
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
- },
- },
- {
- label: "Not yet",
- onClick: "dismiss",
- },
- ],
- })
- }
- }
- })
-
- function flattenSessions(sessions: Session[]): Session[] {
- const childrenMap = new Map()
- for (const session of sessions) {
- if (session.parentID) {
- const children = childrenMap.get(session.parentID) ?? []
- children.push(session)
- childrenMap.set(session.parentID, children)
- }
- }
- const result: Session[] = []
- function visit(session: Session) {
- result.push(session)
- for (const child of childrenMap.get(session.id) ?? []) {
- visit(child)
- }
- }
- for (const session of sessions) {
- if (!session.parentID) visit(session)
- }
- return result
- }
-
- function scrollToSession(sessionId: string) {
- if (!scrollContainerRef) return
- const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
- if (element) {
- element.scrollIntoView({ block: "center", behavior: "smooth" })
- }
- }
-
- function projectSessions(directory: string) {
- if (!directory) return []
- const sessions = globalSync
- .child(directory)[0]
- .session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
- return flattenSessions(sessions ?? [])
- }
-
- const currentSessions = createMemo(() => {
- if (!params.dir) return []
- const directory = base64Decode(params.dir)
- return projectSessions(directory)
- })
-
- function navigateSessionByOffset(offset: number) {
- const projects = layout.projects.list()
- if (projects.length === 0) return
-
- const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
- const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
-
- if (projectIndex === -1) {
- const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
- if (targetProject) navigateToProject(targetProject.worktree)
- return
- }
-
- const sessions = currentSessions()
- const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
-
- let targetIndex: number
- if (sessionIndex === -1) {
- targetIndex = offset > 0 ? 0 : sessions.length - 1
- } else {
- targetIndex = sessionIndex + offset
- }
-
- if (targetIndex >= 0 && targetIndex < sessions.length) {
- const session = sessions[targetIndex]
- navigateToSession(session)
- queueMicrotask(() => scrollToSession(session.id))
- return
- }
-
- const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
- const nextProject = projects[nextProjectIndex]
- if (!nextProject) return
-
- const nextProjectSessions = projectSessions(nextProject.worktree)
- if (nextProjectSessions.length === 0) {
- navigateToProject(nextProject.worktree)
- return
- }
-
- const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
- navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
- queueMicrotask(() => scrollToSession(targetSession.id))
- }
-
- async function archiveSession(session: Session) {
- const [store, setStore] = globalSync.child(session.directory)
- const sessions = store.session ?? []
- const index = sessions.findIndex((s) => s.id === session.id)
- const nextSession = sessions[index + 1] ?? sessions[index - 1]
-
- await globalSDK.client.session.update({
- directory: session.directory,
- sessionID: session.id,
- time: { archived: Date.now() },
- })
- setStore(
- produce((draft) => {
- const match = Binary.search(draft.session, session.id, (s) => s.id)
- if (match.found) draft.session.splice(match.index, 1)
- }),
- )
- if (session.id === params.id) {
- if (nextSession) {
- navigate(`/${params.dir}/session/${nextSession.id}`)
- } else {
- navigate(`/${params.dir}/session`)
- }
- }
- }
-
- command.register(() => [
- {
- id: "sidebar.toggle",
- title: "Toggle sidebar",
- category: "View",
- keybind: "mod+b",
- onSelect: () => layout.sidebar.toggle(),
- },
- ...(platform.openDirectoryPickerDialog
- ? [
- {
- id: "project.open",
- title: "Open project",
- category: "Project",
- keybind: "mod+o",
- onSelect: () => chooseProject(),
- },
- ]
- : []),
- {
- id: "provider.connect",
- title: "Connect provider",
- category: "Provider",
- onSelect: () => connectProvider(),
- },
- {
- id: "session.previous",
- title: "Previous session",
- category: "Session",
- keybind: "alt+arrowup",
- onSelect: () => navigateSessionByOffset(-1),
- },
- {
- id: "session.next",
- title: "Next session",
- category: "Session",
- keybind: "alt+arrowdown",
- onSelect: () => navigateSessionByOffset(1),
- },
- {
- id: "session.archive",
- title: "Archive session",
- category: "Session",
- keybind: "mod+shift+backspace",
- disabled: !params.dir || !params.id,
- onSelect: () => {
- const session = currentSessions().find((s) => s.id === params.id)
- if (session) archiveSession(session)
- },
- },
- ])
-
- function connectProvider() {
- dialog.show(() => )
- }
-
- function navigateToProject(directory: string | undefined) {
- if (!directory) return
- const lastSession = store.lastSession[directory]
- navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
- mobileSidebar.hide()
- }
-
- function navigateToSession(session: Session | undefined) {
- if (!session) return
- navigate(`/${params.dir}/session/${session?.id}`)
- mobileSidebar.hide()
- }
-
- function openProject(directory: string, navigate = true) {
- layout.projects.open(directory)
- if (navigate) navigateToProject(directory)
- }
-
- function closeProject(directory: string) {
- const index = layout.projects.list().findIndex((x) => x.worktree === directory)
- const next = layout.projects.list()[index + 1]
- layout.projects.close(directory)
- if (next) navigateToProject(next.worktree)
- else navigate("/")
- }
-
- async function chooseProject() {
- const result = await platform.openDirectoryPickerDialog?.({
- title: "Open project",
- multiple: true,
- })
- if (Array.isArray(result)) {
- for (const directory of result) {
- openProject(directory, false)
- }
- navigateToProject(result[0])
- } else if (result) {
- openProject(result)
- }
- }
-
- createEffect(() => {
- if (!params.dir || !params.id) return
- const directory = base64Decode(params.dir)
- setStore("lastSession", directory, params.id)
- notification.session.markViewed(params.id)
- })
-
- createEffect(() => {
- if (isLargeViewport()) {
- const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
- document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
- } else {
- document.documentElement.style.setProperty("--dialog-left-margin", "0px")
- }
- })
-
- function getDraggableId(event: unknown): string | undefined {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
- }
-
- function handleDragStart(event: unknown) {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeDraggable", id)
- }
-
- function handleDragOver(event: DragEvent) {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const projects = layout.projects.list()
- const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
- const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
- if (fromIndex !== toIndex && toIndex !== -1) {
- layout.projects.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- function handleDragEnd() {
- setStore("activeDraggable", undefined)
- }
-
- const ProjectAvatar = (props: {
- project: LocalProject
- class?: string
- expandable?: boolean
- notify?: boolean
- }): JSX.Element => {
- const notification = useNotification()
- const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
- const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
- const name = createMemo(() => getFilename(props.project.worktree))
- const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
- const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
- return (
-
-
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
- }
- />
-
-
-
- 0 && props.notify}>
-
-
-
- )
- }
-
- const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
- const name = createMemo(() => getFilename(props.project.worktree))
- const current = createMemo(() => base64Decode(params.dir ?? ""))
- return (
-
-
-
-
-
-
-
-
- )
- }
-
- const SessionItem = (props: {
- session: Session
- slug: string
- project: LocalProject
- depth?: number
- childrenMap: Map
- mobile?: boolean
- }): JSX.Element => {
- const notification = useNotification()
- const depth = props.depth ?? 0
- const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
- const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
- const notifications = createMemo(() => notification.session.unseen(props.session.id))
- const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
- const isWorking = createMemo(() => {
- if (props.session.id === params.id) return false
- const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
- return status?.type === "busy" || status?.type === "retry"
- })
- return (
- <>
-
-
- {(child) => (
-
- )}
-
- >
- )
- }
-
- const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.project.worktree)
- const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
- const slug = createMemo(() => base64Encode(props.project.worktree))
- const name = createMemo(() => getFilename(props.project.worktree))
- const [store, setProjectStore] = globalSync.child(props.project.worktree)
- const sessions = createMemo(() =>
- store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
- )
- const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
- const childSessionsByParent = createMemo(() => {
- const map = new Map()
- for (const session of sessions()) {
- if (session.parentID) {
- const children = map.get(session.parentID) ?? []
- children.push(session)
- map.set(session.parentID, children)
- }
- }
- return map
- })
- const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
- const loadMoreSessions = async () => {
- setProjectStore("limit", (limit) => limit + 5)
- await globalSync.project.loadSessions(props.project.worktree)
- }
- const isExpanded = createMemo(() =>
- props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
- )
- const handleOpenChange = (open: boolean) => {
- if (props.mobile) {
- if (open) mobileProjects.expand(props.project.worktree)
- else mobileProjects.collapse(props.project.worktree)
- } else {
- if (open) layout.projects.expand(props.project.worktree)
- else layout.projects.collapse(props.project.worktree)
- }
- }
- return (
- // @ts-ignore
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- const ProjectDragOverlay = (): JSX.Element => {
- const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
- return (
-
- {(p) => (
-
- )}
-
- )
- }
-
- const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
- const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
- return (
- <>
-
-
-
- Toggle sidebar
- {command.keybind("sidebar.toggle")}
-
- }
- inactive={expanded()}
- >
-
-
-
-
-
-
-
- p.worktree)}>
-
- {(project) => }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Open project
-
- {command.keybind("project.open")}
-
-
- }
- inactive={expanded()}
- >
-
-
-
-
-
-
-
- >
- )
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
{
- if (e.target === e.currentTarget) mobileSidebar.hide()
- }}
- />
-
e.stopPropagation()}
- >
-
-
-
-
-
{props.children}
-
-
-
- )
-}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
deleted file mode 100644
index 42e43232a..000000000
--- a/packages/desktop/src/pages/session.tsx
+++ /dev/null
@@ -1,927 +0,0 @@
-import {
- For,
- onCleanup,
- onMount,
- Show,
- Match,
- Switch,
- createResource,
- createMemo,
- createEffect,
- on,
- createRenderEffect,
- batch,
-} from "solid-js"
-
-import { Dynamic } from "solid-js/web"
-import { useLocal, type LocalFile } from "@/context/local"
-import { createStore } from "solid-js/store"
-import { PromptInput } from "@/components/prompt-input"
-import { DateTime } from "luxon"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { DiffChanges } from "@opencode-ai/ui/diff-changes"
-import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
-import { Tabs } from "@opencode-ai/ui/tabs"
-import { useCodeComponent } from "@opencode-ai/ui/context/code"
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
-import { createAutoScroll } from "@opencode-ai/ui/hooks"
-import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
-import { SessionReview } from "@opencode-ai/ui/session-review"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
-} from "@thisbeyond/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
-import type { JSX } from "solid-js"
-import { useSync } from "@/context/sync"
-import { useTerminal, type LocalPTY } from "@/context/terminal"
-import { useLayout } from "@/context/layout"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { Terminal } from "@/components/terminal"
-import { checksum } from "@opencode-ai/util/encode"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectFile } from "@/components/dialog-select-file"
-import { DialogSelectModel } from "@/components/dialog-select-model"
-import { useCommand } from "@/context/command"
-import { useNavigate, useParams } from "@solidjs/router"
-import { UserMessage } from "@opencode-ai/sdk/v2"
-import { useSDK } from "@/context/sdk"
-import { usePrompt } from "@/context/prompt"
-import { extractPromptFromParts } from "@/utils/prompt"
-import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
-
-export default function Page() {
- const layout = useLayout()
- const local = useLocal()
- const sync = useSync()
- const terminal = useTerminal()
- const dialog = useDialog()
- const codeComponent = useCodeComponent()
- const command = useCommand()
- const params = useParams()
- const navigate = useNavigate()
- const sdk = useSDK()
- const prompt = usePrompt()
-
- const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
- const tabs = createMemo(() => layout.tabs(sessionKey()))
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
- const revertMessageID = createMemo(() => info()?.revert?.messageID)
- const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
- const userMessages = createMemo(() =>
- messages()
- .filter((m) => m.role === "user")
- .sort((a, b) => a.id.localeCompare(b.id)),
- )
- const visibleUserMessages = createMemo(() => {
- const revert = revertMessageID()
- if (!revert) return userMessages()
- return userMessages().filter((m) => m.id < revert)
- })
- const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
-
- const [store, setStore] = createStore({
- clickTimer: undefined as number | undefined,
- activeDraggable: undefined as string | undefined,
- activeTerminalDraggable: undefined as string | undefined,
- userInteracted: false,
- stepsExpanded: true,
- mobileStepsExpanded: {} as Record
,
- messageId: undefined as string | undefined,
- })
-
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
- const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
- return found ?? lastUserMessage()
- })
- const setActiveMessage = (message: UserMessage | undefined) => {
- setStore("messageId", message?.id)
- }
-
- function navigateMessageByOffset(offset: number) {
- const msgs = visibleUserMessages()
- if (msgs.length === 0) return
-
- const current = activeMessage()
- const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
-
- let targetIndex: number
- if (currentIndex === -1) {
- targetIndex = offset > 0 ? 0 : msgs.length - 1
- } else {
- targetIndex = currentIndex + offset
- }
-
- if (targetIndex < 0 || targetIndex >= msgs.length) return
-
- setActiveMessage(msgs[targetIndex])
- }
-
- const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-
- let inputRef!: HTMLDivElement
-
- createEffect(() => {
- if (!params.id) return
- sync.session.sync(params.id)
- })
-
- createEffect(() => {
- if (layout.terminal.opened()) {
- if (terminal.all().length === 0) {
- terminal.new()
- }
- }
- })
-
- createEffect(
- on(
- () => visibleUserMessages().at(-1)?.id,
- (lastId, prevLastId) => {
- if (lastId && prevLastId && lastId > prevLastId) {
- setStore("messageId", undefined)
- }
- },
- { defer: true },
- ),
- )
-
- createEffect(() => {
- params.id
- const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
- batch(() => {
- setStore("userInteracted", false)
- setStore("stepsExpanded", status.type !== "idle")
- })
- })
-
- const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
- const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
-
- createRenderEffect((prev) => {
- const isWorking = working()
- if (!prev && isWorking) {
- setStore("stepsExpanded", true)
- }
- if (prev && !isWorking && !store.userInteracted) {
- setStore("stepsExpanded", false)
- }
- return isWorking
- }, working())
-
- command.register(() => [
- {
- id: "session.new",
- title: "New session",
- description: "Create a new session",
- category: "Session",
- keybind: "mod+shift+s",
- slash: "new",
- onSelect: () => navigate(`/${params.dir}/session`),
- },
- {
- id: "file.open",
- title: "Open file",
- description: "Search and open a file",
- category: "File",
- keybind: "mod+p",
- slash: "open",
- onSelect: () => dialog.show(() => ),
- },
- // {
- // id: "theme.toggle",
- // title: "Toggle theme",
- // description: "Switch between themes",
- // category: "View",
- // keybind: "ctrl+t",
- // slash: "theme",
- // onSelect: () => {
- // const currentTheme = localStorage.getItem("theme") ?? "oc-1"
- // const themes = ["oc-1", "oc-2-paper"]
- // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
- // localStorage.setItem("theme", nextTheme)
- // document.documentElement.setAttribute("data-theme", nextTheme)
- // },
- // },
- {
- id: "terminal.toggle",
- title: "Toggle terminal",
- description: "Show or hide the terminal",
- category: "View",
- keybind: "ctrl+`",
- slash: "terminal",
- onSelect: () => layout.terminal.toggle(),
- },
- {
- id: "review.toggle",
- title: "Toggle review",
- description: "Show or hide the review panel",
- category: "View",
- keybind: "mod+b",
- slash: "review",
- onSelect: () => layout.review.toggle(),
- },
- {
- id: "terminal.new",
- title: "New terminal",
- description: "Create a new terminal tab",
- category: "Terminal",
- keybind: "ctrl+shift+`",
- onSelect: () => terminal.new(),
- },
- {
- id: "steps.toggle",
- title: "Toggle steps",
- description: "Show or hide the steps",
- category: "View",
- keybind: "mod+e",
- slash: "steps",
- disabled: !params.id,
- onSelect: () => setStore("stepsExpanded", (x) => !x),
- },
- {
- id: "message.previous",
- title: "Previous message",
- description: "Go to the previous user message",
- category: "Session",
- keybind: "mod+arrowup",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(-1),
- },
- {
- id: "message.next",
- title: "Next message",
- description: "Go to the next user message",
- category: "Session",
- keybind: "mod+arrowdown",
- disabled: !params.id,
- onSelect: () => navigateMessageByOffset(1),
- },
- {
- id: "model.choose",
- title: "Choose model",
- description: "Select a different model",
- category: "Model",
- keybind: "mod+'",
- slash: "model",
- onSelect: () => dialog.show(() => ),
- },
- {
- id: "agent.cycle",
- title: "Cycle agent",
- description: "Switch to the next agent",
- category: "Agent",
- keybind: "mod+.",
- slash: "agent",
- onSelect: () => local.agent.move(1),
- },
- {
- id: "agent.cycle.reverse",
- title: "Cycle agent backwards",
- description: "Switch to the previous agent",
- category: "Agent",
- keybind: "shift+mod+.",
- onSelect: () => local.agent.move(-1),
- },
- {
- id: "session.undo",
- title: "Undo",
- description: "Undo the last message",
- category: "Session",
- slash: "undo",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- if (status()?.type !== "idle") {
- await sdk.client.session.abort({ sessionID }).catch(() => {})
- }
- const revert = info()?.revert?.messageID
- // Find the last user message that's not already reverted
- const message = userMessages().findLast((x) => !revert || x.id < revert)
- if (!message) return
- await sdk.client.session.revert({ sessionID, messageID: message.id })
- // Restore the prompt from the reverted message
- const parts = sync.data.part[message.id]
- if (parts) {
- const restored = extractPromptFromParts(parts)
- prompt.set(restored)
- }
- // Navigate to the message before the reverted one (which will be the new last visible message)
- const priorMessage = userMessages().findLast((x) => x.id < message.id)
- setActiveMessage(priorMessage)
- },
- },
- {
- id: "session.redo",
- title: "Redo",
- description: "Redo the last undone message",
- category: "Session",
- slash: "redo",
- disabled: !params.id || !info()?.revert?.messageID,
- onSelect: async () => {
- const sessionID = params.id
- if (!sessionID) return
- const revertMessageID = info()?.revert?.messageID
- if (!revertMessageID) return
- const nextMessage = userMessages().find((x) => x.id > revertMessageID)
- if (!nextMessage) {
- // Full unrevert - restore all messages and navigate to last
- await sdk.client.session.unrevert({ sessionID })
- prompt.reset()
- // Navigate to the last message (the one that was at the revert point)
- const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
- setActiveMessage(lastMsg)
- return
- }
- // Partial redo - move forward to next message
- await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- // Navigate to the message before the new revert point
- const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
- setActiveMessage(priorMsg)
- },
- },
- ])
-
- const handleKeyDown = (event: KeyboardEvent) => {
- const activeElement = document.activeElement as HTMLElement | undefined
- if (activeElement) {
- const isProtected = activeElement.closest("[data-prevent-autofocus]")
- const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
- if (isProtected || isInput) return
- }
- if (dialog.active) return
-
- if (activeElement === inputRef) {
- if (event.key === "Escape") inputRef?.blur()
- return
- }
-
- if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
- inputRef?.focus()
- }
- }
-
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
-
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
-
- const resetClickTimer = () => {
- if (!store.clickTimer) return
- clearTimeout(store.clickTimer)
- setStore("clickTimer", undefined)
- }
-
- const startClickTimer = () => {
- const newClickTimer = setTimeout(() => {
- setStore("clickTimer", undefined)
- }, 300)
- setStore("clickTimer", newClickTimer as unknown as number)
- }
-
- const handleTabClick = async (tab: string) => {
- if (store.clickTimer) {
- resetClickTimer()
- } else {
- if (tab.startsWith("file://")) {
- local.file.open(tab.replace("file://", ""))
- }
- startClickTimer()
- }
- }
-
- const handleDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeDraggable", id)
- }
-
- const handleDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const currentTabs = tabs().all()
- const fromIndex = currentTabs?.indexOf(draggable.id.toString())
- const toIndex = currentTabs?.indexOf(droppable.id.toString())
- if (fromIndex !== toIndex && toIndex !== undefined) {
- tabs().move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleDragEnd = () => {
- setStore("activeDraggable", undefined)
- }
-
- const handleTerminalDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeTerminalDraggable", id)
- }
-
- const handleTerminalDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const terminals = terminal.all()
- const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
- const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
- if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
- terminal.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleTerminalDragEnd = () => {
- setStore("activeTerminalDraggable", undefined)
- }
-
- const SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
- const sortable = createSortable(props.terminal.id)
- return (
- // @ts-ignore
-
-
- 1 && (
- terminal.close(props.terminal.id)} />
- )
- }
- >
- {props.terminal.title}
-
-
-
- )
- }
-
- const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
- return (
-
-
-
- {props.file.name}
-
-
-
-
- M
-
-
- A
-
-
- D
-
-
-
-
- )
- }
-
- const SortableTab = (props: {
- tab: string
- onTabClick: (tab: string) => void
- onTabClose: (tab: string) => void
- }): JSX.Element => {
- const sortable = createSortable(props.tab)
- const [file] = createResource(
- () => props.tab,
- async (tab) => {
- if (tab.startsWith("file://")) {
- return local.file.node(tab.replace("file://", ""))
- }
- return undefined
- },
- )
- return (
- // @ts-ignore
-
-
-
- props.onTabClose(props.tab)} />
-
- }
- hideCloseButton
- onClick={() => props.onTabClick(props.tab)}
- >
-
- {(f) => }
-
-
-
-
- )
- }
-
- const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
-
- const mobileWorking = createMemo(() => status().type !== "idle")
- const mobileAutoScroll = createAutoScroll({
- working: mobileWorking,
- onUserInteracted: () => setStore("userInteracted", true),
- })
-
- const MobileTurns = () => (
-
- )
-
- const NewSessionView = () => (
-
-
New session
-
-
-
- {getDirectory(sync.data.path.directory)}
- {getFilename(sync.data.path.directory)}
-
-
-
- {(project) => (
-
-
-
- Last modified
-
- {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
-
-
-
- )}
-
-
- )
-
- const DesktopSessionContent = () => (
-
-
-
-
-
- setStore("stepsExpanded", (x) => !x)}
- onUserInteracted={() => setStore("userInteracted", true)}
- classes={{
- root: "pb-20 flex-1 min-w-0",
- content: "pb-20",
- container:
- "w-full " +
- (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
- }}
- />
-
-
-
-
-
-
-
- )
-
- return (
-
-
-
-
-
-
-
-
- 0}>
-
-
-
- Session
-
-
- {diffs().length} Files Changed
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{
- inputRef = el
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
{
- inputRef = el
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Review
-
-
- {info()?.summary?.files ?? 0}
-
-
-
-
-
-
-
-
- {(tab) => }
-
-
-
-
- Open file
- {command.keybind("file.open")}
-
- }
- class="flex items-center"
- >
- dialog.show(() => )}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {(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) => }
-
- )
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- t.id)}>
- {(pty) => }
-
-
-
- New terminal
- {command.keybind("terminal.new")}
-
- }
- class="flex items-center"
- >
-
-
-
-
-
- {(pty) => (
-
- terminal.clone(pty.id)} />
-
- )}
-
-
-
-
- {(draggedId) => {
- const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
- return (
-
- {(t) => (
-
- {t().title}
-
- )}
-
- )
- }}
-
-
-
-
-
-
- )
-}
diff --git a/packages/desktop/src/sst-env.d.ts b/packages/desktop/src/sst-env.d.ts
deleted file mode 100644
index 47a8fbec7..000000000
--- a/packages/desktop/src/sst-env.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/* This file is auto-generated by SST. Do not edit. */
-/* tslint:disable */
-/* eslint-disable */
-///
-interface ImportMetaEnv {
-
-}
-interface ImportMeta {
- readonly env: ImportMetaEnv
-}
\ No newline at end of file
diff --git a/packages/desktop/src/utils/dom.ts b/packages/desktop/src/utils/dom.ts
deleted file mode 100644
index 4f3724c7c..000000000
--- a/packages/desktop/src/utils/dom.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
- const r = document.createRange()
- r.selectNodeContents(lineElement)
- r.setEnd(targetNode, offset)
- return r.toString().length
-}
-
-export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
- const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
- let remaining = Math.max(0, charIndex)
- let lastText: Node | null = null
- let lastLen = 0
- let node: Node | null
- while ((node = walker.nextNode())) {
- const len = node.textContent?.length || 0
- lastText = node
- lastLen = len
- if (remaining <= len) return { node, offset: remaining }
- remaining -= len
- }
- if (lastText) return { node: lastText, offset: lastLen }
- if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
- return null
-}
-
-export function getSelectionInContainer(
- container: HTMLElement,
-): { sl: number; sch: number; el: number; ech: number } | null {
- const s = window.getSelection()
- if (!s || s.rangeCount === 0) return null
- const r = s.getRangeAt(0)
- const sc = r.startContainer
- const ec = r.endContainer
- const getLineElement = (n: Node) =>
- (n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
- const sle = getLineElement(sc)
- const ele = getLineElement(ec)
- if (!sle || !ele) return null
- if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
- const cc = container.querySelector("code") as HTMLElement | null
- if (!cc) return null
- const lines = Array.from(cc.querySelectorAll(".line"))
- const sli = lines.indexOf(sle as Element)
- const eli = lines.indexOf(ele as Element)
- if (sli === -1 || eli === -1) return null
- const sl = sli + 1
- const el = eli + 1
- const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
- const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
- return { sl, sch, el, ech }
-}
diff --git a/packages/desktop/src/utils/id.ts b/packages/desktop/src/utils/id.ts
deleted file mode 100644
index fa27cf4c5..000000000
--- a/packages/desktop/src/utils/id.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import z from "zod"
-
-const prefixes = {
- session: "ses",
- message: "msg",
- permission: "per",
- user: "usr",
- part: "prt",
- pty: "pty",
-} as const
-
-const LENGTH = 26
-let lastTimestamp = 0
-let counter = 0
-
-type Prefix = keyof typeof prefixes
-export namespace Identifier {
- export function schema(prefix: Prefix) {
- return z.string().startsWith(prefixes[prefix])
- }
-
- export function ascending(prefix: Prefix, given?: string) {
- return generateID(prefix, false, given)
- }
-
- export function descending(prefix: Prefix, given?: string) {
- return generateID(prefix, true, given)
- }
-}
-
-function generateID(prefix: Prefix, descending: boolean, given?: string): string {
- if (!given) {
- return create(prefix, descending)
- }
-
- if (!given.startsWith(prefixes[prefix])) {
- throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
- }
-
- return given
-}
-
-function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
- const currentTimestamp = timestamp ?? Date.now()
-
- if (currentTimestamp !== lastTimestamp) {
- lastTimestamp = currentTimestamp
- counter = 0
- }
-
- counter += 1
-
- let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
-
- if (descending) {
- now = ~now
- }
-
- const timeBytes = new Uint8Array(6)
- for (let i = 0; i < 6; i += 1) {
- timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
- }
-
- return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
-}
-
-function bytesToHex(bytes: Uint8Array): string {
- let hex = ""
- for (let i = 0; i < bytes.length; i += 1) {
- hex += bytes[i].toString(16).padStart(2, "0")
- }
- return hex
-}
-
-function randomBase62(length: number): string {
- const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
- const bytes = getRandomBytes(length)
- let result = ""
- for (let i = 0; i < length; i += 1) {
- result += chars[bytes[i] % 62]
- }
- return result
-}
-
-function getRandomBytes(length: number): Uint8Array {
- const bytes = new Uint8Array(length)
- const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
-
- if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
- cryptoObj.getRandomValues(bytes)
- return bytes
- }
-
- for (let i = 0; i < length; i += 1) {
- bytes[i] = Math.floor(Math.random() * 256)
- }
-
- return bytes
-}
diff --git a/packages/desktop/src/utils/index.ts b/packages/desktop/src/utils/index.ts
deleted file mode 100644
index d87053269..000000000
--- a/packages/desktop/src/utils/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./dom"
diff --git a/packages/desktop/src/utils/persist.ts b/packages/desktop/src/utils/persist.ts
deleted file mode 100644
index 12b334f9f..000000000
--- a/packages/desktop/src/utils/persist.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { usePlatform } from "@/context/platform"
-import { makePersisted } from "@solid-primitives/storage"
-import { createResource, type Accessor } from "solid-js"
-import type { SetStoreFunction, Store } from "solid-js/store"
-
-type InitType = Promise | string | null
-type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor]
-
-export function persisted(key: string, store: [Store, SetStoreFunction]): PersistedWithReady {
- const platform = usePlatform()
- const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
-
- // Create a resource that resolves when the store is initialized
- // This integrates with Suspense and provides a ready signal
- const isAsync = init instanceof Promise
- const [ready] = createResource(
- () => init,
- async (initValue) => {
- if (initValue instanceof Promise) await initValue
- return true
- },
- { initialValue: !isAsync },
- )
-
- return [state, setState, init, () => ready() === true]
-}
diff --git a/packages/desktop/src/utils/prompt.ts b/packages/desktop/src/utils/prompt.ts
deleted file mode 100644
index 45c5ce1f3..000000000
--- a/packages/desktop/src/utils/prompt.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
-import type { Prompt, FileAttachmentPart } from "@/context/prompt"
-
-/**
- * Extract prompt content from message parts for restoring into the prompt input.
- * This is used by undo to restore the original user prompt.
- */
-export function extractPromptFromParts(parts: Part[]): Prompt {
- const result: Prompt = []
- let position = 0
-
- for (const part of parts) {
- if (part.type === "text") {
- const textPart = part as TextPart
- if (!textPart.synthetic && textPart.text) {
- result.push({
- type: "text",
- content: textPart.text,
- start: position,
- end: position + textPart.text.length,
- })
- position += textPart.text.length
- }
- } else if (part.type === "file") {
- const filePart = part as FilePart
- if (filePart.source?.type === "file") {
- const path = filePart.source.path
- const content = "@" + path
- const attachment: FileAttachmentPart = {
- type: "file",
- path,
- content,
- start: position,
- end: position + content.length,
- }
- result.push(attachment)
- position += content.length
- }
- }
- }
-
- if (result.length === 0) {
- result.push({ type: "text", content: "", start: 0, end: 0 })
- }
-
- return result
-}
diff --git a/packages/desktop/src/utils/solid-dnd.tsx b/packages/desktop/src/utils/solid-dnd.tsx
deleted file mode 100644
index a634be4b4..000000000
--- a/packages/desktop/src/utils/solid-dnd.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useDragDropContext } from "@thisbeyond/solid-dnd"
-import { JSXElement } from "solid-js"
-import type { Transformer } from "@thisbeyond/solid-dnd"
-
-export const getDraggableId = (event: unknown): string | undefined => {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
-}
-
-export const ConstrainDragXAxis = (): JSXElement => {
- const context = useDragDropContext()
- if (!context) return <>>
- const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
- const transformer: Transformer = {
- id: "constrain-x-axis",
- order: 100,
- callback: (transform) => ({ ...transform, x: 0 }),
- }
- onDragStart((event) => {
- const id = getDraggableId(event)
- if (!id) return
- addTransformer("draggables", id, transformer)
- })
- onDragEnd((event) => {
- const id = getDraggableId(event)
- if (!id) return
- removeTransformer("draggables", id, transformer.id)
- })
- return <>>
-}
-
-export const ConstrainDragYAxis = (): JSXElement => {
- const context = useDragDropContext()
- if (!context) return <>>
- const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
- const transformer: Transformer = {
- id: "constrain-y-axis",
- order: 100,
- callback: (transform) => ({ ...transform, y: 0 }),
- }
- onDragStart((event) => {
- const id = getDraggableId(event)
- if (!id) return
- addTransformer("draggables", id, transformer)
- })
- onDragEnd((event) => {
- const id = getDraggableId(event)
- if (!id) return
- removeTransformer("draggables", id, transformer.id)
- })
- return <>>
-}
diff --git a/packages/desktop/src/utils/speech.ts b/packages/desktop/src/utils/speech.ts
deleted file mode 100644
index 921e0a159..000000000
--- a/packages/desktop/src/utils/speech.ts
+++ /dev/null
@@ -1,302 +0,0 @@
-import { createSignal, onCleanup } from "solid-js"
-
-// Minimal types to avoid relying on non-standard DOM typings
-type RecognitionResult = {
- 0: { transcript: string }
- isFinal: boolean
-}
-
-type RecognitionEvent = {
- results: RecognitionResult[]
- resultIndex: number
-}
-
-interface Recognition {
- continuous: boolean
- interimResults: boolean
- lang: string
- start: () => void
- stop: () => void
- onresult: ((e: RecognitionEvent) => void) | null
- onerror: ((e: { error: string }) => void) | null
- onend: (() => void) | null
- onstart: (() => void) | null
-}
-
-const COMMIT_DELAY = 250
-
-const appendSegment = (base: string, addition: string) => {
- const trimmed = addition.trim()
- if (!trimmed) return base
- if (!base) return trimmed
- const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
- return `${base}${needsSpace ? " " : ""}${trimmed}`
-}
-
-const extractSuffix = (committed: string, hypothesis: string) => {
- const cleanHypothesis = hypothesis.trim()
- if (!cleanHypothesis) return ""
- const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
- const hypothesisTokens = cleanHypothesis.split(/\s+/)
- let index = 0
- while (
- index < baseTokens.length &&
- index < hypothesisTokens.length &&
- baseTokens[index] === hypothesisTokens[index]
- ) {
- index += 1
- }
- if (index < baseTokens.length) return ""
- return hypothesisTokens.slice(index).join(" ")
-}
-
-export function createSpeechRecognition(opts?: {
- lang?: string
- onFinal?: (text: string) => void
- onInterim?: (text: string) => void
-}) {
- const hasSupport =
- typeof window !== "undefined" &&
- Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
-
- const [isRecording, setIsRecording] = createSignal(false)
- const [committed, setCommitted] = createSignal("")
- const [interim, setInterim] = createSignal("")
-
- let recognition: Recognition | undefined
- let shouldContinue = false
- let committedText = ""
- let sessionCommitted = ""
- let pendingHypothesis = ""
- let lastInterimSuffix = ""
- let shrinkCandidate: string | undefined
- let commitTimer: number | undefined
-
- const cancelPendingCommit = () => {
- if (commitTimer === undefined) return
- clearTimeout(commitTimer)
- commitTimer = undefined
- }
-
- const commitSegment = (segment: string) => {
- const nextCommitted = appendSegment(committedText, segment)
- if (nextCommitted === committedText) return
- committedText = nextCommitted
- setCommitted(committedText)
- if (opts?.onFinal) opts.onFinal(segment.trim())
- }
-
- const promotePending = () => {
- if (!pendingHypothesis) return
- const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
- if (!suffix) {
- pendingHypothesis = ""
- return
- }
- sessionCommitted = appendSegment(sessionCommitted, suffix)
- commitSegment(suffix)
- pendingHypothesis = ""
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- }
-
- const applyInterim = (suffix: string, hypothesis: string) => {
- cancelPendingCommit()
- pendingHypothesis = hypothesis
- lastInterimSuffix = suffix
- shrinkCandidate = undefined
- setInterim(suffix)
- if (opts?.onInterim) {
- opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
- }
- if (!suffix) return
- const snapshot = hypothesis
- commitTimer = window.setTimeout(() => {
- if (pendingHypothesis !== snapshot) return
- const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
- if (!currentSuffix) return
- sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
- commitSegment(currentSuffix)
- pendingHypothesis = ""
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- }, COMMIT_DELAY)
- }
-
- if (hasSupport) {
- const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
-
- recognition = new Ctor()
- recognition.continuous = false
- recognition.interimResults = true
- recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
-
- recognition.onresult = (event: RecognitionEvent) => {
- if (!event.results.length) return
-
- let aggregatedFinal = ""
- let latestHypothesis = ""
-
- for (let i = 0; i < event.results.length; i += 1) {
- const result = event.results[i]
- const transcript = (result[0]?.transcript || "").trim()
- if (!transcript) continue
- if (result.isFinal) {
- aggregatedFinal = appendSegment(aggregatedFinal, transcript)
- } else {
- latestHypothesis = transcript
- }
- }
-
- if (aggregatedFinal) {
- cancelPendingCommit()
- const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
- if (finalSuffix) {
- sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
- commitSegment(finalSuffix)
- }
- pendingHypothesis = ""
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- return
- }
-
- cancelPendingCommit()
-
- if (!latestHypothesis) {
- shrinkCandidate = undefined
- applyInterim("", "")
- return
- }
-
- const suffix = extractSuffix(sessionCommitted, latestHypothesis)
-
- if (!suffix) {
- if (!lastInterimSuffix) {
- shrinkCandidate = undefined
- applyInterim("", latestHypothesis)
- return
- }
- if (shrinkCandidate === "") {
- applyInterim("", latestHypothesis)
- return
- }
- shrinkCandidate = ""
- pendingHypothesis = latestHypothesis
- return
- }
-
- if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
- if (shrinkCandidate === suffix) {
- applyInterim(suffix, latestHypothesis)
- return
- }
- shrinkCandidate = suffix
- pendingHypothesis = latestHypothesis
- return
- }
-
- shrinkCandidate = undefined
- applyInterim(suffix, latestHypothesis)
- }
-
- recognition.onerror = (e: { error: string }) => {
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- if (e.error === "no-speech" && shouldContinue) {
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- setTimeout(() => {
- try {
- recognition?.start()
- } catch {}
- }, 150)
- return
- }
- shouldContinue = false
- setIsRecording(false)
- }
-
- recognition.onstart = () => {
- sessionCommitted = ""
- pendingHypothesis = ""
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- setIsRecording(true)
- }
-
- recognition.onend = () => {
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setIsRecording(false)
- if (shouldContinue) {
- setTimeout(() => {
- try {
- recognition?.start()
- } catch {}
- }, 150)
- }
- }
- }
-
- const start = () => {
- if (!recognition) return
- shouldContinue = true
- sessionCommitted = ""
- pendingHypothesis = ""
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- try {
- recognition.start()
- } catch {}
- }
-
- const stop = () => {
- if (!recognition) return
- shouldContinue = false
- promotePending()
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- try {
- recognition.stop()
- } catch {}
- }
-
- onCleanup(() => {
- shouldContinue = false
- promotePending()
- cancelPendingCommit()
- lastInterimSuffix = ""
- shrinkCandidate = undefined
- setInterim("")
- if (opts?.onInterim) opts.onInterim("")
- try {
- recognition?.stop()
- } catch {}
- })
-
- return {
- isSupported: () => hasSupport,
- isRecording,
- committed,
- interim,
- start,
- stop,
- }
-}
diff --git a/packages/desktop/sst-env.d.ts b/packages/desktop/sst-env.d.ts
deleted file mode 100644
index b6a7e9066..000000000
--- a/packages/desktop/sst-env.d.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/* This file is auto-generated by SST. Do not edit. */
-/* tslint:disable */
-/* eslint-disable */
-/* deno-fmt-ignore-file */
-
-///
-
-import "sst"
-export {}
\ No newline at end of file
diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json
deleted file mode 100644
index db04f79ca..000000000
--- a/packages/desktop/tsconfig.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/tsconfig",
- "compilerOptions": {
- "composite": true,
- "target": "ESNext",
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true,
- "esModuleInterop": true,
- "jsx": "preserve",
- "jsxImportSource": "solid-js",
- "allowJs": true,
- "strict": true,
- "noEmit": false,
- "emitDeclarationOnly": true,
- "outDir": "node_modules/.ts-dist",
- "isolatedModules": true,
- "paths": {
- "@/*": ["./src/*"]
- }
- },
- "exclude": ["dist", "ts-dist"]
-}
diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts
deleted file mode 100644
index 57071a894..000000000
--- a/packages/desktop/vite.config.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { defineConfig } from "vite"
-import desktopPlugin from "./vite"
-
-export default defineConfig({
- plugins: [desktopPlugin] as any,
- server: {
- host: "0.0.0.0",
- allowedHosts: true,
- port: 3000,
- },
- build: {
- target: "esnext",
- sourcemap: true,
- },
-})
diff --git a/packages/desktop/vite.js b/packages/desktop/vite.js
deleted file mode 100644
index 6b8fd6137..000000000
--- a/packages/desktop/vite.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import solidPlugin from "vite-plugin-solid"
-import tailwindcss from "@tailwindcss/vite"
-import { fileURLToPath } from "url"
-
-/**
- * @type {import("vite").PluginOption}
- */
-export default [
- {
- name: "opencode-desktop:config",
- config() {
- return {
- resolve: {
- alias: {
- "@": fileURLToPath(new URL("./src", import.meta.url)),
- },
- },
- worker: {
- format: "es",
- },
- }
- },
- },
- tailwindcss(),
- solidPlugin(),
-]
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 1da1d1ec6..c74dbbb41 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -2600,10 +2600,10 @@ export namespace Server {
},
)
.all("/*", async (c) => {
- return proxy(`https://desktop.opencode.ai${c.req.path}`, {
+ return proxy(`https://app.opencode.ai${c.req.path}`, {
...c.req,
headers: {
- host: "desktop.opencode.ai",
+ host: "app.opencode.ai",
},
})
}),
diff --git a/packages/tauri/package.json b/packages/tauri/package.json
index 1db8ec12c..ecb4751a0 100644
--- a/packages/tauri/package.json
+++ b/packages/tauri/package.json
@@ -12,7 +12,7 @@
"tauri": "tauri"
},
"dependencies": {
- "@opencode-ai/desktop": "workspace:*",
+ "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
diff --git a/packages/tauri/src/index.tsx b/packages/tauri/src/index.tsx
index dc2c4047d..57c1fbe55 100644
--- a/packages/tauri/src/index.tsx
+++ b/packages/tauri/src/index.tsx
@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
-import { App, PlatformProvider, Platform } from "@opencode-ai/desktop"
+import { App, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
diff --git a/packages/tauri/tsconfig.json b/packages/tauri/tsconfig.json
index e7f5c5c27..64a6bc357 100644
--- a/packages/tauri/tsconfig.json
+++ b/packages/tauri/tsconfig.json
@@ -15,6 +15,6 @@
"emitDeclarationOnly": false,
"outDir": "node_modules/.ts-dist"
},
- "references": [{ "path": "../desktop" }],
+ "references": [{ "path": "../app" }],
"include": ["src"]
}
diff --git a/packages/tauri/vite.config.ts b/packages/tauri/vite.config.ts
index ead3d8a8d..123a2028c 100644
--- a/packages/tauri/vite.config.ts
+++ b/packages/tauri/vite.config.ts
@@ -1,11 +1,11 @@
import { defineConfig } from "vite"
-import desktopPlugin from "@opencode-ai/desktop/vite"
+import appPlugin from "@opencode-ai/app/vite"
const host = process.env.TAURI_DEV_HOST
// https://vite.dev/config/
export default defineConfig({
- plugins: [desktopPlugin],
+ plugins: [appPlugin],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
diff --git a/script/publish-start.ts b/script/publish-start.ts
index 9213e1352..6dcbbea5d 100755
--- a/script/publish-start.ts
+++ b/script/publish-start.ts
@@ -17,7 +17,7 @@ if (!Script.preview) {
.then((data: any) => data.version)
const log =
- await $`git log v${previous}..HEAD --oneline --format="%h %s" -- packages/opencode packages/sdk packages/plugin packages/tauri packages/desktop`.text()
+ await $`git log v${previous}..HEAD --oneline --format="%h %s" -- packages/opencode packages/sdk packages/plugin packages/tauri packages/app`.text()
const commits = log.split("\n").filter((line) => line && !line.match(/^\w+ (ignore:|test:|chore:|ci:)/i))
@@ -64,7 +64,7 @@ if (!Script.preview) {
Group the changes into these categories based on the [areas: ...] tags (omit any category with no changes):
- **TUI**: Changes to "opencode" area (the terminal/CLI interface)
- - **Desktop**: Changes to "desktop" or "tauri" areas (the desktop application)
+ - **Desktop**: Changes to "app" or "tauri" areas (the desktop application)
- **SDK**: Changes to "sdk" or "plugin" areas (the SDK and plugin system)
- **Extensions**: Changes to "extensions/zed", "extensions/vscode", or "github" areas (editor extensions and GitHub Action)
- **Other**: Any user-facing changes that don't fit the above categories
--
cgit v1.2.3