summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2025-10-31 15:07:36 -0400
committerGitHub <[email protected]>2025-10-31 15:07:36 -0400
commit96bdeb3c7b04e95ecabaa0253deddd2a22e14afe (patch)
treec37898ee62e8c4f84f5d9abd4c644b1ec7066963 /packages
parent81c617770d8595978b497a9cf3c0a5316b108352 (diff)
downloadopencode-96bdeb3c7b04e95ecabaa0253deddd2a22e14afe.tar.gz
opencode-96bdeb3c7b04e95ecabaa0253deddd2a22e14afe.zip
OpenTUI is here (#2685)
Diffstat (limited to 'packages')
-rw-r--r--packages/console/app/package.json6
-rw-r--r--packages/console/core/package.json2
-rw-r--r--packages/desktop/src/context/sync.tsx14
-rw-r--r--packages/function/src/api.ts20
-rw-r--r--packages/opencode/bunfig.toml2
-rw-r--r--packages/opencode/package.json20
-rw-r--r--packages/opencode/parsers-config.ts207
-rwxr-xr-xpackages/opencode/script/build.ts36
-rwxr-xr-xpackages/opencode/script/publish.ts4
-rw-r--r--packages/opencode/src/bun/index.ts5
-rw-r--r--packages/opencode/src/cli/cmd/attach.ts65
-rw-r--r--packages/opencode/src/cli/cmd/auth.ts2
-rw-r--r--packages/opencode/src/cli/cmd/github.ts2
-rw-r--r--packages/opencode/src/cli/cmd/opentui/opentui.ts0
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx327
-rw-r--r--packages/opencode/src/cli/cmd/tui/attach.ts22
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/border.tsx16
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx31
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx96
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx74
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx80
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx78
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx46
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx52
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/logo.tsx29
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx403
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx78
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx703
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/exit.tsx14
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/helper.tsx25
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/keybind.tsx103
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/local.tsx276
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/route.tsx46
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sdk.tsx37
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx270
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx658
-rw-r--r--packages/opencode/src/cli/cmd/tui/event.ts39
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/home.tsx83
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx56
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx37
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/header.tsx81
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx1270
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx175
-rw-r--r--packages/opencode/src/cli/cmd/tui/spawn.ts57
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts105
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx55
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx79
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx39
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx275
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/dialog.tsx171
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx56
-rw-r--r--packages/opencode/src/cli/cmd/tui/ui/toast.tsx83
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/clipboard.ts127
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/editor.ts31
-rw-r--r--packages/opencode/src/cli/cmd/tui/worker.ts48
-rw-r--r--packages/opencode/src/cli/upgrade.ts17
-rw-r--r--packages/opencode/src/config/config.ts135
-rw-r--r--packages/opencode/src/file/index.ts4
-rw-r--r--packages/opencode/src/global/index.ts9
-rw-r--r--packages/opencode/src/index.ts8
-rw-r--r--packages/opencode/src/lsp/client.ts5
-rw-r--r--packages/opencode/src/lsp/index.ts37
-rw-r--r--packages/opencode/src/lsp/server.ts4
-rw-r--r--packages/opencode/src/mcp/index.ts198
-rw-r--r--packages/opencode/src/permission/index.ts16
-rw-r--r--packages/opencode/src/plugin/index.ts1
-rw-r--r--packages/opencode/src/project/instance.ts10
-rw-r--r--packages/opencode/src/project/state.ts9
-rw-r--r--packages/opencode/src/server/server.ts177
-rw-r--r--packages/opencode/src/session/compaction.ts1
-rw-r--r--packages/opencode/src/session/message-v2.ts6
-rw-r--r--packages/opencode/src/session/prompt.ts12
-rw-r--r--packages/opencode/src/session/summary.ts15
-rw-r--r--packages/opencode/src/session/system.ts3
-rw-r--r--packages/opencode/src/tool/bash.ts59
-rw-r--r--packages/opencode/src/tool/write.ts2
-rw-r--r--packages/opencode/src/util/binary.ts41
-rw-r--r--packages/opencode/src/util/eventloop.ts20
-rw-r--r--packages/opencode/src/util/iife.ts3
-rw-r--r--packages/opencode/src/util/keybind.ts76
-rw-r--r--packages/opencode/src/util/locale.ts39
-rw-r--r--packages/opencode/src/util/rpc.ts42
-rw-r--r--packages/opencode/src/util/signal.ts12
-rw-r--r--packages/opencode/test/fixture/fixture.ts5
-rw-r--r--packages/opencode/test/keybind.test.ts305
-rw-r--r--packages/opencode/test/tool/patch.test.ts12
-rw-r--r--packages/plugin/package.json2
-rw-r--r--packages/plugin/src/example.ts6
-rw-r--r--packages/sdk/js/package.json2
-rwxr-xr-xpackages/sdk/js/script/build.ts2
-rw-r--r--packages/sdk/js/src/gen/sdk.gen.ts40
-rw-r--r--packages/sdk/js/src/gen/types.gen.ts228
-rw-r--r--packages/sdk/js/tsconfig.json3
-rw-r--r--packages/tui/internal/theme/themes/vesper.json2
-rw-r--r--packages/web/src/components/share/content-diff.tsx5
-rw-r--r--packages/web/src/components/share/part.tsx6
96 files changed, 7822 insertions, 473 deletions
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 3d59ee5d5..9e3ab4497 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -11,9 +11,11 @@
},
"dependencies": {
"@ibm/plex": "6.4.1",
- "@kobalte/core": "catalog:",
- "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*",
+ "@opencode-ai/console-mail": "workspace:*",
+ "@openauthjs/openauth": "catalog:",
+ "@kobalte/core": "catalog:",
+ "@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 8bdf7f73b..e9df61911 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -14,7 +14,7 @@
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
- "ulid": "3.0.0",
+ "ulid": "catalog:",
"zod": "catalog:"
},
"exports": {
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index c60206b0b..06fc05677 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -1,4 +1,16 @@
-import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
+import type {
+ Message,
+ Agent,
+ Provider,
+ Session,
+ Part,
+ Config,
+ Path,
+ File,
+ FileNode,
+ Project,
+ Command,
+} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts
index 572ac3cab..6f00dae9a 100644
--- a/packages/function/src/api.ts
+++ b/packages/function/src/api.ts
@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
// Lookup installation
const octokit = new Octokit({ auth: appAuth.token })
- const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
+ const { data: installation } = await octokit.apps.getRepoInstallation({
+ owner,
+ repo,
+ })
// Get installation token
- const installationAuth = await auth({ type: "installation", installationId: installation.id })
+ const installationAuth = await auth({
+ type: "installation",
+ installationId: installation.id,
+ })
return c.json({ token: installationAuth.token })
})
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
// Lookup installation
const appClient = new Octokit({ auth: appAuth.token })
- const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
+ const { data: installation } = await appClient.apps.getRepoInstallation({
+ owner,
+ repo,
+ })
// Get installation token
- const installationAuth = await auth({ type: "installation", installationId: installation.id })
+ const installationAuth = await auth({
+ type: "installation",
+ installationId: installation.id,
+ })
return c.json({ token: installationAuth.token })
} catch (e: any) {
diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml
index 786a37744..d7b987cbb 100644
--- a/packages/opencode/bunfig.toml
+++ b/packages/opencode/bunfig.toml
@@ -1,2 +1,4 @@
+preload = ["@opentui/solid/preload"]
+
[test]
preload = ["./test/preload.ts"]
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index a36117eaa..fa27c49ee 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -8,7 +8,8 @@
"typecheck": "tsgo --noEmit",
"test": "bun test",
"build": "./script/build.ts",
- "dev": "bun run ./src/index.ts"
+ "dev": "bun run --conditions=browser ./src/index.ts",
+ "random": "echo 'Random script updated at $(date)'"
},
"bin": {
"opencode": "./bin/opencode"
@@ -19,6 +20,7 @@
"devDependencies": {
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
+ "@babel/core": "7.28.4",
"@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -27,12 +29,15 @@
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
+ "@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"vscode-languageserver-types": "3.17.5",
+ "why-is-node-running": "3.2.2",
+ "zod-to-json-schema": "3.24.5",
"@opencode-ai/script": "workspace:*"
},
"dependencies": {
@@ -49,12 +54,16 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
+ "@opentui/core": "0.0.0-20251031-fc297165",
+ "@opentui/solid": "0.0.0-20251031-fc297165",
"@parcel/watcher": "2.5.1",
+ "@solid-primitives/event-bus": "1.1.2",
"@pierre/precision-diffs": "catalog:",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"chokidar": "4.0.3",
+ "clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"fuzzysort": "3.1.0",
@@ -65,13 +74,14 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
+ "partial-json": "0.1.7",
"remeda": "catalog:",
- "tree-sitter": "0.22.4",
- "tree-sitter-bash": "0.23.3",
+ "solid-js": "catalog:",
+ "tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
- "ulid": "3.0.1",
+ "ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
- "web-tree-sitter": "0.22.6",
+ "web-tree-sitter": "0.25.10",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts
new file mode 100644
index 000000000..cfa00454b
--- /dev/null
+++ b/packages/opencode/parsers-config.ts
@@ -0,0 +1,207 @@
+export default {
+ // NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
+ // Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
+ // marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
+ // ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
+ parsers: [
+ {
+ filetype: "python",
+ wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
+ queries: {
+ highlights: [
+ // NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
+ // it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
+ // Unclear.
+ // "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
+ "https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "rust",
+ wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "go",
+ wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "cpp",
+ wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "csharp",
+ wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "bash",
+ wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "c",
+ wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "java",
+ wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "ruby",
+ wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
+ ],
+ locals: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
+ ],
+ },
+ },
+ {
+ filetype: "php",
+ wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
+ queries: {
+ highlights: [
+ // NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
+ // "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
+ "https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "scala",
+ wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "html",
+ wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
+ queries: {
+ highlights: [
+ // NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
+ // "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
+ "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
+ ],
+ // TODO: Injections not working for some reason
+ // injections: [
+ // "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
+ // ],
+ },
+ // injectionMapping: {
+ // nodeTypes: {
+ // script_element: "javascript",
+ // style_element: "css",
+ // },
+ // infoStringMap: {
+ // javascript: "javascript",
+ // css: "css",
+ // },
+ // },
+ },
+ {
+ filetype: "json",
+ wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "haskell",
+ wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "css",
+ wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "julia",
+ wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
+ ],
+ },
+ },
+ {
+ filetype: "ocaml",
+ wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
+ queries: {
+ highlights: [
+ "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
+ ],
+ },
+ },
+ ],
+}
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index b3d6d57dd..1d3a3face 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -1,5 +1,9 @@
#!/usr/bin/env bun
+
+import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
+import fs from "fs"
+import { $ } from "bun"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
-import { $ } from "bun"
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
-const GOARCH: Record<string, string> = {
- arm64: "arm64",
- x64: "amd64",
- "x64-baseline": "amd64",
-}
+const singleFlag = process.argv.includes("--single")
-const targets = [
+const allTargets = [
["windows", "x64"],
["linux", "arm64"],
["linux", "x64"],
@@ -28,6 +27,10 @@ const targets = [
["darwin", "arm64"],
]
+const targets = singleFlag
+ ? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
+ : allTargets
+
await $`rm -rf dist`
const binaries: Record<string, string> = {}
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
- await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${Script.version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
- .cwd("../tui")
- .quiet()
+
+ const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
+ await $`mkdir -p ../../node_modules/${opentui}`
+ await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
+ await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
await $`mkdir -p ../../node_modules/${watcher}`
- await $`npm pack npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
+ await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
+ const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
await Bun.build({
+ conditions: ["browser"],
+ tsconfig: "./tsconfig.json",
+ plugins: [solidPlugin],
sourcemap: "external",
compile: {
target: `bun-${os}-${arch}` as any,
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
windows: {},
},
- entrypoints: ["./src/index.ts"],
+ entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
+ OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
OPENCODE_CHANNEL: `'${Script.channel}'`,
- OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
},
})
+
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts
index 833198cb0..ffa9a91f3 100755
--- a/packages/opencode/script/publish.ts
+++ b/packages/opencode/script/publish.ts
@@ -25,8 +25,8 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
- preinstall: "node ./preinstall.mjs",
- postinstall: "node ./postinstall.mjs",
+ preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
+ postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
version: Script.version,
optionalDependencies: binaries,
diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts
index 8874a27ca..5f1847275 100644
--- a/packages/opencode/src/bun/index.ts
+++ b/packages/opencode/src/bun/index.ts
@@ -74,7 +74,10 @@ export namespace BunProc {
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
- log.info("installing package using Bun's default registry resolution", { pkg, version })
+ log.info("installing package using Bun's default registry resolution", {
+ pkg,
+ version,
+ })
await BunProc.run(args, {
cwd: Global.Path.cache,
diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts
deleted file mode 100644
index 868585b05..000000000
--- a/packages/opencode/src/cli/cmd/attach.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { Global } from "../../global"
-import { cmd } from "./cmd"
-import path from "path"
-import fs from "fs/promises"
-import { Log } from "../../util/log"
-
-import { $ } from "bun"
-
-export const AttachCommand = cmd({
- command: "attach <server>",
- describe: "attach to a running opencode server",
- builder: (yargs) =>
- yargs
- .positional("server", {
- type: "string",
- describe: "http://localhost:4096",
- })
- .option("session", {
- alias: ["s"],
- describe: "session id to continue",
- type: "string",
- }),
- handler: async (args) => {
- let cmd = [] as string[]
- const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
- if (tui) {
- let binaryName = tui.name
- if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
- binaryName += ".exe"
- }
- const binary = path.join(Global.Path.cache, "tui", binaryName)
- const file = Bun.file(binary)
- if (!(await file.exists())) {
- await Bun.write(file, tui, { mode: 0o755 })
- if (process.platform !== "win32") await fs.chmod(binary, 0o755)
- }
- cmd = [binary]
- }
- if (!tui) {
- const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
- let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
- await $`go build -o ${binaryName} ./main.go`.cwd(dir)
- cmd = [path.join(dir, binaryName)]
- }
- if (args.session) {
- cmd.push("--session", args.session)
- }
- Log.Default.info("tui", {
- cmd,
- })
- const proc = Bun.spawn({
- cmd,
- stdout: "inherit",
- stderr: "inherit",
- stdin: "inherit",
- env: {
- ...process.env,
- CGO_ENABLED: "0",
- OPENCODE_SERVER: args.server,
- },
- })
-
- await proc.exited
- },
-})
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index e0980e137..763d82b3f 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
- const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
+ const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index d8fc2f3ac..cd3ceb94b 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -1,5 +1,4 @@
import path from "path"
-import { $ } from "bun"
import { exec } from "child_process"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
@@ -20,6 +19,7 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
+import { $ } from "bun"
type GitHubAuthor = {
login: string
diff --git a/packages/opencode/src/cli/cmd/opentui/opentui.ts b/packages/opencode/src/cli/cmd/opentui/opentui.ts
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/opencode/src/cli/cmd/opentui/opentui.ts
+++ /dev/null
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
new file mode 100644
index 000000000..75ea3fb25
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -0,0 +1,327 @@
+import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { Clipboard } from "@tui/util/clipboard"
+import { TextAttributes } from "@opentui/core"
+import { RouteProvider, useRoute, type Route } from "@tui/context/route"
+import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js"
+import { Installation } from "@/installation"
+import { Global } from "@/global"
+import { DialogProvider, useDialog } from "@tui/ui/dialog"
+import { SDKProvider, useSDK } from "@tui/context/sdk"
+import { SyncProvider, useSync } from "@tui/context/sync"
+import { LocalProvider, useLocal } from "@tui/context/local"
+import { DialogModel } from "@tui/component/dialog-model"
+import { DialogStatus } from "@tui/component/dialog-status"
+import { DialogThemeList } from "@tui/component/dialog-theme-list"
+import { DialogHelp } from "./ui/dialog-help"
+import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
+import { DialogAgent } from "@tui/component/dialog-agent"
+import { DialogSessionList } from "@tui/component/dialog-session-list"
+import { KeybindProvider } from "@tui/context/keybind"
+import { ThemeProvider, useTheme } from "@tui/context/theme"
+import { Home } from "@tui/routes/home"
+import { Session } from "@tui/routes/session"
+import { PromptHistoryProvider } from "./component/prompt/history"
+import { DialogAlert } from "./ui/dialog-alert"
+import { ToastProvider, useToast } from "./ui/toast"
+import { ExitProvider } from "./context/exit"
+import type { SessionRoute } from "./context/route"
+import { Session as SessionApi } from "@/session"
+import { TuiEvent } from "./event"
+
+export function tui(input: {
+ url: string
+ sessionID?: string
+ model?: string
+ agent?: string
+ onExit?: () => Promise<void>
+}) {
+ // promise to prevent immediate exit
+ return new Promise<void>((resolve) => {
+ const routeData: Route | undefined = input.sessionID
+ ? {
+ type: "session",
+ sessionID: input.sessionID,
+ }
+ : undefined
+
+ const onExit = async () => {
+ await input.onExit?.()
+ resolve()
+ }
+
+ render(
+ () => {
+ return (
+ <ErrorBoundary fallback={<text>Something went wrong</text>}>
+ <ExitProvider onExit={onExit}>
+ <ToastProvider>
+ <RouteProvider data={routeData}>
+ <SDKProvider url={input.url}>
+ <SyncProvider>
+ <ThemeProvider>
+ <LocalProvider initialModel={input.model} initialAgent={input.agent}>
+ <KeybindProvider>
+ <DialogProvider>
+ <CommandProvider>
+ <PromptHistoryProvider>
+ <App />
+ </PromptHistoryProvider>
+ </CommandProvider>
+ </DialogProvider>
+ </KeybindProvider>
+ </LocalProvider>
+ </ThemeProvider>
+ </SyncProvider>
+ </SDKProvider>
+ </RouteProvider>
+ </ToastProvider>
+ </ExitProvider>
+ </ErrorBoundary>
+ )
+ },
+ {
+ targetFps: 60,
+ gatherStats: false,
+ exitOnCtrlC: false,
+ },
+ )
+ })
+}
+
+function App() {
+ const route = useRoute()
+ const dimensions = useTerminalDimensions()
+ const renderer = useRenderer()
+ renderer.disableStdoutInterception()
+ const dialog = useDialog()
+ const local = useLocal()
+ const command = useCommandDialog()
+ const { event } = useSDK()
+ const sync = useSync()
+ const toast = useToast()
+ const [sessionExists, setSessionExists] = createSignal(false)
+ const { theme } = useTheme()
+
+ useKeyboard(async (evt) => {
+ if (evt.meta && evt.name === "t") {
+ renderer.toggleDebugOverlay()
+ return
+ }
+
+ if (evt.meta && evt.name === "d") {
+ renderer.console.toggle()
+ return
+ }
+ })
+
+ // Make sure session is valid, otherwise redirect to home
+ createEffect(async () => {
+ if (route.data.type === "session") {
+ const data = route.data as SessionRoute
+ await sync.session.sync(data.sessionID).catch(() => {
+ toast.show({
+ message: `Session not found: ${data.sessionID}`,
+ variant: "error",
+ })
+ return route.navigate({ type: "home" })
+ })
+ setSessionExists(true)
+ }
+ })
+
+ createEffect(() => {
+ console.log(JSON.stringify(route.data))
+ })
+
+ command.register(() => [
+ {
+ title: "Switch session",
+ value: "session.list",
+ keybind: "session_list",
+ category: "Session",
+ onSelect: () => {
+ dialog.replace(() => <DialogSessionList />)
+ },
+ },
+ {
+ title: "New session",
+ value: "session.new",
+ keybind: "session_new",
+ category: "Session",
+ onSelect: () => {
+ route.navigate({
+ type: "home",
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Switch model",
+ value: "model.list",
+ keybind: "model_list",
+ category: "Agent",
+ onSelect: () => {
+ dialog.replace(() => <DialogModel />)
+ },
+ },
+ {
+ title: "Switch agent",
+ value: "agent.list",
+ keybind: "agent_list",
+ category: "Agent",
+ onSelect: () => {
+ dialog.replace(() => <DialogAgent />)
+ },
+ },
+ {
+ title: "Agent cycle",
+ value: "agent.cycle",
+ keybind: "agent_cycle",
+ category: "Agent",
+ disabled: true,
+ onSelect: () => {
+ local.agent.move(1)
+ },
+ },
+ {
+ title: "Agent cycle reverse",
+ value: "agent.cycle.reverse",
+ keybind: "agent_cycle_reverse",
+ category: "Agent",
+ disabled: true,
+ onSelect: () => {
+ local.agent.move(-1)
+ },
+ },
+ {
+ title: "View status",
+ keybind: "status_view",
+ value: "opencode.status",
+ onSelect: () => {
+ dialog.replace(() => <DialogStatus />)
+ },
+ category: "System",
+ },
+ {
+ title: "Switch theme",
+ value: "theme.switch",
+ onSelect: () => {
+ dialog.replace(() => <DialogThemeList />)
+ },
+ category: "System",
+ },
+ {
+ title: "Help",
+ value: "help.show",
+ onSelect: () => {
+ dialog.replace(() => <DialogHelp />)
+ },
+ category: "System",
+ },
+ ])
+
+ createEffect(() => {
+ const providerID = local.model.current().providerID
+ if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
+ untrack(() => {
+ DialogAlert.show(
+ dialog,
+ "Warning",
+ "While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
+ ).then(() => local.kv.set("openrouter_warning", true))
+ })
+ }
+ })
+
+ event.on(TuiEvent.CommandExecute.type, (evt) => {
+ command.trigger(evt.properties.command)
+ })
+
+ event.on(TuiEvent.ToastShow.type, (evt) => {
+ toast.show({
+ title: evt.properties.title,
+ message: evt.properties.message,
+ variant: evt.properties.variant,
+ duration: evt.properties.duration,
+ })
+ })
+
+ event.on(SessionApi.Event.Deleted.type, (evt) => {
+ if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
+ route.navigate({ type: "home" })
+ toast.show({
+ variant: "info",
+ message: "The current session was deleted",
+ })
+ }
+ })
+
+ return (
+ <box
+ width={dimensions().width}
+ height={dimensions().height}
+ backgroundColor={theme.background}
+ onMouseUp={async () => {
+ const text = renderer.getSelection()?.getSelectedText()
+ if (text && text.length > 0) {
+ const base64 = Buffer.from(text).toString("base64")
+ const osc52 = `\x1b]52;c;${base64}\x07`
+ const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
+ /* @ts-expect-error */
+ renderer.writeOut(finalOsc52)
+ await Clipboard.copy(text)
+ renderer.clearSelection()
+ toast.show({ message: "Copied to clipboard", variant: "info" })
+ }
+ }}
+ >
+ <box flexDirection="column" flexGrow={1}>
+ <Switch>
+ <Match when={route.data.type === "home"}>
+ <Home />
+ </Match>
+ <Match when={route.data.type === "session" && sessionExists()}>
+ <Session />
+ </Match>
+ </Switch>
+ </box>
+ <box
+ height={1}
+ backgroundColor={theme.backgroundPanel}
+ flexDirection="row"
+ justifyContent="space-between"
+ flexShrink={0}
+ >
+ <box flexDirection="row">
+ <box
+ flexDirection="row"
+ backgroundColor={theme.backgroundElement}
+ paddingLeft={1}
+ paddingRight={1}
+ >
+ <text fg={theme.textMuted}>open</text>
+ <text attributes={TextAttributes.BOLD}>code </text>
+ <text fg={theme.textMuted}>v{Installation.VERSION}</text>
+ </box>
+ <box paddingLeft={1} paddingRight={1}>
+ <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
+ </box>
+ </box>
+ <box flexDirection="row" flexShrink={0}>
+ <text fg={theme.textMuted} paddingRight={1}>
+ tab
+ </text>
+ <text fg={local.agent.color(local.agent.current().name)}>{""}</text>
+ <text
+ bg={local.agent.color(local.agent.current().name)}
+ fg={theme.background}
+ wrapMode="none"
+ >
+ <span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
+ <span> AGENT </span>
+ </text>
+ </box>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
new file mode 100644
index 000000000..38f1b6719
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -0,0 +1,22 @@
+import { cmd } from "../cmd"
+import { tui } from "./app"
+
+export const AttachCommand = cmd({
+ command: "attach <url>",
+ describe: "attach to a running opencode server",
+ builder: (yargs) =>
+ yargs
+ .positional("url", {
+ type: "string",
+ describe: "http://localhost:4096",
+ demandOption: true,
+ })
+ .option("dir", {
+ type: "string",
+ description: "directory to run in",
+ }),
+ handler: async (args) => {
+ if (args.dir) process.chdir(args.dir)
+ await tui(args)
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/component/border.tsx b/packages/opencode/src/cli/cmd/tui/component/border.tsx
new file mode 100644
index 000000000..9cbb96068
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/border.tsx
@@ -0,0 +1,16 @@
+export const SplitBorder = {
+ border: ["left" as const, "right" as const],
+ customBorderChars: {
+ topLeft: "",
+ bottomLeft: "",
+ vertical: "┃",
+ topRight: "",
+ bottomRight: "",
+ horizontal: "",
+ bottomT: "",
+ topT: "",
+ cross: "",
+ leftT: "",
+ rightT: "",
+ },
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
new file mode 100644
index 000000000..65aaeb22b
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
@@ -0,0 +1,31 @@
+import { createMemo } from "solid-js"
+import { useLocal } from "@tui/context/local"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useDialog } from "@tui/ui/dialog"
+
+export function DialogAgent() {
+ const local = useLocal()
+ const dialog = useDialog()
+
+ const options = createMemo(() =>
+ local.agent.list().map((item) => {
+ return {
+ value: item.name,
+ title: item.name,
+ description: item.builtIn ? "native" : item.description,
+ }
+ }),
+ )
+
+ return (
+ <DialogSelect
+ title="Select agent"
+ current={local.agent.current().name}
+ options={options()}
+ onSelect={(option) => {
+ local.agent.set(option.value)
+ dialog.clear()
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
new file mode 100644
index 000000000..91150da9c
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
@@ -0,0 +1,96 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import {
+ createContext,
+ createMemo,
+ createSignal,
+ onCleanup,
+ useContext,
+ type Accessor,
+ type ParentProps,
+} from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+import { useKeybind } from "@tui/context/keybind"
+import type { KeybindsConfig } from "@opencode-ai/sdk"
+
+type Context = ReturnType<typeof init>
+const ctx = createContext<Context>()
+
+export type CommandOption = DialogSelectOption & {
+ keybind?: keyof KeybindsConfig
+}
+
+function init() {
+ const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
+ const dialog = useDialog()
+ const keybind = useKeybind()
+ const options = createMemo(() => {
+ return registrations().flatMap((x) => x())
+ })
+
+ useKeyboard((evt) => {
+ for (const option of options()) {
+ if (option.keybind && keybind.match(option.keybind, evt)) {
+ evt.preventDefault()
+ option.onSelect?.(dialog)
+ return
+ }
+ }
+ })
+
+ const result = {
+ trigger(name: string) {
+ for (const option of options()) {
+ if (option.value === name) {
+ option.onSelect?.(dialog)
+ return
+ }
+ }
+ },
+ register(cb: () => CommandOption[]) {
+ const results = createMemo(cb)
+ setRegistrations((arr) => [results, ...arr])
+ onCleanup(() => {
+ setRegistrations((arr) => arr.filter((x) => x !== results))
+ })
+ },
+ get options() {
+ return options()
+ },
+ }
+ return result
+}
+
+export function useCommandDialog() {
+ const value = useContext(ctx)
+ if (!value) {
+ throw new Error("useCommandDialog must be used within a CommandProvider")
+ }
+ return value
+}
+
+export function CommandProvider(props: ParentProps) {
+ const value = init()
+ const dialog = useDialog()
+ const keybind = useKeybind()
+
+ useKeyboard((evt) => {
+ if (keybind.match("command_list", evt)) {
+ evt.preventDefault()
+ dialog.replace(() => <DialogCommand options={value.options} />)
+ return
+ }
+ })
+
+ return <ctx.Provider value={value}>{props.children}</ctx.Provider>
+}
+
+function DialogCommand(props: { options: CommandOption[] }) {
+ const keybind = useKeybind()
+ return (
+ <DialogSelect
+ title="Commands"
+ options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
new file mode 100644
index 000000000..04f2f6523
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
@@ -0,0 +1,74 @@
+import { createMemo, createSignal } from "solid-js"
+import { useLocal } from "@tui/context/local"
+import { useSync } from "@tui/context/sync"
+import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
+import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
+import { useDialog } from "@tui/ui/dialog"
+
+export function DialogModel() {
+ const local = useLocal()
+ const sync = useSync()
+ const dialog = useDialog()
+ const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
+
+ const options = createMemo(() => {
+ return [
+ ...(!ref()?.filter
+ ? local.model.recent().flatMap((item) => {
+ const provider = sync.data.provider.find((x) => x.id === item.providerID)!
+ if (!provider) return []
+ const model = provider.models[item.modelID]
+ if (!model) return []
+ return [
+ {
+ key: item,
+ value: {
+ providerID: provider.id,
+ modelID: model.id,
+ },
+ title: model.name ?? item.modelID,
+ description: provider.name,
+ category: "Recent",
+ },
+ ]
+ })
+ : []),
+ ...pipe(
+ sync.data.provider,
+ sortBy(
+ (provider) => provider.id !== "opencode",
+ (provider) => provider.name,
+ ),
+ flatMap((provider) =>
+ pipe(
+ provider.models,
+ entries(),
+ map(([model, info]) => ({
+ value: {
+ providerID: provider.id,
+ modelID: model,
+ },
+ title: info.name ?? model,
+ description: provider.name,
+ category: provider.name,
+ })),
+ filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
+ ),
+ ),
+ ),
+ ]
+ })
+
+ return (
+ <DialogSelect
+ ref={setRef}
+ title="Select model"
+ current={local.model.current()}
+ options={options()}
+ onSelect={(option) => {
+ dialog.clear()
+ local.model.set(option.value, { recent: true })
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
new file mode 100644
index 000000000..605eb2bff
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -0,0 +1,80 @@
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { createMemo, createSignal, onMount } from "solid-js"
+import { Locale } from "@/util/locale"
+import { Keybind } from "@/util/keybind"
+import { useTheme } from "../context/theme"
+import { useSDK } from "../context/sdk"
+
+export function DialogSessionList() {
+ const dialog = useDialog()
+ const sync = useSync()
+ const { theme } = useTheme()
+ const route = useRoute()
+ const sdk = useSDK()
+
+ const [toDelete, setToDelete] = createSignal<string>()
+
+ const options = createMemo(() => {
+ const today = new Date().toDateString()
+ return sync.data.session
+ .filter((x) => x.parentID === undefined)
+ .map((x) => {
+ const date = new Date(x.time.updated)
+ let category = date.toDateString()
+ if (category === today) {
+ category = "Today"
+ }
+ const isDeleting = toDelete() === x.id
+ return {
+ title: isDeleting ? "Press delete again to confirm" : x.title,
+ bg: isDeleting ? theme.error : undefined,
+ value: x.id,
+ category,
+ footer: Locale.time(x.time.updated),
+ }
+ })
+ })
+
+ onMount(() => {
+ dialog.setSize("large")
+ })
+
+ return (
+ <DialogSelect
+ title="Sessions"
+ options={options()}
+ limit={50}
+ onMove={() => {
+ setToDelete(undefined)
+ }}
+ onSelect={(option) => {
+ route.navigate({
+ type: "session",
+ sessionID: option.value,
+ })
+ dialog.clear()
+ }}
+ keybind={[
+ {
+ keybind: Keybind.parse("delete")[0],
+ title: "delete",
+ onTrigger: async (option) => {
+ if (toDelete() === option.value) {
+ sdk.client.session.delete({
+ path: {
+ id: option.value,
+ },
+ })
+ setToDelete(undefined)
+ return
+ }
+ setToDelete(option.value)
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
new file mode 100644
index 000000000..732aa4573
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -0,0 +1,78 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useSync } from "@tui/context/sync"
+import { For, Match, Switch, Show } from "solid-js"
+
+export type DialogStatusProps = {}
+
+export function DialogStatus() {
+ const sync = useSync()
+ const { theme } = useTheme()
+
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD}>Status</text>
+ <text fg={theme.textMuted}>esc</text>
+ </box>
+ <Show when={Object.keys(sync.data.mcp).length > 0}>
+ <box>
+ <text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
+ <For each={Object.entries(sync.data.mcp)}>
+ {([key, item]) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: {
+ connected: theme.success,
+ failed: theme.error,
+ disabled: theme.textMuted,
+ }[item.status],
+ }}
+ >
+ •
+ </text>
+ <text wrapMode="word">
+ <b>{key}</b>{" "}
+ <span style={{ fg: theme.textMuted }}>
+ <Switch>
+ <Match when={item.status === "connected"}>Connected</Match>
+ <Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
+ <Match when={item.status === "disabled"}>Disabled in configuration</Match>
+ </Switch>
+ </span>
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ </Show>
+ {sync.data.lsp.length > 0 && (
+ <box>
+ <text>{sync.data.lsp.length} LSP Servers</text>
+ <For each={sync.data.lsp}>
+ {(item) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: {
+ connected: theme.success,
+ error: theme.error,
+ }[item.status],
+ }}
+ >
+ •
+ </text>
+ <text wrapMode="word">
+ <b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ )}
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx
new file mode 100644
index 000000000..78eeded24
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx
@@ -0,0 +1,46 @@
+import { createMemo, createResource } from "solid-js"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+import { createStore } from "solid-js/store"
+
+export function DialogTag(props: { onSelect?: (value: string) => void }) {
+ const sdk = useSDK()
+ const dialog = useDialog()
+
+ const [store] = createStore({
+ filter: "",
+ })
+
+ const [files] = createResource(
+ () => [store.filter],
+ async () => {
+ const result = await sdk.client.find.files({
+ query: {
+ query: store.filter,
+ },
+ })
+ if (result.error) return []
+ const sliced = (result.data ?? []).slice(0, 5)
+ return sliced
+ },
+ )
+
+ const options = createMemo(() =>
+ (files() ?? []).map((file) => ({
+ value: file,
+ title: file,
+ })),
+ )
+
+ return (
+ <DialogSelect
+ title="Autocomplete"
+ options={options()}
+ onSelect={(option) => {
+ props.onSelect?.(option.value)
+ dialog.clear()
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
new file mode 100644
index 000000000..9f7a9203d
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
@@ -0,0 +1,52 @@
+import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
+import { THEMES, useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+import { onCleanup, onMount } from "solid-js"
+
+export function DialogThemeList() {
+ const { selectedTheme, setSelectedTheme } = useTheme()
+ const options = Object.keys(THEMES).map((value) => ({
+ title: value,
+ value: value as keyof typeof THEMES,
+ }))
+ const initial = selectedTheme()
+ const dialog = useDialog()
+ let confirmed = false
+ let ref: DialogSelectRef<keyof typeof THEMES>
+
+ onMount(() => {
+ // highlight the first theme in the list when we open it for UX
+ setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
+ })
+ onCleanup(() => {
+ // if we close the dialog without confirming, reset back to the initial theme
+ if (!confirmed) setSelectedTheme(initial)
+ })
+
+ return (
+ <DialogSelect
+ title="Themes"
+ options={options}
+ onMove={(opt) => {
+ setSelectedTheme(opt.value)
+ }}
+ onSelect={(opt) => {
+ setSelectedTheme(opt.value)
+ confirmed = true
+ dialog.clear()
+ }}
+ ref={(r) => {
+ ref = r
+ }}
+ onFilter={(query) => {
+ if (query.length === 0) {
+ setSelectedTheme(initial)
+ return
+ }
+
+ const first = ref.filtered[0]
+ if (first) setSelectedTheme(first.value)
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
new file mode 100644
index 000000000..59db5fe7d
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
@@ -0,0 +1,29 @@
+import { Installation } from "@/installation"
+import { TextAttributes } from "@opentui/core"
+import { For } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+
+const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
+
+const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
+
+export function Logo() {
+ const { theme } = useTheme()
+ return (
+ <box>
+ <For each={LOGO_LEFT}>
+ {(line, index) => (
+ <box flexDirection="row" gap={1}>
+ <text fg={theme.textMuted}>{line}</text>
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
+ {LOGO_RIGHT[index()]}
+ </text>
+ </box>
+ )}
+ </For>
+ <box flexDirection="row" justifyContent="flex-end">
+ <text fg={theme.textMuted}>{Installation.VERSION}</text>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
new file mode 100644
index 000000000..0ab1cdb13
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -0,0 +1,403 @@
+import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
+import fuzzysort from "fuzzysort"
+import { firstBy } from "remeda"
+import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useSDK } from "@tui/context/sdk"
+import { useSync } from "@tui/context/sync"
+import { useTheme } from "@tui/context/theme"
+import { SplitBorder } from "@tui/component/border"
+import { useCommandDialog } from "@tui/component/dialog-command"
+import type { PromptInfo } from "./history"
+
+export type AutocompleteRef = {
+ onInput: (value: string) => void
+ onKeyDown: (e: KeyEvent) => void
+ visible: false | "@" | "/"
+}
+
+export type AutocompleteOption = {
+ display: string
+ disabled?: boolean
+ description?: string
+ onSelect?: () => void
+}
+
+export function Autocomplete(props: {
+ value: string
+ sessionID?: string
+ setPrompt: (input: (prompt: PromptInfo) => void) => void
+ setExtmark: (partIndex: number, extmarkId: number) => void
+ anchor: () => BoxRenderable
+ input: () => TextareaRenderable
+ ref: (ref: AutocompleteRef) => void
+ fileStyleId: number
+ agentStyleId: number
+ promptPartTypeId: () => number
+}) {
+ const sdk = useSDK()
+ const sync = useSync()
+ const command = useCommandDialog()
+ const { theme } = useTheme()
+
+ const [store, setStore] = createStore({
+ index: 0,
+ selected: 0,
+ visible: false as AutocompleteRef["visible"],
+ position: { x: 0, y: 0, width: 0 },
+ })
+ const filter = createMemo(() => {
+ if (!store.visible) return
+ return props.value.substring(store.index + 1).split(" ")[0]
+ })
+
+ function insertPart(text: string, part: PromptInfo["parts"][number]) {
+ const input = props.input()
+ const currentCursorOffset = input.visualCursor.offset
+
+ const charAfterCursor = props.value.at(currentCursorOffset)
+ const needsSpace = charAfterCursor !== " "
+ const append = "@" + text + (needsSpace ? " " : "")
+
+ input.cursorOffset = store.index
+ const startCursor = input.logicalCursor
+ input.cursorOffset = currentCursorOffset
+ const endCursor = input.logicalCursor
+
+ input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
+ input.insertText(append)
+
+ const virtualText = "@" + text
+ const extmarkStart = store.index
+ const extmarkEnd = extmarkStart + virtualText.length
+
+ const styleId =
+ part.type === "file"
+ ? props.fileStyleId
+ : part.type === "agent"
+ ? props.agentStyleId
+ : undefined
+
+ const extmarkId = input.extmarks.create({
+ start: extmarkStart,
+ end: extmarkEnd,
+ virtual: true,
+ styleId,
+ typeId: props.promptPartTypeId(),
+ })
+
+ props.setPrompt((draft) => {
+ if (part.type === "file" && part.source?.text) {
+ part.source.text.start = extmarkStart
+ part.source.text.end = extmarkEnd
+ part.source.text.value = virtualText
+ } else if (part.type === "agent" && part.source) {
+ part.source.start = extmarkStart
+ part.source.end = extmarkEnd
+ part.source.value = virtualText
+ }
+ const partIndex = draft.parts.length
+ draft.parts.push(part)
+ props.setExtmark(partIndex, extmarkId)
+ })
+ }
+
+ const [files] = createResource(
+ () => filter(),
+ async (query) => {
+ if (!store.visible || store.visible === "/") return []
+
+ // Get files from SDK
+ const result = await sdk.client.find.files({
+ query: {
+ query: query ?? "",
+ },
+ })
+
+ const options: AutocompleteOption[] = []
+
+ // Add file options
+ if (!result.error && result.data) {
+ options.push(
+ ...result.data.map(
+ (item): AutocompleteOption => ({
+ display: item,
+ onSelect: () => {
+ insertPart(item, {
+ type: "file",
+ mime: "text/plain",
+ filename: item,
+ url: `file://${process.cwd()}/${item}`,
+ source: {
+ type: "file",
+ text: {
+ start: 0,
+ end: 0,
+ value: "",
+ },
+ path: item,
+ },
+ })
+ },
+ }),
+ ),
+ )
+ }
+
+ return options
+ },
+ {
+ initialValue: [],
+ },
+ )
+
+ const agents = createMemo(() => {
+ if (store.index !== 0) return []
+ const agents = sync.data.agent
+ return agents
+ .filter((agent) => !agent.builtIn && agent.mode !== "primary")
+ .map(
+ (agent): AutocompleteOption => ({
+ display: "@" + agent.name,
+ onSelect: () => {
+ insertPart(agent.name, {
+ type: "agent",
+ name: agent.name,
+ source: {
+ start: 0,
+ end: 0,
+ value: "",
+ },
+ })
+ },
+ }),
+ )
+ })
+
+ const session = createMemo(() =>
+ props.sessionID ? sync.session.get(props.sessionID) : undefined,
+ )
+ const commands = createMemo((): AutocompleteOption[] => {
+ const results: AutocompleteOption[] = []
+ const s = session()
+ for (const command of sync.data.command) {
+ results.push({
+ display: "/" + command.name,
+ description: command.description,
+ onSelect: () => {
+ const newText = "/" + command.name + " "
+ const cursor = props.input().logicalCursor
+ props.input().deleteRange(0, 0, cursor.row, cursor.col)
+ props.input().insertText(newText)
+ props.input().cursorOffset = Bun.stringWidth(newText)
+ },
+ })
+ }
+ if (s) {
+ results.push(
+ {
+ display: "/undo",
+ description: "undo the last message",
+ onSelect: () => command.trigger("session.undo"),
+ },
+ {
+ display: "/redo",
+ description: "redo the last message",
+ onSelect: () => command.trigger("session.redo"),
+ },
+ {
+ display: "/compact",
+ description: "compact the session",
+ onSelect: () => command.trigger("session.compact"),
+ },
+ {
+ display: "/share",
+ disabled: !!s.share?.url,
+ description: "share a session",
+ onSelect: () => command.trigger("session.share"),
+ },
+ {
+ display: "/unshare",
+ disabled: !s.share,
+ description: "unshare a session",
+ onSelect: () => command.trigger("session.unshare"),
+ },
+ )
+ }
+ results.push(
+ {
+ display: "/new",
+ description: "create a new session",
+ onSelect: () => command.trigger("session.new"),
+ },
+ {
+ display: "/models",
+ description: "list models",
+ onSelect: () => command.trigger("model.list"),
+ },
+ {
+ display: "/agents",
+ description: "list agents",
+ onSelect: () => command.trigger("agent.list"),
+ },
+ {
+ display: "/status",
+ description: "show status",
+ onSelect: () => command.trigger("opencode.status"),
+ },
+ {
+ display: "/help",
+ description: "show help",
+ onSelect: () => command.trigger("help.show"),
+ },
+ )
+ const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
+ if (!max) return results
+ return results.map((item) => ({
+ ...item,
+ display: item.display.padEnd(max + 2),
+ }))
+ })
+
+ const options = createMemo(() => {
+ const mixed: AutocompleteOption[] = (
+ store.visible === "@"
+ ? [...agents(), ...(files.loading ? files.latest || [] : files())]
+ : [...commands()]
+ ).filter((x) => x.disabled !== true)
+ const currentFilter = filter()
+ if (!currentFilter) return mixed.slice(0, 10)
+ const result = fuzzysort.go(currentFilter, mixed, {
+ keys: ["display", "description"],
+ limit: 10,
+ })
+ return result.map((arr) => arr.obj)
+ })
+
+ createEffect(() => {
+ filter()
+ setStore("selected", 0)
+ })
+
+ function move(direction: -1 | 1) {
+ if (!store.visible) return
+ if (!options().length) return
+ let next = store.selected + direction
+ if (next < 0) next = options().length - 1
+ if (next >= options().length) next = 0
+ setStore("selected", next)
+ }
+
+ function select() {
+ const selected = options()[store.selected]
+ if (!selected) return
+ selected.onSelect?.()
+ hide()
+ }
+
+ function show(mode: "@" | "/") {
+ setStore({
+ visible: mode,
+ index: props.input().visualCursor.offset,
+ position: {
+ x: props.anchor().x,
+ y: props.anchor().y,
+ width: props.anchor().width,
+ },
+ })
+ }
+
+ function hide() {
+ const text = props.input().plainText
+ if (store.visible === "/" && !text.endsWith(" ")) {
+ const cursor = props.input().logicalCursor
+ props.input().deleteRange(0, 0, cursor.row, cursor.col)
+ }
+ setStore("visible", false)
+ }
+
+ onMount(() => {
+ props.ref({
+ get visible() {
+ return store.visible
+ },
+ onInput(value: string) {
+ if (store.visible && value.length <= store.index) hide()
+ },
+ onKeyDown(e: KeyEvent) {
+ if (store.visible) {
+ if (e.name === "up") move(-1)
+ if (e.name === "down") move(1)
+ if (e.name === "escape") hide()
+ if (e.name === "return") select()
+ if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
+ }
+ if (!store.visible) {
+ if (e.name === "@") {
+ const cursorOffset = props.input().visualCursor.offset
+ const charBeforeCursor =
+ cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
+ if (
+ charBeforeCursor === " " ||
+ charBeforeCursor === "\n" ||
+ charBeforeCursor === undefined
+ ) {
+ show("@")
+ }
+ }
+
+ if (e.name === "/") {
+ if (props.input().visualCursor.offset === 0) show("/")
+ }
+ }
+ },
+ })
+ })
+
+ const height = createMemo(() => {
+ if (options().length) return Math.min(10, options().length)
+ return 1
+ })
+
+ return (
+ <box
+ visible={store.visible !== false}
+ position="absolute"
+ top={store.position.y - height()}
+ left={store.position.x}
+ width={store.position.width}
+ zIndex={100}
+ {...SplitBorder}
+ borderColor={theme.border}
+ >
+ <box backgroundColor={theme.backgroundElement} height={height()}>
+ <For
+ each={options()}
+ fallback={
+ <box paddingLeft={1} paddingRight={1}>
+ <text>No matching items</text>
+ </box>
+ }
+ >
+ {(option, index) => (
+ <box
+ paddingLeft={1}
+ paddingRight={1}
+ backgroundColor={index() === store.selected ? theme.primary : undefined}
+ flexDirection="row"
+ >
+ <text fg={index() === store.selected ? theme.background : theme.text}>
+ {option.display}
+ </text>
+ <Show when={option.description}>
+ <text fg={index() === store.selected ? theme.background : theme.textMuted}>
+ {option.description}
+ </text>
+ </Show>
+ </box>
+ )}
+ </For>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
new file mode 100644
index 000000000..4b02d558a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
@@ -0,0 +1,78 @@
+import path from "path"
+import { Global } from "@/global"
+import { onMount } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { clone } from "remeda"
+import { createSimpleContext } from "../../context/helper"
+import { appendFile } from "fs/promises"
+import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
+
+export type PromptInfo = {
+ input: string
+ parts: (
+ | Omit<FilePart, "id" | "messageID" | "sessionID">
+ | Omit<AgentPart, "id" | "messageID" | "sessionID">
+ | (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
+ source?: {
+ text: {
+ start: number
+ end: number
+ value: string
+ }
+ }
+ })
+ )[]
+}
+
+export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
+ name: "PromptHistory",
+ init: () => {
+ const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
+ onMount(async () => {
+ const text = await historyFile.text().catch(() => "")
+ const lines = text
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line))
+ setStore("history", lines as PromptInfo[])
+ })
+
+ const [store, setStore] = createStore({
+ index: 0,
+ history: [] as PromptInfo[],
+ })
+
+ return {
+ move(direction: 1 | -1, input: string) {
+ if (!store.history.length) return undefined
+ const current = store.history.at(store.index)
+ if (!current) return undefined
+ if (current.input !== input && input.length) return
+ setStore(
+ produce((draft) => {
+ const next = store.index + direction
+ if (Math.abs(next) > store.history.length) return
+ if (next > 0) return
+ draft.index = next
+ }),
+ )
+ if (store.index === 0)
+ return {
+ input: "",
+ parts: [],
+ }
+ return store.history.at(store.index)
+ },
+ append(item: PromptInfo) {
+ item = clone(item)
+ appendFile(historyFile.name!, JSON.stringify(item) + "\n")
+ setStore(
+ produce((draft) => {
+ draft.history.push(item)
+ draft.index = 0
+ }),
+ )
+ },
+ }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
new file mode 100644
index 000000000..20015c307
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -0,0 +1,703 @@
+import {
+ TextAttributes,
+ BoxRenderable,
+ TextareaRenderable,
+ MouseEvent,
+ KeyEvent,
+ PasteEvent,
+ t,
+ dim,
+ fg,
+} from "@opentui/core"
+import { createEffect, createMemo, Match, Switch, type JSX, onMount } from "solid-js"
+import { useLocal } from "@tui/context/local"
+import { SyntaxTheme, useTheme } from "@tui/context/theme"
+import { SplitBorder } from "@tui/component/border"
+import { useSDK } from "@tui/context/sdk"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { Identifier } from "@/id/id"
+import { createStore, produce } from "solid-js/store"
+import { useKeybind } from "@tui/context/keybind"
+import { usePromptHistory, type PromptInfo } from "./history"
+import { type AutocompleteRef, Autocomplete } from "./autocomplete"
+import { useCommandDialog } from "../dialog-command"
+import { useRenderer } from "@opentui/solid"
+import { Editor } from "@tui/util/editor"
+import { useExit } from "../../context/exit"
+import { Clipboard } from "../../util/clipboard"
+import type { FilePart } from "@opencode-ai/sdk"
+import { TuiEvent } from "../../event"
+
+export type PromptProps = {
+ sessionID?: string
+ disabled?: boolean
+ onSubmit?: () => void
+ ref?: (ref: PromptRef) => void
+ hint?: JSX.Element
+ showPlaceholder?: boolean
+}
+
+export type PromptRef = {
+ focused: boolean
+ set(prompt: PromptInfo): void
+ reset(): void
+ blur(): void
+ focus(): void
+}
+
+export function Prompt(props: PromptProps) {
+ let input: TextareaRenderable
+ let anchor: BoxRenderable
+ let autocomplete: AutocompleteRef
+
+ const keybind = useKeybind()
+ const local = useLocal()
+ const sdk = useSDK()
+ const route = useRoute()
+ const sync = useSync()
+ const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
+ const history = usePromptHistory()
+ const command = useCommandDialog()
+ const renderer = useRenderer()
+ const { theme } = useTheme()
+
+ const textareaKeybindings = createMemo(() => {
+ const newlineBindings = keybind.all.input_newline || []
+ const submitBindings = keybind.all.input_submit || []
+
+ return [
+ { name: "return", action: "submit" },
+ { name: "return", meta: true, action: "newline" },
+ ...newlineBindings.map((binding) => ({
+ name: binding.name,
+ ctrl: binding.ctrl || undefined,
+ meta: binding.meta || undefined,
+ shift: binding.shift || undefined,
+ action: "newline" as const,
+ })),
+ ...submitBindings.map((binding) => ({
+ name: binding.name,
+ ctrl: binding.ctrl || undefined,
+ meta: binding.meta || undefined,
+ shift: binding.shift || undefined,
+ action: "submit" as const,
+ })),
+ ]
+ })
+
+ const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
+ const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
+ const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
+ let promptPartTypeId: number
+
+ command.register(() => {
+ return [
+ {
+ title: "Open editor",
+ category: "Session",
+ keybind: "editor_open",
+ value: "prompt.editor",
+ onSelect: async (dialog) => {
+ dialog.clear()
+ const value = input.plainText
+ input.clear()
+ setStore("prompt", {
+ input: "",
+ parts: [],
+ })
+ const content = await Editor.open({ value, renderer })
+ if (content) {
+ input.setText(content, { history: false })
+ setStore("prompt", {
+ input: content,
+ parts: [],
+ })
+ input.cursorOffset = Bun.stringWidth(content)
+ }
+ },
+ },
+ {
+ title: "Clear prompt",
+ value: "prompt.clear",
+ disabled: true,
+ category: "Prompt",
+ onSelect: (dialog) => {
+ input.extmarks.clear()
+ setStore("prompt", {
+ input: "",
+ parts: [],
+ })
+ setStore("extmarkToPartIndex", new Map())
+ dialog.clear()
+ },
+ },
+ {
+ title: "Submit prompt",
+ value: "prompt.submit",
+ disabled: true,
+ keybind: "input_submit",
+ category: "Prompt",
+ onSelect: (dialog) => {
+ submit()
+ dialog.clear()
+ },
+ },
+ {
+ title: "Paste",
+ value: "prompt.paste",
+ disabled: true,
+ keybind: "input_paste",
+ category: "Prompt",
+ onSelect: async () => {
+ const content = await Clipboard.read()
+ if (content?.mime.startsWith("image/")) {
+ await pasteImage({
+ filename: "clipboard",
+ mime: content.mime,
+ content: content.data,
+ })
+ }
+ },
+ },
+ ]
+ })
+
+ sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
+ setStore(
+ "prompt",
+ produce((draft) => {
+ draft.input += evt.properties.text
+ }),
+ )
+ })
+
+ createEffect(() => {
+ if (props.disabled) input.cursorColor = theme.backgroundElement
+ if (!props.disabled) input.cursorColor = theme.primary
+ })
+
+ const [store, setStore] = createStore<{
+ prompt: PromptInfo
+ mode: "normal" | "shell"
+ extmarkToPartIndex: Map<number, number>
+ }>({
+ prompt: {
+ input: "",
+ parts: [],
+ },
+ mode: "normal",
+ extmarkToPartIndex: new Map(),
+ })
+
+ createEffect(() => {
+ input.focus()
+ })
+
+ onMount(() => {
+ promptPartTypeId = input.extmarks.registerType("prompt-part")
+ })
+
+ function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
+ input.extmarks.clear()
+ setStore("extmarkToPartIndex", new Map())
+
+ parts.forEach((part, partIndex) => {
+ let start = 0
+ let end = 0
+ let virtualText = ""
+ let styleId: number | undefined
+
+ if (part.type === "file" && part.source?.text) {
+ start = part.source.text.start
+ end = part.source.text.end
+ virtualText = part.source.text.value
+ styleId = fileStyleId
+ } else if (part.type === "agent" && part.source) {
+ start = part.source.start
+ end = part.source.end
+ virtualText = part.source.value
+ styleId = agentStyleId
+ } else if (part.type === "text" && part.source?.text) {
+ start = part.source.text.start
+ end = part.source.text.end
+ virtualText = part.source.text.value
+ styleId = pasteStyleId
+ }
+
+ if (virtualText) {
+ const extmarkId = input.extmarks.create({
+ start,
+ end,
+ virtual: true,
+ styleId,
+ typeId: promptPartTypeId,
+ })
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
+ const newMap = new Map(map)
+ newMap.set(extmarkId, partIndex)
+ return newMap
+ })
+ }
+ })
+ }
+
+ function syncExtmarksWithPromptParts() {
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
+ setStore(
+ produce((draft) => {
+ const newMap = new Map<number, number>()
+ const newParts: typeof draft.prompt.parts = []
+
+ for (const extmark of allExtmarks) {
+ const partIndex = draft.extmarkToPartIndex.get(extmark.id)
+ if (partIndex !== undefined) {
+ const part = draft.prompt.parts[partIndex]
+ if (part) {
+ if (part.type === "agent" && part.source) {
+ part.source.start = extmark.start
+ part.source.end = extmark.end
+ } else if (part.type === "file" && part.source?.text) {
+ part.source.text.start = extmark.start
+ part.source.text.end = extmark.end
+ } else if (part.type === "text" && part.source?.text) {
+ part.source.text.start = extmark.start
+ part.source.text.end = extmark.end
+ }
+ newMap.set(extmark.id, newParts.length)
+ newParts.push(part)
+ }
+ }
+ }
+
+ draft.extmarkToPartIndex = newMap
+ draft.prompt.parts = newParts
+ }),
+ )
+ }
+
+ props.ref?.({
+ get focused() {
+ return input.focused
+ },
+ focus() {
+ input.focus()
+ },
+ blur() {
+ input.blur()
+ },
+ set(prompt) {
+ input.setText(prompt.input, { history: false })
+ setStore("prompt", prompt)
+ restoreExtmarksFromParts(prompt.parts)
+ input.gotoBufferEnd()
+ },
+ reset() {
+ input.clear()
+ input.extmarks.clear()
+ setStore("prompt", {
+ input: "",
+ parts: [],
+ })
+ setStore("extmarkToPartIndex", new Map())
+ },
+ })
+
+ async function submit() {
+ if (props.disabled) return
+ if (autocomplete.visible) return
+ if (!store.prompt.input) return
+ const sessionID = props.sessionID
+ ? props.sessionID
+ : await (async () => {
+ const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
+ return sessionID
+ })()
+ const messageID = Identifier.ascending("message")
+ let inputText = store.prompt.input
+
+ // Expand pasted text inline before submitting
+ const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
+ const sortedExtmarks = allExtmarks.sort(
+ (a: { start: number }, b: { start: number }) => b.start - a.start,
+ )
+
+ for (const extmark of sortedExtmarks) {
+ const partIndex = store.extmarkToPartIndex.get(extmark.id)
+ if (partIndex !== undefined) {
+ const part = store.prompt.parts[partIndex]
+ if (part?.type === "text" && part.text) {
+ const before = inputText.slice(0, extmark.start)
+ const after = inputText.slice(extmark.end)
+ inputText = before + part.text + after
+ }
+ }
+ }
+
+ // Filter out text parts (pasted content) since they're now expanded inline
+ const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
+
+ if (store.mode === "shell") {
+ sdk.client.session.shell({
+ path: {
+ id: sessionID,
+ },
+ body: {
+ agent: local.agent.current().name,
+ command: inputText,
+ },
+ })
+ setStore("mode", "normal")
+ } else if (inputText.startsWith("/")) {
+ const [command, ...args] = inputText.split(" ")
+ sdk.client.session.command({
+ path: {
+ id: sessionID,
+ },
+ body: {
+ command: command.slice(1),
+ arguments: args.join(" "),
+ agent: local.agent.current().name,
+ model: `${local.model.current().providerID}/${local.model.current().modelID}`,
+ messageID,
+ },
+ })
+ } else {
+ sdk.client.session.prompt({
+ path: {
+ id: sessionID,
+ },
+ body: {
+ ...local.model.current(),
+ messageID,
+ agent: local.agent.current().name,
+ model: local.model.current(),
+ parts: [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: inputText,
+ },
+ ...nonTextParts.map((x) => ({
+ id: Identifier.ascending("part"),
+ ...x,
+ })),
+ ],
+ },
+ })
+ }
+ history.append(store.prompt)
+ input.extmarks.clear()
+ setStore("prompt", {
+ input: "",
+ parts: [],
+ })
+ setStore("extmarkToPartIndex", new Map())
+ props.onSubmit?.()
+
+ // temporary hack to make sure the message is sent
+ if (!props.sessionID)
+ setTimeout(() => {
+ route.navigate({
+ type: "session",
+ sessionID,
+ })
+ }, 50)
+ input.clear()
+ }
+ const exit = useExit()
+
+ async function pasteImage(file: { filename?: string; content: string; mime: string }) {
+ const currentOffset = input.visualCursor.offset
+ const extmarkStart = currentOffset
+ const count = store.prompt.parts.filter((x) => x.type === "file").length
+ const virtualText = `[Image ${count + 1}]`
+ const extmarkEnd = extmarkStart + virtualText.length
+ const textToInsert = virtualText + " "
+
+ input.insertText(textToInsert)
+
+ const extmarkId = input.extmarks.create({
+ start: extmarkStart,
+ end: extmarkEnd,
+ virtual: true,
+ styleId: pasteStyleId,
+ typeId: promptPartTypeId,
+ })
+
+ const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
+ type: "file" as const,
+ mime: file.mime,
+ filename: file.filename,
+ url: `data:${file.mime};base64,${file.content}`,
+ source: {
+ type: "file",
+ path: file.filename ?? "",
+ text: {
+ start: extmarkStart,
+ end: extmarkEnd,
+ value: virtualText,
+ },
+ },
+ }
+ setStore(
+ produce((draft) => {
+ const partIndex = draft.prompt.parts.length
+ draft.prompt.parts.push(part)
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
+ }),
+ )
+ return
+ }
+
+ return (
+ <>
+ <Autocomplete
+ sessionID={props.sessionID}
+ ref={(r) => (autocomplete = r)}
+ anchor={() => anchor}
+ input={() => input}
+ setPrompt={(cb) => {
+ setStore("prompt", produce(cb))
+ }}
+ setExtmark={(partIndex, extmarkId) => {
+ setStore("extmarkToPartIndex", (map: Map<number, number>) => {
+ const newMap = new Map(map)
+ newMap.set(extmarkId, partIndex)
+ return newMap
+ })
+ }}
+ value={store.prompt.input}
+ fileStyleId={fileStyleId}
+ agentStyleId={agentStyleId}
+ promptPartTypeId={() => promptPartTypeId}
+ />
+ <box ref={(r) => (anchor = r)}>
+ <box
+ flexDirection="row"
+ {...SplitBorder}
+ borderColor={
+ keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
+ }
+ justifyContent="space-evenly"
+ >
+ <box
+ backgroundColor={theme.backgroundElement}
+ width={3}
+ height="100%"
+ alignItems="center"
+ paddingTop={1}
+ >
+ <text attributes={TextAttributes.BOLD} fg={theme.primary}>
+ {store.mode === "normal" ? ">" : "!"}
+ </text>
+ </box>
+ <box
+ paddingTop={1}
+ paddingBottom={1}
+ backgroundColor={theme.backgroundElement}
+ flexGrow={1}
+ >
+ <textarea
+ placeholder={
+ props.showPlaceholder
+ ? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
+ : undefined
+ }
+ textColor={theme.text}
+ focusedTextColor={theme.text}
+ minHeight={1}
+ maxHeight={6}
+ onContentChange={() => {
+ const value = input.plainText
+ setStore("prompt", "input", value)
+ autocomplete.onInput(value)
+ syncExtmarksWithPromptParts()
+ }}
+ keyBindings={textareaKeybindings()}
+ onKeyDown={async (e: KeyEvent) => {
+ if (props.disabled) {
+ e.preventDefault()
+ return
+ }
+ if (keybind.match("input_clear", e) && store.prompt.input !== "") {
+ input.clear()
+ input.extmarks.clear()
+ setStore("prompt", {
+ input: "",
+ parts: [],
+ })
+ setStore("extmarkToPartIndex", new Map())
+ return
+ }
+ if (keybind.match("app_exit", e)) {
+ await exit()
+ return
+ }
+ if (e.name === "!" && input.visualCursor.offset === 0) {
+ setStore("mode", "shell")
+ e.preventDefault()
+ return
+ }
+ if (store.mode === "shell") {
+ if (
+ (e.name === "backspace" && input.visualCursor.offset === 0) ||
+ e.name === "escape"
+ ) {
+ setStore("mode", "normal")
+ e.preventDefault()
+ return
+ }
+ }
+ if (store.mode === "normal") autocomplete.onKeyDown(e)
+ if (!autocomplete.visible) {
+ if (
+ (e.name === "up" && input.cursorOffset === 0) ||
+ (e.name === "down" && input.cursorOffset === input.plainText.length)
+ ) {
+ const direction = e.name === "up" ? -1 : 1
+ const item = history.move(direction, input.plainText)
+
+ if (item) {
+ input.setText(item.input, { history: false })
+ setStore("prompt", item)
+ restoreExtmarksFromParts(item.parts)
+ e.preventDefault()
+ if (direction === -1) input.cursorOffset = 0
+ if (direction === 1) input.cursorOffset = input.plainText.length
+ }
+ return
+ }
+
+ if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
+ if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
+ input.cursorOffset = input.plainText.length
+ }
+ if (!autocomplete.visible) {
+ if (keybind.match("session_interrupt", e) && props.sessionID) {
+ sdk.client.session.abort({
+ path: {
+ id: props.sessionID,
+ },
+ })
+ return
+ }
+ }
+ }}
+ onSubmit={submit}
+ onPaste={async (event: PasteEvent) => {
+ if (props.disabled) {
+ event.preventDefault()
+ return
+ }
+
+ const pastedContent = event.text.trim()
+ if (!pastedContent) {
+ command.trigger("prompt.paste")
+ return
+ }
+
+ // trim ' from the beginning and end of the pasted content. just
+ // ' and nothing else
+ const filepath = pastedContent.replace(/^'+|'+$/g, "")
+ try {
+ const file = Bun.file(filepath)
+ if (file.type.startsWith("image/")) {
+ const content = await file
+ .arrayBuffer()
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
+ .catch(() => {})
+ if (content) {
+ await pasteImage({
+ filename: file.name,
+ mime: file.type,
+ content,
+ })
+ return
+ }
+ }
+ } catch {}
+
+ const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
+ if (lineCount >= 5) {
+ event.preventDefault()
+ const currentOffset = input.visualCursor.offset
+ const virtualText = `[Pasted ~${lineCount} lines]`
+ const textToInsert = virtualText + " "
+ const extmarkStart = currentOffset
+ const extmarkEnd = extmarkStart + virtualText.length
+
+ input.insertText(textToInsert)
+
+ const extmarkId = input.extmarks.create({
+ start: extmarkStart,
+ end: extmarkEnd,
+ virtual: true,
+ styleId: pasteStyleId,
+ typeId: promptPartTypeId,
+ })
+
+ const part = {
+ type: "text" as const,
+ text: pastedContent,
+ source: {
+ text: {
+ start: extmarkStart,
+ end: extmarkEnd,
+ value: virtualText,
+ },
+ },
+ }
+
+ setStore(
+ produce((draft) => {
+ const partIndex = draft.prompt.parts.length
+ draft.prompt.parts.push(part)
+ draft.extmarkToPartIndex.set(extmarkId, partIndex)
+ }),
+ )
+ return
+ }
+ }}
+ ref={(r: TextareaRenderable) => (input = r)}
+ onMouseDown={(r: MouseEvent) => r.target?.focus()}
+ focusedBackgroundColor={theme.backgroundElement}
+ cursorColor={theme.primary}
+ syntaxStyle={SyntaxTheme}
+ />
+ </box>
+ <box
+ backgroundColor={theme.backgroundElement}
+ width={1}
+ justifyContent="center"
+ alignItems="center"
+ ></box>
+ </box>
+ <box flexDirection="row" justifyContent="space-between">
+ <text flexShrink={0} wrapMode="none">
+ <span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
+ <span style={{ bold: true }}>{local.model.parsed().model}</span>
+ </text>
+ <Switch>
+ <Match when={status() === "compacting"}>
+ <text fg={theme.textMuted}>compacting...</text>
+ </Match>
+ <Match when={status() === "working"}>
+ <box flexDirection="row" gap={1}>
+ <text>
+ esc <span style={{ fg: theme.textMuted }}>interrupt</span>
+ </text>
+ </box>
+ </Match>
+ <Match when={props.hint}>{props.hint!}</Match>
+ <Match when={true}>
+ <text>
+ ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
+ </text>
+ </Match>
+ </Switch>
+ </box>
+ </box>
+ </>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
new file mode 100644
index 000000000..7d7feaa28
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
@@ -0,0 +1,14 @@
+import { useRenderer } from "@opentui/solid"
+import { createSimpleContext } from "./helper"
+
+export const { use: useExit, provider: ExitProvider } = createSimpleContext({
+ name: "Exit",
+ init: (input: { onExit?: () => Promise<void> }) => {
+ const renderer = useRenderer()
+ return async () => {
+ renderer.destroy()
+ await input.onExit?.()
+ process.exit(0)
+ }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/helper.tsx b/packages/opencode/src/cli/cmd/tui/context/helper.tsx
new file mode 100644
index 000000000..6be88e775
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/helper.tsx
@@ -0,0 +1,25 @@
+import { createContext, Show, useContext, type ParentProps } from "solid-js"
+
+export function createSimpleContext<T, Props extends Record<string, any>>(input: {
+ name: string
+ init: ((input: Props) => T) | (() => T)
+}) {
+ const ctx = createContext<T>()
+
+ return {
+ provider: (props: ParentProps<Props>) => {
+ const init = input.init(props)
+ return (
+ // @ts-expect-error
+ <Show when={init.ready === undefined || init.ready === true}>
+ <ctx.Provider value={init}>{props.children}</ctx.Provider>
+ </Show>
+ )
+ },
+ use() {
+ const value = useContext(ctx)
+ if (!value) throw new Error(`${input.name} context must be used within a context provider`)
+ return value
+ },
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
new file mode 100644
index 000000000..1ed23ddbe
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx
@@ -0,0 +1,103 @@
+import { createMemo } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { Keybind } from "@/util/keybind"
+import { pipe, mapValues } from "remeda"
+import type { KeybindsConfig } from "@opencode-ai/sdk"
+import type { ParsedKey, Renderable } from "@opentui/core"
+import { createStore } from "solid-js/store"
+import { useKeyboard, useRenderer } from "@opentui/solid"
+import { createSimpleContext } from "./helper"
+
+export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
+ name: "Keybind",
+ init: () => {
+ const sync = useSync()
+ const keybinds = createMemo(() => {
+ return pipe(
+ sync.data.config.keybinds ?? {},
+ mapValues((value) => Keybind.parse(value)),
+ )
+ })
+ const [store, setStore] = createStore({
+ leader: false,
+ })
+ const renderer = useRenderer()
+
+ let focus: Renderable | null
+ let timeout: NodeJS.Timeout
+ function leader(active: boolean) {
+ if (active) {
+ setStore("leader", true)
+ focus = renderer.currentFocusedRenderable
+ focus?.blur()
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(() => {
+ if (!store.leader) return
+ leader(false)
+ if (focus) {
+ focus.focus()
+ }
+ }, 2000)
+ return
+ }
+
+ if (!active) {
+ if (focus && !renderer.currentFocusedRenderable) {
+ focus.focus()
+ }
+ setStore("leader", false)
+ }
+ }
+
+ useKeyboard(async (evt) => {
+ if (!store.leader && result.match("leader", evt)) {
+ leader(true)
+ return
+ }
+
+ if (store.leader && evt.name) {
+ setImmediate(() => {
+ if (focus && renderer.currentFocusedRenderable === focus) {
+ focus.focus()
+ }
+ leader(false)
+ })
+ }
+ })
+
+ const result = {
+ get all() {
+ return keybinds()
+ },
+ get leader() {
+ return store.leader
+ },
+ parse(evt: ParsedKey): Keybind.Info {
+ return {
+ ctrl: evt.ctrl,
+ name: evt.name,
+ shift: evt.shift,
+ leader: store.leader,
+ meta: evt.meta,
+ }
+ },
+ match(key: keyof KeybindsConfig, evt: ParsedKey) {
+ const keybind = keybinds()[key]
+ if (!keybind) return false
+ const parsed: Keybind.Info = result.parse(evt)
+ for (const key of keybind) {
+ if (Keybind.match(key, parsed)) {
+ return true
+ }
+ }
+ },
+ print(key: keyof KeybindsConfig) {
+ const first = keybinds()[key]?.at(0)
+ if (!first) return ""
+ const result = Keybind.toString(first)
+ return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
+ },
+ }
+ return result
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
new file mode 100644
index 000000000..25ec00b32
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -0,0 +1,276 @@
+import { createStore } from "solid-js/store"
+import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { useTheme } from "@tui/context/theme"
+import { uniqueBy } from "remeda"
+import path from "path"
+import { Global } from "@/global"
+import { iife } from "@/util/iife"
+import { createSimpleContext } from "./helper"
+import { useToast } from "../ui/toast"
+
+export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
+ name: "Local",
+ init: (props: { initialModel?: string; initialAgent?: string }) => {
+ const sync = useSync()
+ const toast = useToast()
+
+ function isModelValid(model: { providerID: string, modelID: string }) {
+ const provider = sync.data.provider.find((x) => x.id === model.providerID)
+ return !!provider?.models[model.modelID]
+ }
+
+ function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
+ for (const modelFn of modelFns) {
+ const model = modelFn()
+ if (!model) continue
+ if (isModelValid(model))
+ return model
+ }
+ }
+
+ // Set initial model if provided
+ onMount(() => {
+ batch(() => {
+ if (props.initialAgent) {
+ agent.set(props.initialAgent)
+ }
+ if (props.initialModel) {
+ const [providerID, modelID] = props.initialModel.split("/")
+ if (!providerID || !modelID)
+ return toast.show({
+ variant: "warning",
+ message: `Invalid model format: ${props.initialModel}`,
+ duration: 3000,
+ })
+ model.set({ providerID, modelID }, { recent: true })
+ }
+ })
+ })
+
+ // 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({
+ variant: "warning",
+ message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
+ duration: 3000,
+ })
+ }
+ })
+
+ const agent = iife(() => {
+ const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
+ const [agentStore, setAgentStore] = createStore<{
+ current: string
+ }>({
+ current: agents()[0].name,
+ })
+ const { theme } = useTheme()
+ const colors = createMemo(() => [
+ theme.secondary,
+ theme.accent,
+ theme.success,
+ theme.warning,
+ theme.primary,
+ theme.error,
+ ])
+ return {
+ list() {
+ return agents()
+ },
+ current() {
+ return agents().find((x) => x.name === agentStore.current)!
+ },
+ set(name: string) {
+ if (!agents().some((x) => x.name === name))
+ return toast.show({
+ variant: "warning",
+ message: `Agent not found: ${name}`,
+ duration: 3000,
+ })
+ setAgentStore("current", name)
+ },
+ move(direction: 1 | -1) {
+ batch(() => {
+ let next = agents().findIndex((x) => x.name === agentStore.current) + direction
+ if (next < 0) next = agents().length - 1
+ if (next >= agents().length) next = 0
+ const value = agents()[next]
+ setAgentStore("current", value.name)
+ })
+ },
+ color(name: string) {
+ const index = agents().findIndex((x) => x.name === name)
+ return colors()[index % colors().length]
+ },
+ }
+ })
+
+ const model = iife(() => {
+ const [modelStore, setModelStore] = createStore<{
+ ready: boolean
+ model: Record<
+ string,
+ {
+ providerID: string
+ modelID: string
+ }
+ >
+ recent: {
+ providerID: string
+ modelID: string
+ }[]
+ }>({
+ ready: false,
+ model: {},
+ recent: [],
+ })
+
+ const file = Bun.file(path.join(Global.Path.state, "model.json"))
+
+ file
+ .json()
+ .then((x) => {
+ setModelStore("recent", x.recent)
+ })
+ .catch(() => { })
+ .finally(() => {
+ setModelStore("ready", true)
+ })
+
+ createEffect(() => {
+ Bun.write(
+ file,
+ JSON.stringify({
+ recent: modelStore.recent,
+ }),
+ )
+ })
+
+ 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 modelStore.recent) {
+ if (isModelValid(item)) {
+ return item
+ }
+ }
+ const provider = sync.data.provider[0]
+ const model = Object.values(provider.models)[0]
+ return {
+ providerID: provider.id,
+ modelID: model.id,
+ }
+ })
+
+ const currentModel = createMemo(() => {
+ const a = agent.current()
+ return getFirstValidModel(
+ () => modelStore.model[a.name],
+ () => a.model,
+ fallbackModel,
+ )!
+ })
+
+ return {
+ current: currentModel,
+ get ready() {
+ return modelStore.ready
+ },
+ recent() {
+ return modelStore.recent
+ },
+ parsed: createMemo(() => {
+ const value = currentModel()
+ const provider = sync.data.provider.find((x) => x.id === value.providerID)!
+ const model = provider.models[value.modelID]
+ return {
+ provider: provider.name ?? value.providerID,
+ model: model.name ?? value.modelID,
+ }
+ }),
+ set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
+ batch(() => {
+ if (!isModelValid(model)) {
+ toast.show({
+ message: `Model ${model.providerID}/${model.modelID} is not valid`,
+ variant: "warning",
+ duration: 3000,
+ })
+ return
+ }
+
+ setModelStore("model", agent.current().name, model)
+ if (options?.recent) {
+ const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
+ if (uniq.length > 5) uniq.pop()
+ setModelStore("recent", uniq)
+ }
+ })
+ },
+ }
+ })
+
+ const kv = iife(() => {
+ const [ready, setReady] = createSignal(false)
+ const [kvStore, setKvStore] = createStore({
+ openrouter_warning: false,
+ })
+ const file = Bun.file(path.join(Global.Path.state, "kv.json"))
+
+ file
+ .json()
+ .then((x) => {
+ setKvStore(x)
+ })
+ .catch(() => { })
+ .finally(() => {
+ setReady(true)
+ })
+
+ return {
+ get data() {
+ return kvStore
+ },
+ get ready() {
+ return ready()
+ },
+ set(key: string, value: any) {
+ setKvStore(key as any, value)
+ Bun.write(
+ file,
+ JSON.stringify({
+ [key]: value,
+ }),
+ )
+ },
+ }
+ })
+
+ const result = {
+ model,
+ agent,
+ kv,
+ get ready() {
+ return kv.ready && model.ready
+ },
+ }
+ return result
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
new file mode 100644
index 000000000..ef230dc98
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -0,0 +1,46 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+
+export type HomeRoute = {
+ type: "home"
+}
+
+export type SessionRoute = {
+ type: "session"
+ sessionID: string
+}
+
+export type Route = HomeRoute | SessionRoute
+
+export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
+ name: "Route",
+ init: (props: { data?: Route }) => {
+ const [store, setStore] = createStore<Route>(
+ props.data ??
+ (
+ process.env["OPENCODE_ROUTE"]
+ ? JSON.parse(process.env["OPENCODE_ROUTE"])
+ : {
+ type: "home",
+ }
+ ),
+ )
+
+ return {
+ get data() {
+ return store
+ },
+ navigate(route: Route) {
+ console.log("navigate", route)
+ setStore(route)
+ },
+ }
+ },
+})
+
+export type RouteContext = ReturnType<typeof useRoute>
+
+export function useRouteData<T extends Route["type"]>(type: T) {
+ const route = useRoute()
+ return route.data as Extract<Route, { type: typeof type }>
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
new file mode 100644
index 000000000..655c68022
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -0,0 +1,37 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
+import { createSimpleContext } from "./helper"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
+
+export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
+ name: "SDK",
+ init: (props: { url: string }) => {
+ const abort = new AbortController()
+ const sdk = createOpencodeClient({
+ baseUrl: props.url,
+ signal: abort.signal,
+ fetch: (req) => {
+ // @ts-ignore
+ req.timeout = false
+ return fetch(req)
+ },
+ })
+
+ const emitter = createGlobalEmitter<{
+ [key in Event["type"]]: Extract<Event, { type: key }>
+ }>()
+
+ sdk.event.subscribe().then(async (events) => {
+ for await (const event of events.stream) {
+ console.log("event", event.type)
+ emitter.emit(event.type, event)
+ }
+ })
+
+ onCleanup(() => {
+ abort.abort()
+ })
+
+ return { client: sdk, event: emitter }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
new file mode 100644
index 000000000..765fb6196
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -0,0 +1,270 @@
+import type {
+ Message,
+ Agent,
+ Provider,
+ Session,
+ Part,
+ Config,
+ Todo,
+ Command,
+ Permission,
+ LspStatus,
+ McpStatus,
+} from "@opencode-ai/sdk"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { useSDK } from "@tui/context/sdk"
+import { Binary } from "@/util/binary"
+import { createSimpleContext } from "./helper"
+
+export const { use: useSync, provider: SyncProvider } = createSimpleContext({
+ name: "Sync",
+ init: () => {
+ const [store, setStore] = createStore<{
+ ready: boolean
+ provider: Provider[]
+ agent: Agent[]
+ command: Command[]
+ permission: {
+ [sessionID: string]: Permission[]
+ }
+ config: Config
+ session: Session[]
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+ lsp: LspStatus[]
+ mcp: {
+ [key: string]: McpStatus
+ }
+ }>({
+ config: {},
+ ready: false,
+ agent: [],
+ permission: {},
+ command: [],
+ provider: [],
+ session: [],
+ todo: {},
+ message: {},
+ part: {},
+ lsp: [],
+ mcp: {},
+ })
+
+ const sdk = useSDK()
+
+ sdk.event.listen((e) => {
+ const event = e.details
+ switch (event.type) {
+ case "permission.updated": {
+ const permissions = store.permission[event.properties.sessionID]
+ if (!permissions) {
+ setStore("permission", event.properties.sessionID, [event.properties])
+ break
+ }
+ const match = Binary.search(permissions, event.properties.id, (p) => p.id)
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ if (match.found) {
+ draft[match.index] = event.properties
+ return
+ }
+ draft.push(event.properties)
+ }),
+ )
+ break
+ }
+
+ case "permission.replied": {
+ const permissions = store.permission[event.properties.sessionID]
+ const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+ if (!match.found) break
+ setStore(
+ "permission",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(match.index, 1)
+ }),
+ )
+ break
+ }
+
+ case "todo.updated":
+ setStore("todo", event.properties.sessionID, event.properties.todos)
+ break
+
+ case "session.deleted": {
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ if (result.found) {
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
+ case "session.updated":
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ 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 "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]
+ 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 parts = store.part[event.properties.part.messageID]
+ if (!parts) {
+ setStore("part", event.properties.part.messageID, [event.properties.part])
+ break
+ }
+ const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
+ if (result.found) {
+ setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
+ break
+ }
+ setStore(
+ "part",
+ event.properties.part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties.part)
+ }),
+ )
+ break
+ }
+
+ case "message.part.removed": {
+ const parts = store.part[event.properties.messageID]
+ 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
+ }
+
+ case "lsp.updated": {
+ sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
+ break
+ }
+ }
+ })
+
+ // blocking
+ Promise.all([
+ sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
+ sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
+ sdk.client.config.get().then((x) => setStore("config", x.data!)),
+ ]).then(() => setStore("ready", true))
+
+ // non-blocking
+ Promise.all([
+ sdk.client.session.list().then((x) =>
+ setStore(
+ "session",
+ (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
+ ),
+ ),
+ sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
+ sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
+ sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
+ ])
+
+ const result = {
+ data: store,
+ set: setStore,
+ get ready() {
+ return store.ready
+ },
+ session: {
+ get(sessionID: string) {
+ const match = Binary.search(store.session, sessionID, (s) => s.id)
+ if (match.found) return store.session[match.index]
+ return undefined
+ },
+ status(sessionID: string) {
+ const session = result.session.get(sessionID)
+ if (!session) return "idle"
+ if (session.time.compacting) return "compacting"
+ const messages = store.message[sessionID] ?? []
+ const last = messages.at(-1)
+ if (!last) return "idle"
+ if (last.role === "user") return "working"
+ return last.time.completed ? "idle" : "working"
+ },
+ async sync(sessionID: string) {
+ const [session, messages, todo] = await Promise.all([
+ sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
+ sdk.client.session.messages({ path: { id: sessionID } }),
+ sdk.client.session.todo({ path: { id: 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)
+ for (const message of messages.data!) {
+ draft.part[message.info.id] = message.parts
+ }
+ }),
+ )
+ },
+ },
+ }
+ return result
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
new file mode 100644
index 000000000..894b87b09
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -0,0 +1,658 @@
+import { SyntaxStyle, RGBA } from "@opentui/core"
+import { createMemo, createSignal, createEffect } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { createSimpleContext } from "./helper"
+import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
+import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
+import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
+import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
+import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
+import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
+import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
+import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
+import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
+import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
+import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
+import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
+import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
+import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
+import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
+import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
+import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
+import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
+import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
+import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
+import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
+import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
+import { iife } from "@/util/iife"
+import { createStore, reconcile } from "solid-js/store"
+
+type Theme = {
+ primary: RGBA
+ secondary: RGBA
+ accent: RGBA
+ error: RGBA
+ warning: RGBA
+ success: RGBA
+ info: RGBA
+ text: RGBA
+ textMuted: RGBA
+ background: RGBA
+ backgroundPanel: RGBA
+ backgroundElement: RGBA
+ border: RGBA
+ borderActive: RGBA
+ borderSubtle: RGBA
+ diffAdded: RGBA
+ diffRemoved: RGBA
+ diffContext: RGBA
+ diffHunkHeader: RGBA
+ diffHighlightAdded: RGBA
+ diffHighlightRemoved: RGBA
+ diffAddedBg: RGBA
+ diffRemovedBg: RGBA
+ diffContextBg: RGBA
+ diffLineNumber: RGBA
+ diffAddedLineNumberBg: RGBA
+ diffRemovedLineNumberBg: RGBA
+ markdownText: RGBA
+ markdownHeading: RGBA
+ markdownLink: RGBA
+ markdownLinkText: RGBA
+ markdownCode: RGBA
+ markdownBlockQuote: RGBA
+ markdownEmph: RGBA
+ markdownStrong: RGBA
+ markdownHorizontalRule: RGBA
+ markdownListItem: RGBA
+ markdownListEnumeration: RGBA
+ markdownImage: RGBA
+ markdownImageText: RGBA
+ markdownCodeBlock: RGBA
+}
+
+type HexColor = `#${string}`
+type RefName = string
+type ColorModeObj = {
+ dark: HexColor | RefName
+ light: HexColor | RefName
+}
+type ColorValue = HexColor | RefName | ColorModeObj
+type ThemeJson = {
+ $schema?: string
+ defs?: Record<string, HexColor | RefName>
+ theme: Record<keyof Theme, ColorValue>
+}
+
+export const THEMES = {
+ aura: resolveTheme(aura),
+ ayu: resolveTheme(ayu),
+ catppuccin: resolveTheme(catppuccin),
+ cobalt2: resolveTheme(cobalt2),
+ dracula: resolveTheme(dracula),
+ everforest: resolveTheme(everforest),
+ github: resolveTheme(github),
+ gruvbox: resolveTheme(gruvbox),
+ kanagawa: resolveTheme(kanagawa),
+ material: resolveTheme(material),
+ matrix: resolveTheme(matrix),
+ monokai: resolveTheme(monokai),
+ nord: resolveTheme(nord),
+ ["one-dark"]: resolveTheme(onedark),
+ opencode: resolveTheme(opencode),
+ palenight: resolveTheme(palenight),
+ rosepine: resolveTheme(rosepine),
+ solarized: resolveTheme(solarized),
+ synthwave84: resolveTheme(synthwave84),
+ tokyonight: resolveTheme(tokyonight),
+ vesper: resolveTheme(vesper),
+ zenburn: resolveTheme(zenburn),
+}
+
+function resolveTheme(theme: ThemeJson) {
+ const defs = theme.defs ?? {}
+ function resolveColor(c: ColorValue): RGBA {
+ if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
+ // TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
+ return resolveColor(c.dark)
+ }
+ return Object.fromEntries(
+ Object.entries(theme.theme).map(([key, value]) => {
+ return [key, resolveColor(value)]
+ }),
+ ) as Theme
+}
+
+const syntaxThemeDark = [
+ {
+ scope: ["prompt"],
+ style: {
+ foreground: "#7dcfff",
+ },
+ },
+ {
+ scope: ["extmark.file"],
+ style: {
+ foreground: "#ff9e64",
+ bold: true,
+ },
+ },
+ {
+ scope: ["extmark.agent"],
+ style: {
+ foreground: "#bb9af7",
+ bold: true,
+ },
+ },
+ {
+ scope: ["extmark.paste"],
+ style: {
+ foreground: "#1a1b26",
+ background: "#ff9e64",
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment"],
+ style: {
+ foreground: "#565f89",
+ italic: true,
+ },
+ },
+ {
+ scope: ["comment.documentation"],
+ style: {
+ foreground: "#565f89",
+ italic: true,
+ },
+ },
+ {
+ scope: ["string", "symbol"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["number", "boolean"],
+ style: {
+ foreground: "#ff9e64",
+ },
+ },
+ {
+ scope: ["character.special"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
+ style: {
+ foreground: "#bb9af7",
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.type"],
+ style: {
+ foreground: "#2ac3de",
+ bold: true,
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.function", "function.method"],
+ style: {
+ foreground: "#bb9af7",
+ },
+ },
+ {
+ scope: ["keyword"],
+ style: {
+ foreground: "#bb9af7",
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.import"],
+ style: {
+ foreground: "#bb9af7",
+ },
+ },
+ {
+ scope: ["operator", "keyword.operator", "punctuation.delimiter"],
+ style: {
+ foreground: "#89ddff",
+ },
+ },
+ {
+ scope: ["keyword.conditional.ternary"],
+ style: {
+ foreground: "#89ddff",
+ },
+ },
+ {
+ scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
+ style: {
+ foreground: "#7dcfff",
+ },
+ },
+ {
+ scope: ["variable.member", "function", "constructor"],
+ style: {
+ foreground: "#7aa2f7",
+ },
+ },
+ {
+ scope: ["type", "module"],
+ style: {
+ foreground: "#2ac3de",
+ },
+ },
+ {
+ scope: ["constant"],
+ style: {
+ foreground: "#ff9e64",
+ },
+ },
+ {
+ scope: ["property"],
+ style: {
+ foreground: "#73daca",
+ },
+ },
+ {
+ scope: ["class"],
+ style: {
+ foreground: "#2ac3de",
+ },
+ },
+ {
+ scope: ["parameter"],
+ style: {
+ foreground: "#e0af68",
+ },
+ },
+ {
+ scope: ["punctuation", "punctuation.bracket"],
+ style: {
+ foreground: "#89ddff",
+ },
+ },
+ {
+ scope: [
+ "variable.builtin",
+ "type.builtin",
+ "function.builtin",
+ "module.builtin",
+ "constant.builtin",
+ ],
+ style: {
+ foreground: "#f7768e",
+ },
+ },
+ {
+ scope: ["variable.super"],
+ style: {
+ foreground: "#f7768e",
+ },
+ },
+ {
+ scope: ["string.escape", "string.regexp"],
+ style: {
+ foreground: "#bb9af7",
+ },
+ },
+ {
+ scope: ["keyword.directive"],
+ style: {
+ foreground: "#bb9af7",
+ italic: true,
+ },
+ },
+ {
+ scope: ["punctuation.special"],
+ style: {
+ foreground: "#89ddff",
+ },
+ },
+ {
+ scope: ["keyword.modifier"],
+ style: {
+ foreground: "#bb9af7",
+ italic: true,
+ },
+ },
+ {
+ scope: ["keyword.exception"],
+ style: {
+ foreground: "#bb9af7",
+ italic: true,
+ },
+ },
+ // Markdown specific styles
+ {
+ scope: ["markup.heading"],
+ style: {
+ foreground: "#7aa2f7",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.1"],
+ style: {
+ foreground: "#bb9af7",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.2"],
+ style: {
+ foreground: "#7aa2f7",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.3"],
+ style: {
+ foreground: "#7dcfff",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.4"],
+ style: {
+ foreground: "#73daca",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.5"],
+ style: {
+ foreground: "#9ece6a",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.heading.6"],
+ style: {
+ foreground: "#565f89",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.bold", "markup.strong"],
+ style: {
+ foreground: "#e6edf3",
+ bold: true,
+ },
+ },
+ {
+ scope: ["markup.italic"],
+ style: {
+ foreground: "#e6edf3",
+ italic: true,
+ },
+ },
+ {
+ scope: ["markup.list"],
+ style: {
+ foreground: "#ff9e64",
+ },
+ },
+ {
+ scope: ["markup.quote"],
+ style: {
+ foreground: "#565f89",
+ italic: true,
+ },
+ },
+ {
+ scope: ["markup.raw", "markup.raw.block"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["markup.raw.inline"],
+ style: {
+ foreground: "#9ece6a",
+ background: "#1a1b26",
+ },
+ },
+ {
+ scope: ["markup.link"],
+ style: {
+ foreground: "#7aa2f7",
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.link.label"],
+ style: {
+ foreground: "#7dcfff",
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.link.url"],
+ style: {
+ foreground: "#7aa2f7",
+ underline: true,
+ },
+ },
+ {
+ scope: ["label"],
+ style: {
+ foreground: "#73daca",
+ },
+ },
+ {
+ scope: ["spell", "nospell"],
+ style: {
+ foreground: "#e6edf3",
+ },
+ },
+ {
+ scope: ["conceal"],
+ style: {
+ foreground: "#565f89",
+ },
+ },
+ // Additional common highlight groups
+ {
+ scope: ["string.special", "string.special.url"],
+ style: {
+ foreground: "#73daca",
+ underline: true,
+ },
+ },
+ {
+ scope: ["character"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["float"],
+ style: {
+ foreground: "#ff9e64",
+ },
+ },
+ {
+ scope: ["comment.error"],
+ style: {
+ foreground: "#f7768e",
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment.warning"],
+ style: {
+ foreground: "#e0af68",
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["comment.todo", "comment.note"],
+ style: {
+ foreground: "#7aa2f7",
+ italic: true,
+ bold: true,
+ },
+ },
+ {
+ scope: ["namespace"],
+ style: {
+ foreground: "#2ac3de",
+ },
+ },
+ {
+ scope: ["field"],
+ style: {
+ foreground: "#73daca",
+ },
+ },
+ {
+ scope: ["type.definition"],
+ style: {
+ foreground: "#2ac3de",
+ bold: true,
+ },
+ },
+ {
+ scope: ["keyword.export"],
+ style: {
+ foreground: "#bb9af7",
+ },
+ },
+ {
+ scope: ["attribute", "annotation"],
+ style: {
+ foreground: "#e0af68",
+ },
+ },
+ {
+ scope: ["tag"],
+ style: {
+ foreground: "#f7768e",
+ },
+ },
+ {
+ scope: ["tag.attribute"],
+ style: {
+ foreground: "#bb9af7",
+ },
+ },
+ {
+ scope: ["tag.delimiter"],
+ style: {
+ foreground: "#89ddff",
+ },
+ },
+ {
+ scope: ["markup.strikethrough"],
+ style: {
+ foreground: "#565f89",
+ },
+ },
+ {
+ scope: ["markup.underline"],
+ style: {
+ foreground: "#e6edf3",
+ underline: true,
+ },
+ },
+ {
+ scope: ["markup.list.checked"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["markup.list.unchecked"],
+ style: {
+ foreground: "#565f89",
+ },
+ },
+ {
+ scope: ["diff.plus"],
+ style: {
+ foreground: "#9ece6a",
+ },
+ },
+ {
+ scope: ["diff.minus"],
+ style: {
+ foreground: "#f7768e",
+ },
+ },
+ {
+ scope: ["diff.delta"],
+ style: {
+ foreground: "#7dcfff",
+ },
+ },
+ {
+ scope: ["error"],
+ style: {
+ foreground: "#f7768e",
+ bold: true,
+ },
+ },
+ {
+ scope: ["warning"],
+ style: {
+ foreground: "#e0af68",
+ bold: true,
+ },
+ },
+ {
+ scope: ["info"],
+ style: {
+ foreground: "#7dcfff",
+ },
+ },
+ {
+ scope: ["debug"],
+ style: {
+ foreground: "#565f89",
+ },
+ },
+]
+
+export const SyntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
+
+export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
+ name: "Theme",
+ init: () => {
+ const sync = useSync()
+ const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
+ const [theme, setTheme] = createStore({} as Theme)
+ createEffect(() => {
+ if (!sync.ready) return
+ setSelectedTheme(
+ iife(() => {
+ if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
+ return sync.data.config.theme as keyof typeof THEMES
+ }
+ return "opencode"
+ }),
+ )
+ })
+
+ createEffect(() => {
+ setTheme(reconcile(THEMES[selectedTheme()]))
+ })
+
+ return {
+ theme,
+ selectedTheme,
+ setSelectedTheme,
+ get ready() {
+ return sync.ready
+ },
+ }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts
new file mode 100644
index 000000000..005d0d54a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/event.ts
@@ -0,0 +1,39 @@
+import { Bus } from "@/bus"
+import z from "zod"
+
+export const TuiEvent = {
+ PromptAppend: Bus.event("tui.prompt.append", z.object({ text: z.string() })),
+ CommandExecute: Bus.event(
+ "tui.command.execute",
+ z.object({
+ command: z.union([
+ z.enum([
+ "session.list",
+ "session.new",
+ "session.share",
+ "session.interrupt",
+ "session.compact",
+ "session.page.up",
+ "session.page.down",
+ "session.half.page.up",
+ "session.half.page.down",
+ "session.first",
+ "session.last",
+ "prompt.clear",
+ "prompt.submit",
+ "agent.cycle",
+ ]),
+ z.string(),
+ ]),
+ }),
+ ),
+ ToastShow: Bus.event(
+ "tui.toast.show",
+ z.object({
+ title: z.string().optional(),
+ message: z.string(),
+ variant: z.enum(["info", "success", "warning", "error"]),
+ duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
+ }),
+ ),
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
new file mode 100644
index 000000000..f24915113
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -0,0 +1,83 @@
+import { Prompt, type PromptRef } from "@tui/component/prompt"
+import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+import { useKeybind } from "../context/keybind"
+import type { KeybindsConfig } from "@opencode-ai/sdk"
+import { Logo } from "../component/logo"
+import { Locale } from "@/util/locale"
+import { useSync } from "../context/sync"
+import { Toast } from "../ui/toast"
+import { useDialog } from "../ui/dialog"
+
+export function Home() {
+ const sync = useSync()
+ const { theme } = useTheme()
+ const dialog = useDialog()
+ const mcpError = createMemo(() => {
+ return Object.values(sync.data.mcp).some((x) => x.status === "failed")
+ })
+ let promptRef: PromptRef | undefined = undefined
+
+ createEffect(() => {
+ dialog.allClosedEvent.listen(() => {
+ promptRef?.focus()
+ })
+ })
+
+ const Hint = (
+ <Show when={Object.keys(sync.data.mcp).length > 0}>
+ <box flexShrink={0} flexDirection="row" gap={1}>
+ <text>
+ <Switch>
+ <Match when={mcpError()}>
+ <span style={{ fg: theme.error }}>•</span> mcp errors{" "}
+ <span style={{ fg: theme.textMuted }}>ctrl+x s</span>
+ </Match>
+ <Match when={true}>
+ <span style={{ fg: theme.success }}>•</span>{" "}
+ {Locale.pluralize(
+ Object.values(sync.data.mcp).length,
+ "{} mcp server",
+ "{} mcp servers",
+ )}
+ </Match>
+ </Switch>
+ </text>
+ </box>
+ </Show>
+ )
+
+ return (
+ <box
+ flexGrow={1}
+ justifyContent="center"
+ alignItems="center"
+ paddingLeft={2}
+ paddingRight={2}
+ gap={1}
+ >
+ <Logo />
+ <box width={39}>
+ <HelpRow keybind="command_list">Commands</HelpRow>
+ <HelpRow keybind="session_list">List sessions</HelpRow>
+ <HelpRow keybind="model_list">Switch model</HelpRow>
+ <HelpRow keybind="agent_cycle">Switch agent</HelpRow>
+ </box>
+ <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
+ <Prompt hint={Hint} ref={(r) => (promptRef = r)} />
+ </box>
+ <Toast />
+ </box>
+ )
+}
+
+function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
+ const keybind = useKeybind()
+ const { theme } = useTheme()
+ return (
+ <box flexDirection="row" justifyContent="space-between" width="100%">
+ <text>{props.children}</text>
+ <text fg={theme.primary}>{keybind.print(props.keybind)}</text>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
new file mode 100644
index 000000000..ee2b77afc
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
@@ -0,0 +1,56 @@
+import { createMemo } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useSDK } from "@tui/context/sdk"
+import { useRoute } from "@tui/context/route"
+
+export function DialogMessage(props: { messageID: string; sessionID: string }) {
+ const sync = useSync()
+ const sdk = useSDK()
+ const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
+ const route = useRoute()
+
+ return (
+ <DialogSelect
+ title="Message Actions"
+ options={[
+ {
+ title: "Revert",
+ value: "session.revert",
+ description: "undo messages and file changes",
+ onSelect: (dialog) => {
+ sdk.client.session.revert({
+ path: {
+ id: props.sessionID,
+ },
+ body: {
+ messageID: message()!.id,
+ },
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Fork",
+ value: "session.fork",
+ description: "create a new session",
+ onSelect: async (dialog) => {
+ const result = await sdk.client.session.fork({
+ path: {
+ id: props.sessionID,
+ },
+ body: {
+ messageID: props.messageID,
+ },
+ })
+ route.navigate({
+ sessionID: result.data!.id,
+ type: "session",
+ })
+ dialog.clear()
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
new file mode 100644
index 000000000..f5976cdf0
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -0,0 +1,37 @@
+import { createMemo, onMount } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import type { TextPart } from "@opencode-ai/sdk"
+import { Locale } from "@/util/locale"
+import { DialogMessage } from "./dialog-message"
+import { useDialog } from "../../ui/dialog"
+
+export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
+ const sync = useSync()
+ const dialog = useDialog()
+
+ onMount(() => {
+ dialog.setSize("large")
+ })
+
+ const options = createMemo((): DialogSelectOption<string>[] => {
+ const messages = sync.data.message[props.sessionID] ?? []
+ const result = [] as DialogSelectOption<string>[]
+ for (const message of messages) {
+ if (message.role !== "user") continue
+ const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
+ if (!part) continue
+ result.push({
+ title: part.text.replace(/\n/g, " "),
+ value: message.id,
+ footer: Locale.time(message.time.created),
+ onSelect: (dialog) => {
+ dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
+ },
+ })
+ }
+ return result
+ })
+
+ return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
new file mode 100644
index 000000000..31d25baa3
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
@@ -0,0 +1,81 @@
+import { createMemo, Match, Show, Switch } from "solid-js"
+import { useRouteData } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { pipe, sumBy } from "remeda"
+import { useTheme } from "@tui/context/theme"
+import { SplitBorder } from "@tui/component/border"
+import type { AssistantMessage } from "@opencode-ai/sdk"
+
+export function Header() {
+ const route = useRouteData("session")
+ const sync = useSync()
+ const { theme } = useTheme()
+ const session = createMemo(() => sync.session.get(route.sessionID)!)
+ const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
+
+ const cost = createMemo(() => {
+ const total = pipe(
+ messages(),
+ sumBy((x) => (x.role === "assistant" ? x.cost : 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.find((x) => x.id === last.providerID)?.models[last.modelID]
+ let result = total.toLocaleString()
+ if (model?.limit.context) {
+ result += "/" + Math.round((total / model.limit.context) * 100) + "%"
+ }
+ return result
+ })
+
+ return (
+ <box
+ paddingLeft={1}
+ paddingRight={1}
+ {...SplitBorder}
+ borderColor={theme.backgroundElement}
+ flexShrink={0}
+ >
+ <text>
+ <span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
+ <span style={{ bold: true }}>{session().title}</span>
+ </text>
+ <box flexDirection="row" justifyContent="space-between" gap={1}>
+ <box flexGrow={1} flexShrink={1}>
+ <Switch>
+ <Match when={session().share?.url}>
+ <text fg={theme.textMuted} wrapMode="word">
+ {session().share!.url}
+ </text>
+ </Match>
+ <Match when={true}>
+ <text wrapMode="word">
+ /share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
+ </text>
+ </Match>
+ </Switch>
+ </box>
+ <Show when={context()}>
+ <text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
+ {context()} ({cost()})
+ </text>
+ </Show>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
new file mode 100644
index 000000000..baa63d370
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -0,0 +1,1270 @@
+import {
+ createContext,
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ Show,
+ Switch,
+ useContext,
+ type Component,
+} from "solid-js"
+import { Dynamic } from "solid-js/web"
+import path from "path"
+import { useRouteData } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { SplitBorder } from "@tui/component/border"
+import { SyntaxTheme, useTheme } from "@tui/context/theme"
+import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers } from "@opentui/core"
+import { Prompt, type PromptRef } from "@tui/component/prompt"
+import type {
+ AssistantMessage,
+ Part,
+ ToolPart,
+ UserMessage,
+ TextPart,
+ ReasoningPart,
+} from "@opencode-ai/sdk"
+import { useLocal } from "@tui/context/local"
+import { Locale } from "@/util/locale"
+import type { Tool } from "@/tool/tool"
+import type { ReadTool } from "@/tool/read"
+import type { WriteTool } from "@/tool/write"
+import { BashTool } from "@/tool/bash"
+import type { GlobTool } from "@/tool/glob"
+import { TodoWriteTool } from "@/tool/todo"
+import type { GrepTool } from "@/tool/grep"
+import type { ListTool } from "@/tool/ls"
+import type { EditTool } from "@/tool/edit"
+import type { PatchTool } from "@/tool/patch"
+import type { WebFetchTool } from "@/tool/webfetch"
+import type { TaskTool } from "@/tool/task"
+import {
+ useKeyboard,
+ useRenderer,
+ useTerminalDimensions,
+ type BoxProps,
+ type JSX,
+} from "@opentui/solid"
+import { useSDK } from "@tui/context/sdk"
+import { useCommandDialog } from "@tui/component/dialog-command"
+import { Shimmer } from "@tui/ui/shimmer"
+import { useKeybind } from "@tui/context/keybind"
+import { Header } from "./header"
+import { parsePatch } from "diff"
+import { useDialog } from "../../ui/dialog"
+import { DialogMessage } from "./dialog-message"
+import type { PromptInfo } from "../../component/prompt/history"
+import { iife } from "@/util/iife"
+import { DialogConfirm } from "@tui/ui/dialog-confirm"
+import { DialogTimeline } from "./dialog-timeline"
+import { Sidebar } from "./sidebar"
+import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import parsers from "../../../../../../parsers-config.ts"
+import { Toast } from "../../ui/toast"
+
+addDefaultParsers(parsers.parsers)
+
+const context = createContext<{
+ width: number
+ conceal: () => boolean
+}>()
+
+function use() {
+ const ctx = useContext(context)
+ if (!ctx) throw new Error("useContext must be used within a Session component")
+ return ctx
+}
+
+export function Session() {
+ const route = useRouteData("session")
+ const sync = useSync()
+ const { theme } = useTheme()
+ const session = createMemo(() => sync.session.get(route.sessionID)!)
+ const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
+ const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
+
+ const pending = createMemo(() => {
+ return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id
+ })
+
+ const dimensions = useTerminalDimensions()
+ const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">("auto")
+ const [conceal, setConceal] = createSignal(true)
+
+ const wide = createMemo(() => dimensions().width > 120)
+ const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
+ const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
+
+ createEffect(() => sync.session.sync(route.sessionID))
+
+ const sdk = useSDK()
+
+ let scroll: ScrollBoxRenderable
+ let prompt: PromptRef
+ const keybind = useKeybind()
+
+ createEffect(() => {
+ dialog.allClosedEvent.listen(() => {
+ prompt.focus()
+ })
+ })
+
+ useKeyboard((evt) => {
+ if (dialog.stack.length > 0) return
+
+ const first = permissions()[0]
+ if (first) {
+ const response = iife(() => {
+ if (evt.name === "return") return "once"
+ if (evt.name === "a") return "always"
+ if (evt.name === "d") return "reject"
+ return
+ })
+ if (response) {
+ sdk.client.postSessionIdPermissionsPermissionId({
+ path: {
+ permissionID: first.id,
+ id: route.sessionID,
+ },
+ body: {
+ response: response,
+ },
+ })
+ }
+ }
+ })
+
+ function toBottom() {
+ setTimeout(() => {
+ scroll.scrollTo(scroll.scrollHeight)
+ }, 50)
+ }
+
+ // snap to bottom when revert position changes
+ createEffect((old) => {
+ if (old !== session()?.revert?.messageID) toBottom()
+ return session()?.revert?.messageID
+ })
+
+ const local = useLocal()
+
+ const command = useCommandDialog()
+ command.register(() => [
+ {
+ title: "Jump to message",
+ value: "session.timeline",
+ keybind: "session_timeline",
+ category: "Session",
+ onSelect: (dialog) => {
+ dialog.replace(() => (
+ <DialogTimeline
+ onMove={(messageID) => {
+ const child = scroll.getChildren().find((child) => {
+ return child.id === messageID
+ })
+ if (child) scroll.scrollBy(child.y - scroll.y - 1)
+ }}
+ sessionID={route.sessionID}
+ />
+ ))
+ },
+ },
+ {
+ title: "Compact session",
+ value: "session.compact",
+ keybind: "session_compact",
+ category: "Session",
+ onSelect: (dialog) => {
+ sdk.client.session.summarize({
+ path: {
+ id: route.sessionID,
+ },
+ body: {
+ modelID: local.model.current().modelID,
+ providerID: local.model.current().providerID,
+ },
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Share session",
+ value: "session.share",
+ keybind: "session_share",
+ disabled: !!session()?.share?.url,
+ category: "Session",
+ onSelect: (dialog) => {
+ sdk.client.session.share({
+ path: {
+ id: route.sessionID,
+ },
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Unshare session",
+ value: "session.unshare",
+ keybind: "session_unshare",
+ disabled: !session()?.share?.url,
+ category: "Session",
+ onSelect: (dialog) => {
+ sdk.client.session.unshare({
+ path: {
+ id: route.sessionID,
+ },
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Undo previous message",
+ value: "session.undo",
+ keybind: "messages_undo",
+ category: "Session",
+ onSelect: (dialog) => {
+ const revert = session().revert?.messageID
+ const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
+ if (!message) return
+ sdk.client.session.revert({
+ path: {
+ id: route.sessionID,
+ },
+ body: {
+ messageID: message.id,
+ },
+ })
+ const parts = sync.data.part[message.id]
+ prompt.set(
+ parts.reduce(
+ (agg, part) => {
+ if (part.type === "text") agg.input += part.text
+ if (part.type === "file") agg.parts.push(part)
+ return agg
+ },
+ { input: "", parts: [] as PromptInfo["parts"] },
+ ),
+ )
+ dialog.clear()
+ },
+ },
+ {
+ title: "Redo",
+ value: "session.redo",
+ keybind: "messages_redo",
+ disabled: !session()?.revert?.messageID,
+ category: "Session",
+ onSelect: (dialog) => {
+ dialog.clear()
+ const messageID = session().revert?.messageID
+ if (!messageID) return
+ const message = messages().find((x) => x.role === "user" && x.id > messageID)
+ if (!message) {
+ sdk.client.session.unrevert({
+ path: {
+ id: route.sessionID,
+ },
+ })
+ prompt.set({ input: "", parts: [] })
+ return
+ }
+ sdk.client.session.revert({
+ path: {
+ id: route.sessionID,
+ },
+ body: {
+ messageID: message.id,
+ },
+ })
+ },
+ },
+ {
+ title: "Toggle sidebar",
+ value: "session.sidebar.toggle",
+ keybind: "sidebar_toggle",
+ category: "Session",
+ onSelect: (dialog) => {
+ setSidebar((prev) => {
+ if (prev === "auto") return sidebarVisible() ? "hide" : "show"
+ if (prev === "show") return "hide"
+ return "show"
+ })
+ dialog.clear()
+ },
+ },
+ {
+ title: "Toggle code concealment",
+ value: "session.toggle.conceal",
+ keybind: "messages_toggle_conceal" as any,
+ category: "Session",
+ onSelect: (dialog) => {
+ setConceal((prev) => !prev)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Page up",
+ value: "session.page.up",
+ keybind: "messages_page_up",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollBy(-scroll.height / 2)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Page down",
+ value: "session.page.down",
+ keybind: "messages_page_down",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollBy(scroll.height / 2)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Half page up",
+ value: "session.half.page.up",
+ keybind: "messages_half_page_up",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollBy(-scroll.height / 4)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Half page down",
+ value: "session.half.page.down",
+ keybind: "messages_half_page_down",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollBy(scroll.height / 4)
+ dialog.clear()
+ },
+ },
+ {
+ title: "First message",
+ value: "session.first",
+ keybind: "messages_first",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollTo(0)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Last message",
+ value: "session.last",
+ keybind: "messages_last",
+ category: "Session",
+ disabled: true,
+ onSelect: (dialog) => {
+ scroll.scrollTo(scroll.scrollHeight)
+ dialog.clear()
+ },
+ },
+ ])
+
+ const revert = createMemo(() => {
+ const s = session()
+ if (!s) return
+ const messageID = s.revert?.messageID
+ if (!messageID) return
+ const reverted = messages().filter((x) => x.id >= messageID && x.role === "user")
+
+ const diffFiles = (() => {
+ const diffText = s.revert?.diff || ""
+ if (!diffText) return []
+
+ const patches = parsePatch(diffText)
+ return patches.map((patch) => {
+ const filename = patch.newFileName || patch.oldFileName || "unknown"
+ const cleanFilename = filename.replace(/^[ab]\//, "")
+ return {
+ filename: cleanFilename,
+ additions: patch.hunks.reduce(
+ (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
+ 0,
+ ),
+ deletions: patch.hunks.reduce(
+ (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
+ 0,
+ ),
+ }
+ })
+ })()
+
+ return {
+ messageID,
+ reverted,
+ diff: s.revert!.diff,
+ diffFiles,
+ }
+ })
+
+ const dialog = useDialog()
+ const renderer = useRenderer()
+
+ return (
+ <context.Provider
+ value={{
+ get width() {
+ return contentWidth()
+ },
+ conceal,
+ }}
+ >
+ <box
+ flexDirection="row"
+ paddingBottom={1}
+ paddingTop={1}
+ paddingLeft={2}
+ paddingRight={2}
+ gap={2}
+ >
+ <box flexGrow={1} gap={1}>
+ <Show when={session()}>
+ <Show when={!sidebarVisible()}>
+ <Header />
+ </Show>
+ <scrollbox
+ ref={(r) => (scroll = r)}
+ scrollbarOptions={{ visible: false }}
+ stickyScroll={true}
+ stickyStart="bottom"
+ flexGrow={1}
+ >
+ <For each={messages()}>
+ {(message, index) => (
+ <Switch>
+ <Match when={message.id === revert()?.messageID}>
+ {(function () {
+ const command = useCommandDialog()
+ const [hover, setHover] = createSignal(false)
+ const dialog = useDialog()
+
+ const handleUnrevert = async () => {
+ const confirmed = await DialogConfirm.show(
+ dialog,
+ "Confirm Redo",
+ "Are you sure you want to restore the reverted messages?",
+ )
+ if (confirmed) {
+ command.trigger("session.redo")
+ }
+ }
+
+ return (
+ <box
+ onMouseOver={() => setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={handleUnrevert}
+ marginTop={1}
+ flexShrink={0}
+ border={["left"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.backgroundPanel}
+ >
+ <box
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ backgroundColor={
+ hover() ? theme.backgroundElement : theme.backgroundPanel
+ }
+ >
+ <text fg={theme.textMuted}>
+ {revert()!.reverted.length} message reverted
+ </text>
+ <text fg={theme.textMuted}>
+ <span style={{ fg: theme.text }}>
+ {keybind.print("messages_redo")}
+ </span>{" "}
+ or /redo to restore
+ </text>
+ <Show when={revert()!.diffFiles?.length}>
+ <box marginTop={1}>
+ <For each={revert()!.diffFiles}>
+ {(file) => (
+ <text>
+ {file.filename}
+ <Show when={file.additions > 0}>
+ <span style={{ fg: theme.diffAdded }}>
+ {" "}
+ +{file.additions}
+ </span>
+ </Show>
+ <Show when={file.deletions > 0}>
+ <span style={{ fg: theme.diffRemoved }}>
+ {" "}
+ -{file.deletions}
+ </span>
+ </Show>
+ </text>
+ )}
+ </For>
+ </box>
+ </Show>
+ </box>
+ </box>
+ )
+ })()}
+ </Match>
+ <Match when={revert()?.messageID && message.id >= revert()!.messageID}>
+ <></>
+ </Match>
+ <Match when={message.role === "user"}>
+ <UserMessage
+ index={index()}
+ onMouseUp={() => {
+ if (renderer.getSelection()?.getSelectedText()) return
+ dialog.replace(() => (
+ <DialogMessage messageID={message.id} sessionID={route.sessionID} />
+ ))
+ }}
+ message={message as UserMessage}
+ parts={sync.data.part[message.id] ?? []}
+ pending={pending()}
+ />
+ </Match>
+ <Match when={message.role === "assistant"}>
+ <AssistantMessage
+ last={index() === messages().length - 1}
+ message={message as AssistantMessage}
+ parts={sync.data.part[message.id] ?? []}
+ />
+ </Match>
+ </Switch>
+ )}
+ </For>
+ </scrollbox>
+ <box flexShrink={0}>
+ <Prompt
+ ref={(r) => (prompt = r)}
+ disabled={permissions().length > 0}
+ onSubmit={() => {
+ toBottom()
+ }}
+ sessionID={route.sessionID}
+ />
+ </box>
+ </Show>
+ <Toast />
+ </box>
+ <Show when={sidebarVisible()}>
+ <Sidebar sessionID={route.sessionID} />
+ </Show>
+ </box>
+ </context.Provider>
+ )
+}
+
+const MIME_BADGE: Record<string, string> = {
+ "text/plain": "txt",
+ "image/png": "img",
+ "image/jpeg": "img",
+ "image/gif": "img",
+ "image/webp": "img",
+ "application/pdf": "pdf",
+ "application/x-directory": "dir",
+}
+
+function UserMessage(props: {
+ message: UserMessage
+ parts: Part[]
+ onMouseUp: () => void
+ index: number
+ pending?: string
+}) {
+ const text = createMemo(
+ () => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0],
+ )
+ const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
+ const sync = useSync()
+ const { theme } = useTheme()
+ const [hover, setHover] = createSignal(false)
+ const queued = createMemo(() => props.pending && props.message.id > props.pending)
+ const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
+
+ return (
+ <Show when={text()}>
+ <box
+ id={props.message.id}
+ onMouseOver={() => {
+ setHover(true)
+ }}
+ onMouseOut={() => {
+ setHover(false)
+ }}
+ onMouseUp={props.onMouseUp}
+ border={["left"]}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ marginTop={props.index === 0 ? 0 : 1}
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={color()}
+ flexShrink={0}
+ >
+ <text>{text()?.text}</text>
+ <Show when={files().length}>
+ <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
+ <For each={files()}>
+ {(file) => {
+ const bg = createMemo(() => {
+ if (file.mime.startsWith("image/")) return theme.accent
+ if (file.mime === "application/pdf") return theme.primary
+ return theme.secondary
+ })
+ return (
+ <text>
+ <span style={{ bg: bg(), fg: theme.background }}>
+ {" "}
+ {MIME_BADGE[file.mime] ?? file.mime}{" "}
+ </span>
+ <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
+ {" "}
+ {file.filename}{" "}
+ </span>
+ </text>
+ )
+ }}
+ </For>
+ </box>
+ </Show>
+ <text>
+ {sync.data.config.username ?? "You"}{" "}
+ <Show
+ when={queued()}
+ fallback={
+ <span style={{ fg: theme.textMuted }}>
+ ({Locale.time(props.message.time.created)})
+ </span>
+ }
+ >
+ <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}>
+ {" "}
+ QUEUED{" "}
+ </span>
+ </Show>
+ </text>
+ </box>
+ </Show>
+ )
+}
+
+function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
+ const local = useLocal()
+ const { theme } = useTheme()
+ return (
+ <>
+ <For each={props.parts}>
+ {(part) => {
+ const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
+ return (
+ <Show when={component()}>
+ <Dynamic component={component()} part={part as any} message={props.message} />
+ </Show>
+ )
+ }}
+ </For>
+ <Show when={props.message.error}>
+ <box
+ border={["left"]}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ marginTop={1}
+ backgroundColor={theme.backgroundPanel}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.error}
+ >
+ <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
+ </box>
+ </Show>
+ <Show
+ when={
+ !props.message.time.completed ||
+ (props.last &&
+ props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls"))
+ }
+ >
+ <box
+ paddingLeft={2}
+ marginTop={1}
+ flexDirection="row"
+ gap={1}
+ border={["left"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.backgroundElement}
+ >
+ <text fg={local.agent.color(props.message.mode)}>
+ {Locale.titlecase(props.message.mode)}
+ </text>
+ <Shimmer text={`${props.message.modelID}`} color={theme.text} />
+ </box>
+ </Show>
+ <Show
+ when={
+ props.message.time.completed &&
+ props.parts.some((item) => item.type === "step-finish" && item.reason !== "tool-calls")
+ }
+ >
+ <box paddingLeft={3}>
+ <text marginTop={1}>
+ <span style={{ fg: local.agent.color(props.message.mode) }}>
+ {Locale.titlecase(props.message.mode)}
+ </span>{" "}
+ <span style={{ fg: theme.textMuted }}>{props.message.modelID}</span>
+ </text>
+ </box>
+ </Show>
+ </>
+ )
+}
+
+const PART_MAPPING = {
+ text: TextPart,
+ tool: ToolPart,
+ reasoning: ReasoningPart,
+}
+
+function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
+ const { theme } = useTheme()
+ return (
+ <Show when={props.part.text.trim()}>
+ <box
+ id={"text-" + props.part.id}
+ marginTop={1}
+ flexShrink={0}
+ border={["left"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.backgroundPanel}
+ >
+ <box
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ backgroundColor={theme.backgroundPanel}
+ >
+ <text>{props.part.text.trim()}</text>
+ </box>
+ </box>
+ </Show>
+ )
+}
+
+function TextPart(props: { part: TextPart; message: AssistantMessage }) {
+ const ctx = use()
+ return (
+ <Show when={props.part.text.trim()}>
+ <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
+ <code
+ filetype="markdown"
+ drawUnstyledText={false}
+ syntaxStyle={SyntaxTheme}
+ content={props.part.text.trim()}
+ conceal={ctx.conceal()}
+ />
+ </box>
+ </Show>
+ )
+}
+
+// Pending messages moved to individual tool pending functions
+
+function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
+ const { theme } = useTheme()
+ const sync = useSync()
+ const [margin, setMargin] = createSignal(0)
+ const component = createMemo(() => {
+ const render = ToolRegistry.render(props.part.tool) ?? GenericTool
+
+ const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
+ const input = props.part.state.input
+ const container = ToolRegistry.container(props.part.tool)
+ const permissions = sync.data.permission[props.message.sessionID] ?? []
+ const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
+ const permission = permissions[permissionIndex]
+
+ const style: BoxProps =
+ container === "block" || permission
+ ? {
+ border: permissionIndex === 0 ? (["left", "right"] as const) : (["left"] as const),
+ paddingTop: 1,
+ paddingBottom: 1,
+ paddingLeft: 2,
+ marginTop: 1,
+ gap: 1,
+ backgroundColor: theme.backgroundPanel,
+ customBorderChars: SplitBorder.customBorderChars,
+ borderColor: permissionIndex === 0 ? theme.warning : theme.background,
+ }
+ : {
+ paddingLeft: 3,
+ }
+
+ return (
+ <box
+ marginTop={margin()}
+ {...style}
+ renderBefore={function () {
+ const el = this as BoxRenderable
+ const parent = el.parent
+ if (!parent) {
+ return
+ }
+ if (el.height > 1) {
+ setMargin(1)
+ return
+ }
+ const children = parent.getChildren()
+ const index = children.indexOf(el)
+ const previous = children[index - 1]
+ if (!previous) {
+ setMargin(0)
+ return
+ }
+ if (previous.height > 1 || previous.id.startsWith("text-")) {
+ setMargin(1)
+ return
+ }
+ }}
+ >
+ <Dynamic
+ component={render}
+ input={input}
+ tool={props.part.tool}
+ metadata={metadata}
+ permission={permission?.metadata ?? {}}
+ output={props.part.state.status === "completed" ? props.part.state.output : undefined}
+ />
+ {props.part.state.status === "error" && (
+ <box paddingLeft={2}>
+ <text fg={theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
+ </box>
+ )}
+ {permission && (
+ <box gap={1}>
+ <text fg={theme.text}>Permission required to run this tool:</text>
+ <box flexDirection="row" gap={2}>
+ <text>
+ <b>enter</b>
+ <span style={{ fg: theme.textMuted }}> accept</span>
+ </text>
+ <text>
+ <b>a</b>
+ <span style={{ fg: theme.textMuted }}> accept always</span>
+ </text>
+ <text>
+ <b>d</b>
+ <span style={{ fg: theme.textMuted }}> deny</span>
+ </text>
+ </box>
+ </box>
+ )}
+ </box>
+ )
+ })
+
+ return <Show when={component()}>{component()}</Show>
+}
+
+type ToolProps<T extends Tool.Info> = {
+ input: Partial<Tool.InferParameters<T>>
+ metadata: Partial<Tool.InferMetadata<T>>
+ permission: Record<string, any>
+ tool: string
+ output?: string
+}
+function GenericTool(props: ToolProps<any>) {
+ return (
+ <ToolTitle icon="⚙" fallback="Writing command..." when={true}>
+ {props.tool} {input(props.input)}
+ </ToolTitle>
+ )
+}
+
+const ToolRegistry = (() => {
+ const state: Record<
+ string,
+ { name: string; container: "inline" | "block"; render?: Component<ToolProps<any>> }
+ > = {}
+ function register<T extends Tool.Info>(input: {
+ name: string
+ container: "inline" | "block"
+ render?: Component<ToolProps<T>>
+ }) {
+ state[input.name] = input
+ return input
+ }
+ return {
+ register,
+ container(name: string) {
+ return state[name]?.container
+ },
+ render(name: string) {
+ return state[name]?.render
+ },
+ }
+})()
+
+function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
+ const { theme } = useTheme()
+ return (
+ <text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
+ <Show fallback={<>~ {props.fallback}</>} when={props.when}>
+ <span style={{ bold: true }}>{props.icon}</span> {props.children}
+ </Show>
+ </text>
+ )
+}
+
+ToolRegistry.register<typeof BashTool>({
+ name: "bash",
+ container: "block",
+ render(props) {
+ const output = createMemo(() => Bun.stripANSI(props.metadata.output?.trim() ?? ""))
+ const { theme } = useTheme()
+ return (
+ <>
+ <ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
+ {props.input.description || "Shell"}
+ </ToolTitle>
+ <Show when={props.input.command}>
+ <text fg={theme.text}>$ {props.input.command}</text>
+ </Show>
+ <Show when={output()}>
+ <box>
+ <text fg={theme.text}>{output()}</text>
+ </box>
+ </Show>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof ReadTool>({
+ name: "read",
+ container: "inline",
+ render(props) {
+ return (
+ <>
+ <ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
+ Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
+ </ToolTitle>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof WriteTool>({
+ name: "write",
+ container: "block",
+ render(props) {
+ const { theme } = useTheme()
+ const lines = createMemo(() => {
+ return props.input.content?.split("\n") ?? []
+ })
+ const code = createMemo(() => {
+ if (!props.input.content) return ""
+ const text = props.input.content
+ return text
+ })
+
+ const numbers = createMemo(() => {
+ const pad = lines().length.toString().length
+ return lines()
+ .map((_, index) => index + 1)
+ .map((x) => x.toString().padStart(pad, " "))
+ })
+
+ return (
+ <>
+ <ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
+ Wrote {props.input.filePath}
+ </ToolTitle>
+ <box flexDirection="row">
+ <box flexShrink={0}>
+ <For each={numbers()}>
+ {(value) => <text style={{ fg: theme.textMuted }}>{value}</text>}
+ </For>
+ </box>
+ <box paddingLeft={1} flexGrow={1}>
+ <code
+ filetype={filetype(props.input.filePath!)}
+ syntaxStyle={SyntaxTheme}
+ content={code()}
+ />
+ </box>
+ </box>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof GlobTool>({
+ name: "glob",
+ container: "inline",
+ render(props) {
+ return (
+ <>
+ <ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
+ Glob "{props.input.pattern}"{" "}
+ <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+ <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
+ </ToolTitle>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof GrepTool>({
+ name: "grep",
+ container: "inline",
+ render(props) {
+ return (
+ <ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
+ Grep "{props.input.pattern}"{" "}
+ <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
+ <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
+ </ToolTitle>
+ )
+ },
+})
+
+ToolRegistry.register<typeof ListTool>({
+ name: "list",
+ container: "inline",
+ render(props) {
+ const dir = createMemo(() => {
+ if (props.input.path) {
+ return normalizePath(props.input.path)
+ }
+ return ""
+ })
+ return (
+ <>
+ <ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
+ List {dir()}
+ </ToolTitle>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof TaskTool>({
+ name: "task",
+ container: "block",
+ render(props) {
+ const { theme } = useTheme()
+ return (
+ <>
+ <ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
+ Task {props.input.description}
+ </ToolTitle>
+ <Show when={props.metadata.summary?.length}>
+ <box>
+ <For each={props.metadata.summary ?? []}>
+ {(task) => (
+ <text style={{ fg: theme.textMuted }}>
+ ∟ {task.tool} {task.state.status === "completed" ? task.state.title : ""}
+ </text>
+ )}
+ </For>
+ </box>
+ </Show>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof WebFetchTool>({
+ name: "webfetch",
+ container: "inline",
+ render(props) {
+ return (
+ <ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
+ WebFetch {(props.input as any).url}
+ </ToolTitle>
+ )
+ },
+})
+
+ToolRegistry.register<typeof EditTool>({
+ name: "edit",
+ container: "block",
+ render(props) {
+ const ctx = use()
+
+ const style = createMemo(() => (ctx.width > 120 ? "split" : "stacked"))
+
+ const diff = createMemo(() => {
+ const diff = props.metadata.diff ?? props.permission["diff"]
+ if (!diff) return null
+ const patches = parsePatch(diff)
+ if (patches.length === 0) return null
+
+ const patch = patches[0]
+ const oldLines: string[] = []
+ const newLines: string[] = []
+
+ for (const hunk of patch.hunks) {
+ let i = 0
+ while (i < hunk.lines.length) {
+ const line = hunk.lines[i]
+
+ if (line.startsWith("-")) {
+ const removedLines: string[] = []
+ while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) {
+ removedLines.push("- " + hunk.lines[i].slice(1))
+ i++
+ }
+
+ const addedLines: string[] = []
+ while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
+ addedLines.push("+ " + hunk.lines[i].slice(1))
+ i++
+ }
+
+ const maxLen = Math.max(removedLines.length, addedLines.length)
+ for (let j = 0; j < maxLen; j++) {
+ oldLines.push(removedLines[j] ?? "")
+ newLines.push(addedLines[j] ?? "")
+ }
+ } else if (line.startsWith("+")) {
+ const addedLines: string[] = []
+ while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) {
+ addedLines.push("+ " + hunk.lines[i].slice(1))
+ i++
+ }
+
+ for (const added of addedLines) {
+ oldLines.push("")
+ newLines.push(added)
+ }
+ } else {
+ oldLines.push(" " + line.slice(1))
+ newLines.push(" " + line.slice(1))
+ i++
+ }
+ }
+ }
+
+ return {
+ oldContent: oldLines.join("\n"),
+ newContent: newLines.join("\n"),
+ }
+ })
+
+ const code = createMemo(() => {
+ if (!props.metadata.diff) return ""
+ const text = props.metadata.diff.split("\n").slice(5).join("\n")
+ return text.trim()
+ })
+
+ const ft = createMemo(() => filetype(props.input.filePath))
+
+ return (
+ <>
+ <ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
+ Edit {normalizePath(props.input.filePath!)}{" "}
+ {input({
+ replaceAll: props.input.replaceAll,
+ })}
+ </ToolTitle>
+ <Switch>
+ <Match when={props.permission["diff"]}>
+ <text>{props.permission["diff"]?.trim()}</text>
+ </Match>
+ <Match when={diff() && style() === "split"}>
+ <box paddingLeft={1} flexDirection="row" gap={2}>
+ <box flexGrow={1} flexBasis={0}>
+ <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.oldContent} />
+ </box>
+ <box flexGrow={1} flexBasis={0}>
+ <code filetype={ft()} syntaxStyle={SyntaxTheme} content={diff()!.newContent} />
+ </box>
+ </box>
+ </Match>
+ <Match when={code()}>
+ <box paddingLeft={1}>
+ <code filetype={ft()} syntaxStyle={SyntaxTheme} content={code()} />
+ </box>
+ </Match>
+ </Switch>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof PatchTool>({
+ name: "patch",
+ container: "block",
+ render(props) {
+ return (
+ <>
+ <ToolTitle icon="%" fallback="Preparing patch..." when={true}>
+ Patch
+ </ToolTitle>
+ <Show when={props.output}>
+ <box>
+ <text>{props.output?.trim()}</text>
+ </box>
+ </Show>
+ </>
+ )
+ },
+})
+
+ToolRegistry.register<typeof TodoWriteTool>({
+ name: "todowrite",
+ container: "block",
+ render(props) {
+ const { theme } = useTheme()
+ return (
+ <box>
+ <For each={props.input.todos ?? []}>
+ {(todo) => (
+ <text style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}>
+ [{todo.status === "completed" ? "✓" : " "}] {todo.content}
+ </text>
+ )}
+ </For>
+ </box>
+ )
+ },
+})
+
+function normalizePath(input?: string) {
+ if (!input) return ""
+ if (path.isAbsolute(input)) {
+ return path.relative(process.cwd(), input) || "."
+ }
+ return input
+}
+
+function input(input: Record<string, any>, omit?: string[]): string {
+ const primitives = Object.entries(input).filter(([key, value]) => {
+ if (omit?.includes(key)) return false
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
+ })
+ if (primitives.length === 0) return ""
+ return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
+}
+
+function filetype(input?: string) {
+ if (!input) return "none"
+ const ext = path.extname(input)
+ const language = LANGUAGE_EXTENSIONS[ext]
+ if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
+ return language
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
new file mode 100644
index 000000000..380d82964
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -0,0 +1,175 @@
+import { useSync } from "@tui/context/sync"
+import { createMemo, For, Show, Switch, Match } from "solid-js"
+import { useTheme } from "../../context/theme"
+import { Locale } from "@/util/locale"
+import path from "path"
+import type { AssistantMessage } from "@opencode-ai/sdk"
+
+export function Sidebar(props: { sessionID: string }) {
+ const sync = useSync()
+ const { theme } = useTheme()
+ const session = createMemo(() => sync.session.get(props.sessionID)!)
+ const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
+ const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
+
+ 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.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 (
+ <Show when={session()}>
+ <box flexShrink={0} gap={1} width={40}>
+ <box>
+ <text>
+ <b>{session().title}</b>
+ </text>
+ <Show when={session().share?.url}>
+ <text fg={theme.textMuted}>{session().share!.url}</text>
+ </Show>
+ </box>
+ <box>
+ <text>
+ <b>Context</b>
+ </text>
+ <text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
+ <text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
+ <text fg={theme.textMuted}>{cost()} spent</text>
+ </box>
+ <Show when={Object.keys(sync.data.mcp).length > 0}>
+ <box>
+ <text>
+ <b>MCP</b>
+ </text>
+ <For each={Object.entries(sync.data.mcp)}>
+ {([key, item]) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: {
+ connected: theme.success,
+ failed: theme.error,
+ disabled: theme.textMuted,
+ }[item.status],
+ }}
+ >
+ •
+ </text>
+ <text wrapMode="word">
+ {key}{" "}
+ <span style={{ fg: theme.textMuted }}>
+ <Switch>
+ <Match when={item.status === "connected"}>Connected</Match>
+ <Match when={item.status === "failed" && item}>
+ {(val) => <i>{val().error}</i>}
+ </Match>
+ <Match when={item.status === "disabled"}>Disabled in configuration</Match>
+ </Switch>
+ </span>
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ </Show>
+ <Show when={sync.data.lsp.length > 0}>
+ <box>
+ <text>
+ <b>LSP</b>
+ </text>
+ <For each={sync.data.lsp}>
+ {(item) => (
+ <box flexDirection="row" gap={1}>
+ <text
+ flexShrink={0}
+ style={{
+ fg: {
+ connected: theme.success,
+ error: theme.error,
+ }[item.status],
+ }}
+ >
+ •
+ </text>
+ <text fg={theme.textMuted}>
+ {item.id} {item.root}
+ </text>
+ </box>
+ )}
+ </For>
+ </box>
+ </Show>
+ <Show when={session().summary?.diffs}>
+ <box>
+ <text>
+ <b>Modified Files</b>
+ </text>
+ <For each={session().summary?.diffs || []}>
+ {(item) => {
+ const file = createMemo(() => {
+ const splits = item.file.split(path.sep).filter(Boolean)
+ const last = splits.at(-1)!
+ const rest = splits.slice(0, -1).join(path.sep)
+ return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
+ })
+ return (
+ <box flexDirection="row" gap={1} justifyContent="space-between">
+ <text fg={theme.textMuted} wrapMode="char">
+ {file()}
+ </text>
+ <box flexDirection="row" gap={1} flexShrink={0}>
+ <Show when={item.additions}>
+ <text fg={theme.diffAdded}>+{item.additions}</text>
+ </Show>
+ <Show when={item.deletions}>
+ <text fg={theme.diffRemoved}>-{item.deletions}</text>
+ </Show>
+ </box>
+ </box>
+ )
+ }}
+ </For>
+ </box>
+ </Show>
+ <Show when={todo().length > 0}>
+ <box>
+ <text>
+ <b>Todo</b>
+ </text>
+ <For each={todo()}>
+ {(todo) => (
+ <text
+ style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
+ >
+ [{todo.status === "completed" ? "✓" : " "}] {todo.content}
+ </text>
+ )}
+ </For>
+ </box>
+ </Show>
+ </box>
+ </Show>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts
new file mode 100644
index 000000000..162d8793b
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/spawn.ts
@@ -0,0 +1,57 @@
+import { cmd } from "@/cli/cmd/cmd"
+import { Instance } from "@/project/instance"
+import path from "path"
+import { Server } from "@/server/server"
+import { upgrade } from "@/cli/upgrade"
+
+export const TuiSpawnCommand = cmd({
+ command: "spawn [project]",
+ builder: (yargs) =>
+ yargs
+ .positional("project", {
+ type: "string",
+ describe: "path to start opencode in",
+ })
+ .option("port", {
+ type: "number",
+ describe: "port to listen on",
+ default: 0,
+ })
+ .option("hostname", {
+ alias: ["h"],
+ type: "string",
+ describe: "hostname to listen on",
+ default: "127.0.0.1",
+ }),
+ handler: async (args) => {
+ upgrade()
+ const server = Server.listen({
+ port: args.port,
+ hostname: "127.0.0.1",
+ })
+ const bin = process.execPath
+ const cmd = []
+ let cwd = process.cwd()
+ if (bin.endsWith("bun")) {
+ cmd.push(
+ process.execPath,
+ "run",
+ "--conditions",
+ "browser",
+ new URL("../../../index.ts", import.meta.url).pathname,
+ )
+ cwd = new URL("../../../../", import.meta.url).pathname
+ } else cmd.push(process.execPath)
+ cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
+ const proc = Bun.spawn({
+ cmd,
+ cwd,
+ stdout: "inherit",
+ stderr: "inherit",
+ stdin: "inherit",
+ })
+ await proc.exited
+ await Instance.disposeAll()
+ await server.stop(true)
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
new file mode 100644
index 000000000..cadeac17e
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -0,0 +1,105 @@
+import { cmd } from "@/cli/cmd/cmd"
+import { tui } from "./app"
+import { Rpc } from "@/util/rpc"
+import { type rpc } from "./worker"
+import { upgrade } from "@/cli/upgrade"
+import { Session } from "@/session"
+import { bootstrap } from "@/cli/bootstrap"
+import path from "path"
+import { UI } from "@/cli/ui"
+
+export const TuiThreadCommand = cmd({
+ command: "$0 [project]",
+ describe: "start opencode tui",
+ builder: (yargs) =>
+ yargs
+ .positional("project", {
+ type: "string",
+ describe: "path to start opencode in",
+ })
+ .option("model", {
+ type: "string",
+ alias: ["m"],
+ describe: "model to use in the format of provider/model",
+ })
+ .option("continue", {
+ alias: ["c"],
+ describe: "continue the last session",
+ type: "boolean",
+ })
+ .option("session", {
+ alias: ["s"],
+ describe: "session id to continue",
+ type: "string",
+ })
+ .option("agent", {
+ type: "string",
+ describe: "agent to use",
+ })
+ .option("port", {
+ type: "number",
+ describe: "port to listen on",
+ default: 0,
+ })
+ .option("hostname", {
+ alias: ["h"],
+ type: "string",
+ describe: "hostname to listen on",
+ default: "127.0.0.1",
+ }),
+ handler: async (args) => {
+ const cwd = args.project ? path.resolve(args.project) : process.cwd()
+ try {
+ process.chdir(cwd)
+ } catch (e) {
+ UI.error("Failed to change directory to " + cwd)
+ return
+ }
+ await bootstrap(cwd, async () => {
+ upgrade()
+
+ const sessionID = await (async () => {
+ if (args.continue) {
+ const it = Session.list()
+ try {
+ for await (const s of it) {
+ if (s.parentID === undefined) {
+ return s.id
+ }
+ }
+ return
+ } finally {
+ await it.return()
+ }
+ }
+ if (args.session) {
+ return args.session
+ }
+ return undefined
+ })()
+
+ const worker = new Worker("./src/cli/cmd/tui/worker.ts")
+ worker.onerror = console.error
+ const client = Rpc.client<typeof rpc>(worker)
+ process.on("uncaughtException", (e) => {
+ console.error(e)
+ })
+ process.on("unhandledRejection", (e) => {
+ console.error(e)
+ })
+ const server = await client.call("server", {
+ port: args.port,
+ hostname: args.hostname,
+ })
+ await tui({
+ url: server.url,
+ sessionID,
+ model: args.model,
+ agent: args.agent,
+ onExit: async () => {
+ await client.call("shutdown", undefined)
+ },
+ })
+ })
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx
new file mode 100644
index 000000000..6bb59d6c7
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx
@@ -0,0 +1,55 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useDialog, type DialogContext } from "./dialog"
+import { useKeyboard } from "@opentui/solid"
+
+export type DialogAlertProps = {
+ title: string
+ message: string
+ onConfirm?: () => void
+}
+
+export function DialogAlert(props: DialogAlertProps) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+
+ useKeyboard((evt) => {
+ if (evt.name === "return") {
+ props.onConfirm?.()
+ dialog.clear()
+ }
+ })
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD}>{props.title}</text>
+ <text fg={theme.textMuted}>esc</text>
+ </box>
+ <box paddingBottom={1}>
+ <text fg={theme.textMuted}>{props.message}</text>
+ </box>
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
+ <box
+ paddingLeft={3}
+ paddingRight={3}
+ backgroundColor={theme.primary}
+ onMouseUp={() => {
+ props.onConfirm?.()
+ dialog.clear()
+ }}
+ >
+ <text fg={theme.background}>ok</text>
+ </box>
+ </box>
+ </box>
+ )
+}
+
+DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
+ return new Promise<void>((resolve) => {
+ dialog.replace(
+ () => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
+ () => resolve(),
+ )
+ })
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
new file mode 100644
index 000000000..dd5b238b1
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
@@ -0,0 +1,79 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useDialog, type DialogContext } from "./dialog"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+import { Locale } from "@/util/locale"
+
+export type DialogConfirmProps = {
+ title: string
+ message: string
+ onConfirm?: () => void
+ onCancel?: () => void
+}
+
+export function DialogConfirm(props: DialogConfirmProps) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const [store, setStore] = createStore({
+ active: "confirm" as "confirm" | "cancel",
+ })
+
+ useKeyboard((evt) => {
+ if (evt.name === "return") {
+ if (store.active === "confirm") props.onConfirm?.()
+ if (store.active === "cancel") props.onCancel?.()
+ dialog.clear()
+ }
+
+ if (evt.name === "left" || evt.name === "right") {
+ setStore("active", store.active === "confirm" ? "cancel" : "confirm")
+ }
+ })
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD}>{props.title}</text>
+ <text fg={theme.textMuted}>esc</text>
+ </box>
+ <box paddingBottom={1}>
+ <text fg={theme.textMuted}>{props.message}</text>
+ </box>
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
+ <For each={["cancel", "confirm"]}>
+ {(key) => (
+ <box
+ paddingLeft={1}
+ paddingRight={1}
+ backgroundColor={key === store.active ? theme.primary : undefined}
+ onMouseUp={(evt) => {
+ if (key === "confirm") props.onConfirm?.()
+ if (key === "cancel") props.onCancel?.()
+ dialog.clear()
+ }}
+ >
+ <text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
+ </box>
+ )}
+ </For>
+ </box>
+ </box>
+ )
+}
+
+DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
+ return new Promise<boolean>((resolve) => {
+ dialog.replace(
+ () => (
+ <DialogConfirm
+ title={title}
+ message={message}
+ onConfirm={() => resolve(true)}
+ onCancel={() => resolve(false)}
+ />
+ ),
+ () => resolve(false),
+ )
+ })
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
new file mode 100644
index 000000000..f08e0dabf
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
@@ -0,0 +1,39 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "@tui/context/theme"
+import { useDialog } from "./dialog"
+import { useKeyboard } from "@opentui/solid"
+
+export function DialogHelp() {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+
+ useKeyboard((evt) => {
+ if (evt.name === "return" || evt.name === "escape") {
+ dialog.clear()
+ }
+ })
+
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD}>Help</text>
+ <text fg={theme.textMuted}>esc/enter</text>
+ </box>
+ <box paddingBottom={1}>
+ <text fg={theme.textMuted}>
+ Press Ctrl+P to see all available actions and commands in any context.
+ </text>
+ </box>
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
+ <box
+ paddingLeft={3}
+ paddingRight={3}
+ backgroundColor={theme.primary}
+ onMouseUp={() => dialog.clear()}
+ >
+ <text fg={theme.background}>ok</text>
+ </box>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
new file mode 100644
index 000000000..668ffb8d3
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -0,0 +1,275 @@
+import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
+import { useTheme } from "@tui/context/theme"
+import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
+import { batch, createEffect, createMemo, For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import * as fuzzysort from "fuzzysort"
+import { isDeepEqual } from "remeda"
+import { useDialog, type DialogContext } from "@tui/ui/dialog"
+import { useKeybind } from "@tui/context/keybind"
+import { Keybind } from "@/util/keybind"
+import { Locale } from "@/util/locale"
+
+export interface DialogSelectProps<T> {
+ title: string
+ options: DialogSelectOption<T>[]
+ ref?: (ref: DialogSelectRef<T>) => void
+ onMove?: (option: DialogSelectOption<T>) => void
+ onFilter?: (query: string) => void
+ onSelect?: (option: DialogSelectOption<T>) => void
+ keybind?: {
+ keybind: Keybind.Info
+ title: string
+ onTrigger: (option: DialogSelectOption<T>) => void
+ }[]
+ limit?: number
+ current?: T
+}
+
+export interface DialogSelectOption<T = any> {
+ title: string
+ value: T
+ description?: string
+ footer?: string
+ category?: string
+ disabled?: boolean
+ bg?: RGBA
+ onSelect?: (ctx: DialogContext) => void
+}
+
+export type DialogSelectRef<T> = {
+ filter: string
+ filtered: DialogSelectOption<T>[]
+}
+
+export function DialogSelect<T>(props: DialogSelectProps<T>) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const [store, setStore] = createStore({
+ selected: 0,
+ filter: "",
+ })
+
+ let input: InputRenderable
+
+ const filtered = createMemo(() => {
+ const needle = store.filter.toLowerCase()
+ const result = pipe(
+ props.options,
+ filter((x) => x.disabled !== true),
+ take(props.limit ?? Infinity),
+ (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
+ )
+ return result
+ })
+
+ const grouped = createMemo(() => {
+ const result = pipe(
+ filtered(),
+ groupBy((x) => x.category ?? ""),
+ // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
+ entries(),
+ )
+ return result
+ })
+
+ const flat = createMemo(() => {
+ return pipe(
+ grouped(),
+ flatMap(([_, options]) => options),
+ )
+ })
+
+ const dimensions = useTerminalDimensions()
+ const height = createMemo(() =>
+ Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
+ )
+
+ const selected = createMemo(() => flat()[store.selected])
+
+ createEffect(() => {
+ store.filter
+ setStore("selected", 0)
+ scroll.scrollTo(0)
+ })
+
+ function move(direction: number) {
+ let next = store.selected + direction
+ if (next < 0) next = flat().length - 1
+ if (next >= flat().length) next = 0
+ moveTo(next)
+ }
+
+ function moveTo(next: number) {
+ setStore("selected", next)
+ props.onMove?.(selected()!)
+ const target = scroll.getChildren().find((child) => {
+ return child.id === JSON.stringify(selected()?.value)
+ })
+ if (!target) return
+ const y = target.y - scroll.y
+ if (y >= scroll.height) {
+ scroll.scrollBy(y - scroll.height + 1)
+ }
+ if (y < 0) {
+ scroll.scrollBy(y)
+ if (isDeepEqual(flat()[0].value, selected()?.value)) {
+ scroll.scrollTo(0)
+ }
+ }
+ }
+
+ const keybind = useKeybind()
+ useKeyboard((evt) => {
+ if (evt.name === "up") move(-1)
+ if (evt.name === "down") move(1)
+ if (evt.name === "pageup") move(-10)
+ if (evt.name === "pagedown") move(10)
+ if (evt.name === "return") {
+ const option = selected()
+ if (option.onSelect) option.onSelect(dialog)
+ props.onSelect?.(option)
+ }
+
+ for (const item of props.keybind ?? []) {
+ if (Keybind.match(item.keybind, keybind.parse(evt))) {
+ const s = selected()
+ if (s) item.onTrigger(s)
+ }
+ }
+ })
+
+ let scroll: ScrollBoxRenderable
+ const ref: DialogSelectRef<T> = {
+ get filter() {
+ return store.filter
+ },
+ get filtered() {
+ return filtered()
+ },
+ }
+ props.ref?.(ref)
+
+ return (
+ <box gap={1}>
+ <box paddingLeft={3} paddingRight={2}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD}>{props.title}</text>
+ <text fg={theme.textMuted}>esc</text>
+ </box>
+ <box paddingTop={1} paddingBottom={1}>
+ <input
+ onInput={(e) => {
+ batch(() => {
+ setStore("filter", e)
+ props.onFilter?.(e)
+ })
+ }}
+ focusedBackgroundColor={theme.backgroundPanel}
+ cursorColor={theme.primary}
+ focusedTextColor={theme.textMuted}
+ ref={(r) => {
+ input = r
+ input.focus()
+ }}
+ placeholder="Enter search term"
+ />
+ </box>
+ </box>
+ <scrollbox
+ paddingLeft={2}
+ paddingRight={2}
+ scrollbarOptions={{ visible: false }}
+ ref={(r: ScrollBoxRenderable) => (scroll = r)}
+ maxHeight={height()}
+ >
+ <For each={grouped()}>
+ {([category, options], index) => (
+ <>
+ <Show when={category}>
+ <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
+ <text fg={theme.accent} attributes={TextAttributes.BOLD}>
+ {category}
+ </text>
+ </box>
+ </Show>
+ <For each={options}>
+ {(option) => {
+ const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
+ return (
+ <box
+ id={JSON.stringify(option.value)}
+ flexDirection="row"
+ onMouseUp={() => {
+ option.onSelect?.(dialog)
+ props.onSelect?.(option)
+ }}
+ onMouseOver={() => {
+ const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
+ if (index === -1) return
+ moveTo(index)
+ }}
+ backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
+ paddingLeft={1}
+ paddingRight={1}
+ gap={1}
+ >
+ <Option
+ title={option.title}
+ footer={option.footer}
+ description={option.description !== category ? option.description : undefined}
+ active={active()}
+ current={isDeepEqual(option.value, props.current)}
+ />
+ </box>
+ )
+ }}
+ </For>
+ </>
+ )}
+ </For>
+ </scrollbox>
+ <box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1}>
+ <For each={props.keybind ?? []}>
+ {(item) => (
+ <text>
+ <span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
+ <span style={{ fg: theme.textMuted }}> {item.title}</span>
+ </text>
+ )}
+ </For>
+ </box>
+ </box>
+ )
+}
+
+function Option(props: {
+ title: string
+ description?: string
+ active?: boolean
+ current?: boolean
+ footer?: string
+ onMouseOver?: () => void
+}) {
+ const { theme } = useTheme()
+ return (
+ <>
+ <text
+ flexGrow={1}
+ fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
+ attributes={props.active ? TextAttributes.BOLD : undefined}
+ overflow="hidden"
+ wrapMode="none"
+ >
+ {Locale.truncate(props.title, 62)}
+ <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
+ </text>
+ <Show when={props.footer}>
+ <box flexShrink={0}>
+ <text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
+ </box>
+ </Show>
+ </>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
new file mode 100644
index 000000000..ec2233c69
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
@@ -0,0 +1,171 @@
+import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+import { Renderable, RGBA } from "@opentui/core"
+import { createStore } from "solid-js/store"
+import { createEventBus } from "@solid-primitives/event-bus"
+
+const Border = {
+ topLeft: "┃",
+ topRight: "┃",
+ bottomLeft: "┃",
+ bottomRight: "┃",
+ horizontal: "",
+ vertical: "┃",
+ topT: "+",
+ bottomT: "+",
+ leftT: "+",
+ rightT: "+",
+ cross: "+",
+}
+export function Dialog(
+ props: ParentProps<{
+ size?: "medium" | "large"
+ onClose: () => void
+ }>,
+) {
+ const dimensions = useTerminalDimensions()
+ const { theme } = useTheme()
+
+ return (
+ <box
+ onMouseUp={async () => {
+ props.onClose?.()
+ }}
+ width={dimensions().width}
+ height={dimensions().height}
+ alignItems="center"
+ position="absolute"
+ paddingTop={dimensions().height / 4}
+ left={0}
+ top={0}
+ backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
+ >
+ <box
+ onMouseUp={async (e) => {
+ e.stopPropagation()
+ }}
+ customBorderChars={Border}
+ width={props.size === "large" ? 80 : 60}
+ maxWidth={dimensions().width - 2}
+ backgroundColor={theme.backgroundPanel}
+ borderColor={theme.border}
+ paddingTop={1}
+ >
+ {props.children}
+ </box>
+ </box>
+ )
+}
+
+function init() {
+ const [store, setStore] = createStore({
+ stack: [] as {
+ element: JSX.Element
+ onClose?: () => void
+ }[],
+ size: "medium" as "medium" | "large",
+ })
+ const allClosedEvent = createEventBus<void>()
+
+ useKeyboard((evt) => {
+ if (evt.name === "escape" && store.stack.length > 0) {
+ const current = store.stack.at(-1)!
+ current.onClose?.()
+ setStore("stack", store.stack.slice(0, -1))
+ evt.preventDefault()
+ refocus()
+ }
+ })
+
+ const renderer = useRenderer()
+ let focus: Renderable | null
+ function refocus() {
+ setTimeout(() => {
+ if (!focus) return
+ if (focus.isDestroyed) return
+ function find(item: Renderable) {
+ for (const child of item.getChildren()) {
+ if (child === focus) return true
+ if (find(child)) return true
+ }
+ return false
+ }
+ const found = find(renderer.root)
+ if (!found) return
+ focus.focus()
+ }, 1)
+ }
+
+ createEffect(() => {
+ if (store.stack.length === 0) {
+ allClosedEvent.emit()
+ }
+ })
+
+ return {
+ clear() {
+ for (const item of store.stack) {
+ if (item.onClose) item.onClose()
+ }
+ batch(() => {
+ setStore("size", "medium")
+ setStore("stack", [])
+ })
+ refocus()
+ },
+ replace(input: any, onClose?: () => void) {
+ if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
+ for (const item of store.stack) {
+ if (item.onClose) item.onClose()
+ }
+ setStore("size", "medium")
+ setStore("stack", [
+ {
+ element: input,
+ onClose,
+ },
+ ])
+ },
+ get stack() {
+ return store.stack
+ },
+ get size() {
+ return store.size
+ },
+ setSize(size: "medium" | "large") {
+ setStore("size", size)
+ },
+ get allClosedEvent() {
+ return allClosedEvent
+ }
+ }
+}
+
+export type DialogContext = ReturnType<typeof init>
+
+const ctx = createContext<DialogContext>()
+
+export function DialogProvider(props: ParentProps) {
+ const value = init()
+ return (
+ <ctx.Provider value={value}>
+ {props.children}
+ <box position="absolute">
+ <Show when={value.stack.length}>
+ <Dialog onClose={() => value.clear()} size={value.size}>
+ {value.stack.at(-1)!.element}
+ </Dialog>
+ </Show>
+ </box>
+ </ctx.Provider>
+ )
+}
+
+export function useDialog() {
+ const value = useContext(ctx)
+ if (!value) {
+ throw new Error("useDialog must be used within a DialogProvider")
+ }
+ return value
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
new file mode 100644
index 000000000..6c5629b8a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
@@ -0,0 +1,56 @@
+import { RGBA } from "@opentui/core"
+import { useTimeline } from "@opentui/solid"
+import { createMemo, createSignal } from "solid-js"
+
+export type ShimmerProps = {
+ text: string
+ color: RGBA
+}
+
+const DURATION = 2_500
+
+export function Shimmer(props: ShimmerProps) {
+ const timeline = useTimeline({
+ duration: DURATION,
+ loop: true,
+ })
+ const characters = props.text.split("")
+ const color = props.color
+
+ const shimmerSignals = characters.map((_, i) => {
+ const [shimmer, setShimmer] = createSignal(0.4)
+ const target = {
+ shimmer: shimmer(),
+ setShimmer,
+ }
+
+ timeline!.add(
+ target,
+ {
+ shimmer: 1,
+ duration: DURATION / (props.text.length + 1),
+ ease: "linear",
+ alternate: true,
+ loop: 2,
+ onUpdate: () => {
+ target.setShimmer(target.shimmer)
+ },
+ },
+ (i * (DURATION / (props.text.length + 1))) / 2,
+ )
+
+ return shimmer
+ })
+
+ return (
+ <text>
+ {(() => {
+ return characters.map((ch, i) => {
+ const shimmer = shimmerSignals[i]
+ const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
+ return <span style={{ fg }}>{ch}</span>
+ })
+ })()}
+ </text>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
new file mode 100644
index 000000000..f742635e0
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx
@@ -0,0 +1,83 @@
+import { createContext, useContext, type ParentProps, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useTheme } from "@tui/context/theme"
+import { SplitBorder } from "../component/border"
+import { TextAttributes } from "@opentui/core"
+import z from "zod"
+import { TuiEvent } from "../event"
+
+export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
+
+export function Toast() {
+ const toast = useToast()
+ const { theme } = useTheme()
+
+ return (
+ <Show when={toast.currentToast}>
+ {(current) => (
+ <box
+ position="absolute"
+ justifyContent="center"
+ alignItems="flex-start"
+ top={2}
+ right={2}
+ paddingLeft={2}
+ paddingRight={2}
+ paddingTop={1}
+ paddingBottom={1}
+ backgroundColor={theme.backgroundPanel}
+ borderColor={theme[current().variant]}
+ border={["left", "right"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ >
+ <Show when={current().title}>
+ <text attributes={TextAttributes.BOLD} marginBottom={1}>
+ {current().title}
+ </text>
+ </Show>
+ <text>{current().message}</text>
+ </box>
+ )}
+ </Show>
+ )
+}
+
+function init() {
+ const [store, setStore] = createStore({
+ currentToast: null as ToastOptions | null,
+ })
+
+ let timeoutHandle: NodeJS.Timeout | null = null
+
+ return {
+ show(options: ToastOptions) {
+ const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
+ const { duration, ...currentToast } = parsedOptions
+ setStore("currentToast", currentToast)
+ if (timeoutHandle) clearTimeout(timeoutHandle)
+ timeoutHandle = setTimeout(() => {
+ setStore("currentToast", null)
+ }, duration).unref()
+ },
+ get currentToast(): ToastOptions | null {
+ return store.currentToast
+ },
+ }
+}
+
+export type ToastContext = ReturnType<typeof init>
+
+const ctx = createContext<ToastContext>()
+
+export function ToastProvider(props: ParentProps) {
+ const value = init()
+ return <ctx.Provider value={value}>{props.children}</ctx.Provider>
+}
+
+export function useToast() {
+ const value = useContext(ctx)
+ if (!value) {
+ throw new Error("useToast must be used within a ToastProvider")
+ }
+ return value
+}
diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
new file mode 100644
index 000000000..a2d83c912
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
@@ -0,0 +1,127 @@
+import { $ } from "bun"
+import { platform } from "os"
+import clipboardy from "clipboardy"
+import { lazy } from "../../../../util/lazy.js"
+import { tmpdir } from "os"
+import path from "path"
+
+export namespace Clipboard {
+ export interface Content {
+ data: string
+ mime: string
+ }
+
+ export async function read(): Promise<Content | undefined> {
+ const os = platform()
+
+ if (os === "darwin") {
+ const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
+ try {
+ await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
+ .nothrow()
+ .quiet()
+ const file = Bun.file(tmpfile)
+ const buffer = await file.arrayBuffer()
+ return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
+ } catch {
+ } finally {
+ await $`rm -f "${tmpfile}"`.nothrow().quiet()
+ }
+ }
+
+ if (os === "linux") {
+ const wayland = await $`wl-paste -t image/png`.nothrow().text()
+ if (wayland) {
+ return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
+ }
+ const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
+ if (x11) {
+ return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
+ }
+ }
+
+ if (os === "win32") {
+ const script =
+ "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
+ const base64 = await $`powershell -command "${script}"`.nothrow().text()
+ if (base64) {
+ const imageBuffer = Buffer.from(base64.trim(), "base64")
+ if (imageBuffer.length > 0) {
+ return { data: imageBuffer.toString("base64url"), mime: "image/png" }
+ }
+ }
+ }
+
+ const text = await clipboardy.read().catch(() => {})
+ if (text) {
+ return { data: text, mime: "text/plain" }
+ }
+ }
+
+ const getCopyMethod = lazy(() => {
+ const os = platform()
+
+ if (os === "darwin") {
+ console.log("clipboard: using osascript")
+ return async (text: string) => {
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+ await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
+ }
+ }
+
+ if (os === "linux") {
+ if (process.env["WAYLAND_DISPLAY"]) {
+ console.log("clipboard: using wl-copy")
+ return async (text: string) => {
+ const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
+ proc.stdin.write(text)
+ proc.stdin.end()
+ await proc.exited
+ }
+ }
+ if (Bun.which("xclip")) {
+ console.log("clipboard: using xclip")
+ return async (text: string) => {
+ const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
+ stdin: "pipe",
+ stdout: "ignore",
+ stderr: "ignore",
+ })
+ proc.stdin.write(text)
+ proc.stdin.end()
+ await proc.exited
+ }
+ }
+ if (Bun.which("xsel")) {
+ console.log("clipboard: using xsel")
+ return async (text: string) => {
+ const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
+ stdin: "pipe",
+ stdout: "ignore",
+ stderr: "ignore",
+ })
+ proc.stdin.write(text)
+ proc.stdin.end()
+ await proc.exited
+ }
+ }
+ }
+
+ if (os === "win32") {
+ console.log("clipboard: using powershell")
+ return async (text: string) => {
+ const escaped = text.replace(/"/g, '""')
+ await $`powershell -command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
+ }
+ }
+
+ console.log("clipboard: no native support")
+ return async (text: string) => {
+ await clipboardy.write(text).catch(() => {})
+ }
+ })
+
+ export async function copy(text: string): Promise<void> {
+ await getCopyMethod()(text)
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts
new file mode 100644
index 000000000..25a17fc7a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts
@@ -0,0 +1,31 @@
+import { defer } from "@/util/defer"
+import { rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { CliRenderer } from "@opentui/core"
+
+export namespace Editor {
+ export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
+ const editor = process.env["EDITOR"]
+ if (!editor) return
+
+ const filepath = join(tmpdir(), `${Date.now()}.md`)
+ await using _ = defer(async () => rm(filepath, { force: true }))
+
+ await Bun.write(filepath, opts.value)
+ opts.renderer.suspend()
+ opts.renderer.currentRenderBuffer.clear()
+ const parts = editor.split(" ")
+ const proc = Bun.spawn({
+ cmd: [...parts, filepath],
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ })
+ await proc.exited
+ const content = await Bun.file(filepath).text()
+ opts.renderer.resume()
+ opts.renderer.requestRender()
+ return content || undefined
+ }
+}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
new file mode 100644
index 000000000..d268449c1
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -0,0 +1,48 @@
+import { Installation } from "@/installation"
+import { Server } from "@/server/server"
+import { Log } from "@/util/log"
+import { Instance } from "@/project/instance"
+import { Rpc } from "@/util/rpc"
+
+await Log.init({
+ print: process.argv.includes("--print-logs"),
+ dev: Installation.isLocal(),
+ level: (() => {
+ if (Installation.isLocal()) return "DEBUG"
+ return "INFO"
+ })(),
+})
+
+process.on("unhandledRejection", (e) => {
+ Log.Default.error("rejection", {
+ e: e instanceof Error ? e.message : e,
+ })
+})
+
+process.on("uncaughtException", (e) => {
+ Log.Default.error("exception", {
+ e: e instanceof Error ? e.message : e,
+ })
+})
+
+let server: Bun.Server<undefined>
+export const rpc = {
+ async server(input: { port: number; hostname: string }) {
+ if (server) await server.stop(true)
+ try {
+ server = Server.listen(input)
+ return {
+ url: server.url.toString(),
+ }
+ } catch (e) {
+ console.error(e)
+ throw e
+ }
+ },
+ async shutdown() {
+ await Instance.disposeAll()
+ await server.stop(true)
+ },
+}
+
+Rpc.listen(rpc)
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
new file mode 100644
index 000000000..73332f955
--- /dev/null
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -0,0 +1,17 @@
+import { Bus } from "@/bus"
+import { Config } from "@/config/config"
+import { Flag } from "@/flag/flag"
+import { Installation } from "@/installation"
+
+export async function upgrade() {
+ const config = await Config.global()
+ if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
+ const latest = await Installation.latest().catch(() => {})
+ if (!latest) return
+ if (Installation.VERSION === latest) return
+ const method = await Installation.method()
+ if (method === "unknown") return
+ await Installation.upgrade(method, latest)
+ .then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
+ .catch(() => {})
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 55b5f0115..fec949606 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -49,7 +49,7 @@ export namespace Config {
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
process.env[value.key] = value.token
- const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
+ const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
result = mergeDeep(
result,
await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
@@ -108,29 +108,13 @@ export namespace Config {
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
- if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
- result.keybinds.messages_undo = result.keybinds.messages_revert
- }
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
- if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
- result.keybinds.messages_undo = result.keybinds.messages_revert
- }
- if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
- result.keybinds.switch_agent = result.keybinds.switch_mode
- }
- if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
- result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
- }
- if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
- result.keybinds.agent_cycle = result.keybinds.switch_agent
- }
- if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
- result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
- }
+
+ if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
return {
config: result,
@@ -181,7 +165,7 @@ export namespace Config {
{
cwd: dir,
},
- )
+ ).catch(() => {})
}
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
@@ -401,17 +385,11 @@ export namespace Config {
.optional()
.default("ctrl+x")
.describe("Leader key for keybind combinations"),
- app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
- project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
- tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
- thinking_blocks: z
- .string()
- .optional()
- .default("<leader>b")
- .describe("Toggle thinking blocks"),
+ sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
+ status_view: z.string().optional().default("<leader>s").describe("View status"),
session_export: z
.string()
.optional()
@@ -424,29 +402,23 @@ export namespace Config {
.optional()
.default("<leader>g")
.describe("Show session timeline"),
- session_share: z.string().optional().default("<leader>s").describe("Share current session"),
+ session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
- session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
- session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
- session_child_cycle: z
- .string()
- .optional()
- .default("ctrl+right")
- .describe("Cycle to next child session"),
- session_child_cycle_reverse: z
+ session_interrupt: z
.string()
.optional()
- .default("ctrl+left")
- .describe("Cycle to previous child session"),
+ .default("escape")
+ .describe("Interrupt current session"),
+ session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z
.string()
.optional()
- .default("pgup")
+ .default("pageup")
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
- .default("pgdown")
+ .default("pagedown")
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
@@ -458,22 +430,26 @@ export namespace Config {
.optional()
.default("ctrl+alt+d")
.describe("Scroll messages down by half page"),
- messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
+ messages_first: z
+ .string()
+ .optional()
+ .default("ctrl+g,home")
+ .describe("Navigate to first message"),
messages_last: z
.string()
.optional()
- .default("ctrl+alt+g")
+ .default("ctrl+alt+g,end")
.describe("Navigate to last message"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
- model_list: z.string().optional().default("<leader>m").describe("List available models"),
- model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
- model_cycle_recent_reverse: z
+ messages_toggle_conceal: z
.string()
.optional()
- .default("shift+f2")
- .describe("Previous recent model"),
+ .default("<leader>h")
+ .describe("Toggle code block concealment in messages"),
+ model_list: z.string().optional().default("<leader>m").describe("List available models"),
+ command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
@@ -485,59 +461,6 @@ export namespace Config {
.optional()
.default("shift+enter,ctrl+j")
.describe("Insert newline in input"),
- // Deprecated commands
- switch_mode: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated use agent_cycle. Next mode"),
- switch_mode_reverse: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated use agent_cycle_reverse. Previous mode"),
- switch_agent: z
- .string()
- .optional()
- .default("tab")
- .describe("@deprecated use agent_cycle. Next agent"),
- switch_agent_reverse: z
- .string()
- .optional()
- .default("shift+tab")
- .describe("@deprecated use agent_cycle_reverse. Previous agent"),
- file_list: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated Currently not available. List files"),
- file_close: z.string().optional().default("none").describe("@deprecated Close file"),
- file_search: z.string().optional().default("none").describe("@deprecated Search file"),
- file_diff_toggle: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated Split/unified diff"),
- messages_previous: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated Navigate to previous message"),
- messages_next: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated Navigate to next message"),
- messages_layout_toggle: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated Toggle layout"),
- messages_revert: z
- .string()
- .optional()
- .default("none")
- .describe("@deprecated use messages_undo. Revert message"),
})
.strict()
.meta({
@@ -820,7 +743,10 @@ export namespace Config {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
- { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
+ {
+ path: configFilepath,
+ message: errMsg + ` ${resolvedPath} does not exist`,
+ },
{ cause: error },
)
}
@@ -874,7 +800,10 @@ export namespace Config {
return data
}
- throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
+ throw new InvalidError({
+ path: configFilepath,
+ issues: parsed.error.issues,
+ })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 49eac54ac..cb405c181 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -284,7 +284,9 @@ export namespace File {
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
const nodes: Node[] = []
- for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
+ for (const entry of await fs.promises.readdir(resolved, {
+ withFileTypes: true,
+ })) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(Instance.directory, fullPath)
diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts
index 4ebea7c66..2fbe30707 100644
--- a/packages/opencode/src/global/index.ts
+++ b/packages/opencode/src/global/index.ts
@@ -1,6 +1,7 @@
import fs from "fs/promises"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
+import os from "os"
const app = "opencode"
@@ -11,6 +12,7 @@ const state = path.join(xdgState!, app)
export namespace Global {
export const Path = {
+ home: os.homedir(),
data,
bin: path.join(data, "bin"),
log: path.join(data, "log"),
@@ -38,7 +40,12 @@ if (version !== CACHE_VERSION) {
try {
const contents = await fs.readdir(Global.Path.cache)
await Promise.all(
- contents.map((item) => fs.rm(path.join(Global.Path.cache, item), { recursive: true, force: true })),
+ contents.map((item) =>
+ fs.rm(path.join(Global.Path.cache, item), {
+ recursive: true,
+ force: true,
+ }),
+ ),
)
} catch (e) {}
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 45ccd3cad..26c9f543d 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -12,13 +12,14 @@ import { Installation } from "./installation"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
-import { TuiCommand } from "./cli/cmd/tui"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
-import { AttachCommand } from "./cli/cmd/attach"
+import { AttachCommand } from "./cli/cmd/tui/attach"
+import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
@@ -69,7 +70,8 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command(AcpCommand)
.command(McpCommand)
- .command(TuiCommand)
+ .command(TuiThreadCommand)
+ .command(TuiSpawnCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 2d36f454b..1a6e2cb71 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -139,7 +139,10 @@ export namespace LSPClient {
if (version !== undefined) {
const next = version + 1
files[input.path] = next
- log.info("textDocument/didChange", { path: input.path, version: next })
+ log.info("textDocument/didChange", {
+ path: input.path,
+ version: next,
+ })
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index cccc8e774..67dd838ac 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/packages/opencode/src/lsp/index.ts
@@ -6,10 +6,15 @@ import z from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
+import { Bus } from "../bus"
export namespace LSP {
const log = Log.create({ service: "lsp" })
+ export const Event = {
+ Updated: Bus.event("lsp.updated", z.object({})),
+ }
+
export const Range = z
.object({
start: z.object({
@@ -109,6 +114,33 @@ export namespace LSP {
return state()
}
+ export const Status = z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ root: z.string(),
+ status: z.union([z.literal("connected"), z.literal("error")]),
+ })
+ .meta({
+ ref: "LSPStatus",
+ })
+ export type Status = z.infer<typeof Status>
+
+ export async function status() {
+ return state().then((x) => {
+ const result: Status[] = []
+ for (const client of x.clients) {
+ result.push({
+ id: client.serverID,
+ name: x.servers[client.serverID].id,
+ root: path.relative(Instance.directory, client.root),
+ status: "connected",
+ })
+ }
+ return result
+ })
+ }
+
async function getClients(file: string) {
const s = await state()
const extension = path.parse(file).ext || file
@@ -147,12 +179,15 @@ export namespace LSP {
}).catch((err) => {
s.broken.add(root + server.id)
handle.process.kill()
- log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
+ log.error(`Failed to initialize LSP client ${server.id}`, {
+ error: err,
+ })
return undefined
})
if (!client) continue
s.clients.push(client)
result.push(client)
+ Bus.publish(Event.Updated, {})
}
return result
}
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index da4508790..d0fb11e94 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -467,7 +467,7 @@ export namespace LSPServer {
return
}
- const release = await releaseResponse.json()
+ const release = (await releaseResponse.json()) as any
const platform = process.platform
const arch = process.arch
@@ -660,7 +660,7 @@ export namespace LSPServer {
return
}
- const release = await releaseResponse.json()
+ const release = (await releaseResponse.json()) as any
const platform = process.platform
let assetName = ""
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index d492a9365..d80d34330 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -5,9 +5,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
-import z from "zod"
-import { Session } from "../session"
-import { Bus } from "../bus"
+import z from "zod/v4"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
@@ -21,27 +19,61 @@ export namespace MCP {
}),
)
+ type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
+
+ export const Status = z
+ .discriminatedUnion("status", [
+ z
+ .object({
+ status: z.literal("connected"),
+ })
+ .meta({
+ ref: "MCPStatusConnected",
+ }),
+ z
+ .object({
+ status: z.literal("disabled"),
+ })
+ .meta({
+ ref: "MCPStatusDisabled",
+ }),
+ z
+ .object({
+ status: z.literal("failed"),
+ error: z.string(),
+ })
+ .meta({
+ ref: "MCPStatusFailed",
+ }),
+ ])
+ .meta({
+ ref: "MCPStatus",
+ })
+ export type Status = z.infer<typeof Status>
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
const state = Instance.state(
async () => {
const cfg = await Config.get()
const config = cfg.mcp ?? {}
- const clients: {
- [name: string]: MCPClient
- } = {}
+ const clients: Record<string, Client> = {}
+ const status: Record<string, Status> = {}
await Promise.all(
Object.entries(config).map(async ([key, mcp]) => {
const result = await create(key, mcp).catch(() => undefined)
if (!result) return
- clients[key] = result.client
+
+ status[key] = result.status
+
+ if (result.mcpClient) {
+ clients[key] = result.mcpClient
+ }
}),
)
-
return {
+ status,
clients,
- config,
}
},
async (state) => {
@@ -53,17 +85,22 @@ export namespace MCP {
const s = await state()
const result = await create(name, mcp)
if (!result) return
- s.clients[name] = result.client
+ if (!result.mcpClient) {
+ s.status[name] = result.status
+ return
+ }
+ s.clients[name] = result.mcpClient
+ s.status[name] = result.status
}
- async function create(name: string, mcp: Config.Mcp) {
+ async function create(key: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
- log.info("mcp server disabled", { name })
+ log.info("mcp server disabled", { key })
return
}
- log.info("found", { name, type: mcp.type })
-
+ log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
+ let status: Status | undefined
if (mcp.type === "remote") {
const transports = [
@@ -86,44 +123,37 @@ export namespace MCP {
]
let lastError: Error | undefined
for (const { name, transport } of transports) {
- const client = await experimental_createMCPClient({
+ const result = await experimental_createMCPClient({
name: "opencode",
transport,
- }).catch((error) => {
- lastError = error instanceof Error ? error : new Error(String(error))
- log.debug("transport connection failed", {
- name,
- transport: name,
- url: mcp.url,
- error: lastError.message,
- })
- return null
- })
- if (client) {
- log.debug("transport connection succeeded", { name, transport: name })
- mcpClient = client
- break
- }
- }
- if (!mcpClient) {
- const errorMessage = lastError
- ? `MCP server ${name} failed to connect: ${lastError.message}`
- : `MCP server ${name} failed to connect to ${mcp.url}`
- log.error("remote mcp connection failed", { name, url: mcp.url, error: lastError?.message })
- Bus.publish(Session.Event.Error, {
- error: {
- name: "UnknownError",
- data: {
- message: errorMessage,
- },
- },
})
+ .then((client) => {
+ log.info("connected", { key, transport: name })
+ mcpClient = client
+ status = { status: "connected" }
+ return true
+ })
+ .catch((error) => {
+ lastError = error instanceof Error ? error : new Error(String(error))
+ log.debug("transport connection failed", {
+ key,
+ transport: name,
+ url: mcp.url,
+ error: lastError.message,
+ })
+ status = {
+ status: "failed",
+ error: lastError.message,
+ }
+ return false
+ })
+ if (result) break
}
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
- const client = await experimental_createMCPClient({
+ await experimental_createMCPClient({
name: "opencode",
transport: new StdioClientTransport({
stderr: "ignore",
@@ -135,63 +165,61 @@ export namespace MCP {
...mcp.environment,
},
}),
- }).catch((error) => {
- const errorMessage =
- error instanceof Error
- ? `MCP server ${name} failed to start: ${error.message}`
- : `MCP server ${name} failed to start`
- log.error("local mcp startup failed", {
- name,
- command: mcp.command,
- error: error instanceof Error ? error.message : String(error),
+ })
+ .then((client) => {
+ mcpClient = client
+ status = {
+ status: "connected",
+ }
})
- Bus.publish(Session.Event.Error, {
- error: {
- name: "UnknownError",
- data: {
- message: errorMessage,
- },
- },
+ .catch((error) => {
+ log.error("local mcp startup failed", {
+ key,
+ command: mcp.command,
+ error: error instanceof Error ? error.message : String(error),
+ })
+ status = {
+ status: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ }
})
- return null
- })
- if (client) {
- mcpClient = client
+ }
+
+ if (!status) {
+ status = {
+ status: "failed",
+ error: "Unknown error",
}
}
if (!mcpClient) {
- log.warn("mcp client not initialized", { name })
- return
+ return {
+ mcpClient: undefined,
+ status,
+ }
}
- const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => { })
+ const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
if (!result) {
- log.warn("mcp client verification failed, dropping client", { name })
- return
+ await mcpClient.close()
+ status = {
+ status: "failed",
+ error: "Failed to get tools",
+ }
+ return {
+ mcpClient: undefined,
+ status,
+ }
}
return {
- client: mcpClient,
+ mcpClient,
+ status,
}
}
export async function status() {
- return state().then((state) => {
- const result: Record<string, "connected" | "failed" | "disabled"> = {}
- for (const [key, client] of Object.entries(state.config)) {
- if (client.enabled === false) {
- result[key] = "disabled"
- continue
- }
- if (state.clients[key]) {
- result[key] = "connected"
- continue
- }
- result[key] = "failed"
- }
- return result
- })
+ return state().then((state) => state.status)
}
export async function clients() {
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index a36da6e81..eb541049d 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -41,7 +41,11 @@ export namespace Permission {
Updated: Bus.event("permission.updated", Info),
Replied: Bus.event(
"permission.replied",
- z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
+ z.object({
+ sessionID: z.string(),
+ permissionID: z.string(),
+ response: z.string(),
+ }),
),
}
@@ -141,16 +145,16 @@ export namespace Permission {
const match = pending[input.sessionID]?.[input.permissionID]
if (!match) return
delete pending[input.sessionID][input.permissionID]
- if (input.response === "reject") {
- match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
- return
- }
- match.resolve()
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
+ if (input.response === "reject") {
+ match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
+ return
+ }
+ match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
const approveKeys = toKeys(match.info.pattern, match.info.type)
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index d8ecaf6ad..0d66b469f 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -14,6 +14,7 @@ export namespace Plugin {
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
+ // @ts-ignore - fetch type incompatibility
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 01ea87a3c..3b99a5fa1 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -1,3 +1,4 @@
+import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
@@ -42,6 +43,15 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
+ Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
},
+ async disposeAll() {
+ for (const [_key, value] of cache) {
+ await context.provide(value, async () => {
+ await Instance.dispose()
+ })
+ }
+ cache.clear()
+ },
}
diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts
index 6377833eb..908ae316b 100644
--- a/packages/opencode/src/project/state.ts
+++ b/packages/opencode/src/project/state.ts
@@ -9,7 +9,11 @@ export namespace State {
const log = Log.create({ service: "state" })
const recordsByKey = new Map<string, Map<any, Entry>>()
- export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
+ export function create<S>(
+ root: () => string,
+ init: () => S,
+ dispose?: (state: Awaited<S>) => Promise<void>,
+ ) {
return () => {
const key = root()
let entries = recordsByKey.get(key)
@@ -57,9 +61,8 @@ export namespace State {
tasks.push(task)
}
-
+ entries.delete(key)
await Promise.all(tasks)
-
disposalFinished = true
log.info("state disposal completed", { key })
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 550312c44..deb3ed493 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1,6 +1,12 @@
import { Log } from "../util/log"
import { Bus } from "../bus"
-import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
+import {
+ describeRoute,
+ generateSpecs,
+ validator,
+ resolver,
+ openAPIRouteHandler,
+} from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
import { stream, streamSSE } from "hono/streaming"
@@ -15,7 +21,7 @@ import { Config } from "../config/config"
import { File } from "../file"
import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
-import { callTui, TuiRoute } from "./tui"
+import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance"
import { Agent } from "../agent/agent"
@@ -35,6 +41,7 @@ import { InstanceBootstrap } from "../project/bootstrap"
import { MCP } from "../mcp"
import { Storage } from "../storage/storage"
import type { ContentfulStatusCode } from "hono/utils/http-status"
+import { TuiEvent } from "@/cli/cmd/tui/event"
import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
@@ -248,7 +255,9 @@ export namespace Server {
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
- parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+ parameters: (t.parameters as any)?._def
+ ? zodToJsonSchema(t.parameters as any)
+ : t.parameters,
})),
)
},
@@ -446,7 +455,11 @@ export namespace Server {
}),
),
async (c) => {
- await Session.remove(c.req.valid("param").id)
+ const sessionID = c.req.valid("param").id
+ await Session.remove(sessionID)
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "session.list",
+ })
return c.json(true)
},
)
@@ -1033,7 +1046,10 @@ export namespace Server {
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
return c.json({
providers: Object.values(providers),
- default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+ default: mapValues(
+ providers,
+ (item) => Provider.sort(Object.values(item.models))[0].id,
+ ),
})
},
)
@@ -1290,7 +1306,7 @@ export namespace Server {
description: "MCP server status",
content: {
"application/json": {
- schema: resolver(z.any()),
+ schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
@@ -1300,6 +1316,26 @@ export namespace Server {
return c.json(await MCP.status())
},
)
+ .get(
+ "/lsp",
+ describeRoute({
+ description: "Get LSP server status",
+ operationId: "lsp.status",
+ responses: {
+ 200: {
+ description: "LSP server status",
+ content: {
+ "application/json": {
+ schema: resolver(LSP.Status.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await LSP.status())
+ },
+ )
.post(
"/tui/append-prompt",
describeRoute({
@@ -1317,13 +1353,11 @@ export namespace Server {
...errors(400),
},
}),
- validator(
- "json",
- z.object({
- text: z.string(),
- }),
- ),
- async (c) => c.json(await callTui(c)),
+ validator("json", TuiEvent.PromptAppend.properties),
+ async (c) => {
+ await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
+ return c.json(true)
+ },
)
.post(
"/tui/open-help",
@@ -1341,7 +1375,10 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ // TODO: open dialog
+ return c.json(true)
+ },
)
.post(
"/tui/open-sessions",
@@ -1359,7 +1396,12 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "session.list",
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/open-themes",
@@ -1377,7 +1419,12 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "session.list",
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/open-models",
@@ -1395,7 +1442,12 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "model.list",
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/submit-prompt",
@@ -1413,7 +1465,12 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "prompt.submit",
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/clear-prompt",
@@ -1431,7 +1488,12 @@ export namespace Server {
},
},
}),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ await Bus.publish(TuiEvent.CommandExecute, {
+ command: "prompt.clear",
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/execute-command",
@@ -1450,13 +1512,27 @@ export namespace Server {
...errors(400),
},
}),
- validator(
- "json",
- z.object({
- command: z.string(),
- }),
- ),
- async (c) => c.json(await callTui(c)),
+ validator("json", z.object({ command: z.string() })),
+ async (c) => {
+ const command = c.req.valid("json").command
+ await Bus.publish(TuiEvent.CommandExecute, {
+ // @ts-expect-error
+ command: {
+ session_new: "session.new",
+ session_share: "session.share",
+ session_interrupt: "session.interrupt",
+ session_compact: "session.compact",
+ messages_page_up: "session.page.up",
+ messages_page_down: "session.page.down",
+ messages_half_page_up: "session.half.page.up",
+ messages_half_page_down: "session.half.page.down",
+ messages_first: "session.first",
+ messages_last: "session.last",
+ agent_cycle: "agent.cycle",
+ }[command],
+ })
+ return c.json(true)
+ },
)
.post(
"/tui/show-toast",
@@ -1474,15 +1550,52 @@ export namespace Server {
},
},
}),
+ validator("json", TuiEvent.ToastShow.properties),
+ async (c) => {
+ await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
+ return c.json(true)
+ },
+ )
+ .post(
+ "/tui/publish",
+ describeRoute({
+ description: "Publish a TUI event",
+ operationId: "tui.publish",
+ responses: {
+ 200: {
+ description: "Event published successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
validator(
"json",
- z.object({
- title: z.string().optional(),
- message: z.string(),
- variant: z.enum(["info", "success", "warning", "error"]),
- }),
+ z.union(
+ Object.values(TuiEvent).map((def) => {
+ return z
+ .object({
+ type: z.literal(def.type),
+ properties: def.properties,
+ })
+ .meta({
+ ref: "Event" + "." + def.type,
+ })
+ }),
+ ),
),
- async (c) => c.json(await callTui(c)),
+ async (c) => {
+ const evt = c.req.valid("json")
+ await Bus.publish(
+ Object.values(TuiEvent).find((def) => def.type === evt.type)!,
+ evt.properties,
+ )
+ return c.json(true)
+ },
)
.route("/tui/control", TuiRoute)
.put(
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 116a7e39d..cc6351675 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -119,6 +119,7 @@ export namespace SessionCompaction {
cwd: Instance.directory,
root: Instance.worktree,
},
+ summary: true,
cost: 0,
tokens: {
output: 0,
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index e1a5c844f..3b28afe0f 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -182,6 +182,8 @@ export namespace MessageV2 {
export const ToolStatePending = z
.object({
status: z.literal("pending"),
+ input: z.record(z.string(), z.any()),
+ raw: z.string(),
})
.meta({
ref: "ToolStatePending",
@@ -192,7 +194,7 @@ export namespace MessageV2 {
export const ToolStateRunning = z
.object({
status: z.literal("running"),
- input: z.any(),
+ input: z.record(z.string(), z.any()),
title: z.string().optional(),
metadata: z.record(z.string(), z.any()).optional(),
time: z.object({
@@ -433,6 +435,8 @@ export namespace MessageV2 {
if (part.toolInvocation.state === "partial-call") {
return {
status: "pending",
+ input: {},
+ raw: "",
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index b9208f550..2f1505869 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1054,6 +1054,8 @@ export namespace SessionPrompt {
callID: value.id,
state: {
status: "pending",
+ input: {},
+ raw: "",
},
})
toolcalls[value.id] = part as MessageV2.ToolPart
@@ -1302,16 +1304,16 @@ export namespace SessionPrompt {
part.state.status !== "completed" &&
part.state.status !== "error"
) {
- Session.updatePart({
+ await Session.updatePart({
...part,
state: {
+ ...part.state,
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
- input: {},
},
})
}
@@ -1815,6 +1817,12 @@ export namespace SessionPrompt {
content: x,
}),
),
+ {
+ role: "user" as const,
+ content: `
+ The following is the text to summarize:
+ `,
+ },
...MessageV2.toModelMessage([
{
info: {
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index 12b040f6b..10b844d61 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -81,10 +81,15 @@ export namespace SessionSummary {
),
{
role: "user" as const,
- content: textPart?.text ?? "",
+ content: `
+ The following is the text to summarize:
+ <text>
+ ${textPart?.text ?? ""}
+ </text>
+ `,
},
],
- headers:small.info.headers,
+ headers: small.info.headers,
model: small.language,
})
log.info("title", { title: result.text })
@@ -117,9 +122,9 @@ export namespace SessionSummary {
`,
},
],
- headers: small.info.headers
- })
- summary = result.text
+ headers: small.info.headers,
+ }).catch(() => {})
+ if (result) summary = result.text
}
userMsg.summary.body = summary
log.info("body", { body: summary })
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index b7555a644..3173dcac5 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -108,7 +108,8 @@ export namespace SystemPrompt {
const found = Array.from(paths).map((p) =>
Bun.file(p)
.text()
- .catch(() => ""),
+ .catch(() => "")
+ .then((x) => "Instructions from: " + p + "\n" + x),
)
return Promise.all(found).then((result) => result.filter(Boolean))
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index e77bbf8ff..e7b7d7382 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -2,47 +2,40 @@ import z from "zod"
import { spawn } from "child_process"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
-import { Permission } from "../permission"
-import { Filesystem } from "../util/filesystem"
-import { lazy } from "../util/lazy"
import { Log } from "../util/log"
-import { Wildcard } from "../util/wildcard"
-import { $ } from "bun"
import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
+import { lazy } from "@/util/lazy"
+import { Language } from "web-tree-sitter"
+import { Agent } from "@/agent/agent"
+import { $ } from "bun"
+import { Filesystem } from "@/util/filesystem"
+import { Wildcard } from "@/util/wildcard"
+import { Permission } from "@/permission"
const MAX_OUTPUT_LENGTH = 30_000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
const SIGKILL_TIMEOUT_MS = 200
-const log = Log.create({ service: "bash-tool" })
+export const log = Log.create({ service: "bash-tool" })
const parser = lazy(async () => {
- try {
- const { default: Parser } = await import("tree-sitter")
- const Bash = await import("tree-sitter-bash")
- const p = new Parser()
- p.setLanguage(Bash.language as any)
- return p
- } catch (e) {
- const { default: Parser } = await import("web-tree-sitter")
- const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
- with: { type: "wasm" },
- })
- await Parser.init({
- locateFile() {
- return treeWasm
- },
- })
- const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
- with: { type: "wasm" },
- })
- const bashLanguage = await Parser.Language.load(bashWasm)
- const p = new Parser()
- p.setLanguage(bashLanguage)
- return p
- }
+ const { Parser } = await import("web-tree-sitter")
+ const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
+ with: { type: "wasm" },
+ })
+ await Parser.init({
+ locateFile() {
+ return treeWasm
+ },
+ })
+ const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
+ with: { type: "wasm" },
+ })
+ const bashLanguage = await Language.load(bashWasm)
+ const p = new Parser()
+ p.setLanguage(bashLanguage)
+ return p
})
export const BashTool = Tool.define("bash", {
@@ -64,10 +57,14 @@ export const BashTool = Tool.define("bash", {
}
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
const tree = await parser().then((p) => p.parse(params.command))
+ if (!tree) {
+ throw new Error("Failed to parse command")
+ }
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
const askPatterns = new Set<string>()
for (const node of tree.rootNode.descendantsOfType("command")) {
+ if (!node) continue
const command = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index fb7e2fe03..a4b00100f 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -14,8 +14,8 @@ import { Agent } from "../agent/agent"
export const WriteTool = Tool.define("write", {
description: DESCRIPTION,
parameters: z.object({
- filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
+ filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
}),
async execute(params, ctx) {
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
diff --git a/packages/opencode/src/util/binary.ts b/packages/opencode/src/util/binary.ts
new file mode 100644
index 000000000..3d8f61851
--- /dev/null
+++ b/packages/opencode/src/util/binary.ts
@@ -0,0 +1,41 @@
+export namespace Binary {
+ export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
+ let left = 0
+ let right = array.length - 1
+
+ while (left <= right) {
+ const mid = Math.floor((left + right) / 2)
+ const midId = compare(array[mid])
+
+ if (midId === id) {
+ return { found: true, index: mid }
+ } else if (midId < id) {
+ left = mid + 1
+ } else {
+ right = mid - 1
+ }
+ }
+
+ return { found: false, index: left }
+ }
+
+ export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
+ const id = compare(item)
+ let left = 0
+ let right = array.length
+
+ while (left < right) {
+ const mid = Math.floor((left + right) / 2)
+ const midId = compare(array[mid])
+
+ if (midId < id) {
+ left = mid + 1
+ } else {
+ right = mid
+ }
+ }
+
+ array.splice(left, 0, item)
+ return array
+ }
+}
diff --git a/packages/opencode/src/util/eventloop.ts b/packages/opencode/src/util/eventloop.ts
new file mode 100644
index 000000000..87f6eef41
--- /dev/null
+++ b/packages/opencode/src/util/eventloop.ts
@@ -0,0 +1,20 @@
+import { Log } from "./log"
+
+export namespace EventLoop {
+ export async function wait() {
+ return new Promise<void>((resolve) => {
+ const check = () => {
+ const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()]
+ Log.Default.info("eventloop", {
+ active,
+ })
+ if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) {
+ resolve()
+ } else {
+ setImmediate(check)
+ }
+ }
+ check()
+ })
+ }
+}
diff --git a/packages/opencode/src/util/iife.ts b/packages/opencode/src/util/iife.ts
new file mode 100644
index 000000000..ca9ae6c10
--- /dev/null
+++ b/packages/opencode/src/util/iife.ts
@@ -0,0 +1,3 @@
+export function iife<T>(fn: () => T) {
+ return fn()
+}
diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts
new file mode 100644
index 000000000..96619416f
--- /dev/null
+++ b/packages/opencode/src/util/keybind.ts
@@ -0,0 +1,76 @@
+import { isDeepEqual } from "remeda"
+
+export namespace Keybind {
+ export type Info = {
+ ctrl: boolean
+ meta: boolean
+ shift: boolean
+ leader: boolean
+ name: string
+ }
+
+ export function match(a: Info, b: Info): boolean {
+ return isDeepEqual(a, b)
+ }
+
+ export function toString(info: Info): string {
+ const parts: string[] = []
+
+ if (info.ctrl) parts.push("ctrl")
+ if (info.meta) parts.push("alt")
+ if (info.shift) parts.push("shift")
+ if (info.name) {
+ if (info.name === "delete") parts.push("del")
+ else parts.push(info.name)
+ }
+
+ let result = parts.join("+")
+
+ if (info.leader) {
+ result = result ? `<leader> ${result}` : `<leader>`
+ }
+
+ return result
+ }
+
+ export function parse(key: string): Info[] {
+ if (key === "none") return []
+
+ return key.split(",").map((combo) => {
+ // Handle <leader> syntax by replacing with leader+
+ const normalized = combo.replace(/<leader>/g, "leader+")
+ const parts = normalized.toLowerCase().split("+")
+ const info: Info = {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "",
+ }
+
+ for (const part of parts) {
+ switch (part) {
+ case "ctrl":
+ info.ctrl = true
+ break
+ case "alt":
+ case "meta":
+ case "option":
+ info.meta = true
+ break
+ case "shift":
+ info.shift = true
+ break
+ case "leader":
+ info.leader = true
+ break
+ default:
+ info.name = part
+ break
+ }
+ }
+
+ return info
+ })
+ }
+}
diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts
new file mode 100644
index 000000000..ab2623271
--- /dev/null
+++ b/packages/opencode/src/util/locale.ts
@@ -0,0 +1,39 @@
+export namespace Locale {
+ export function titlecase(str: string) {
+ return str.replace(/\b\w/g, (c) => c.toUpperCase())
+ }
+
+ export function time(input: number) {
+ const date = new Date(input)
+ return date.toLocaleTimeString()
+ }
+
+ export function number(num: number): string {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + "M"
+ } else if (num >= 1000) {
+ return (num / 1000).toFixed(1) + "K"
+ }
+ return num.toString()
+ }
+
+ export function truncate(str: string, len: number): string {
+ if (str.length <= len) return str
+ return str.slice(0, len - 1) + "…"
+ }
+
+ export function truncateMiddle(str: string, maxLength: number = 35): string {
+ if (str.length <= maxLength) return str
+
+ const ellipsis = "…"
+ const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
+ const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
+
+ return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
+ }
+
+ export function pluralize(count: number, singular: string, plural: string): string {
+ const template = count === 1 ? singular : plural
+ return template.replace("{}", count.toString())
+ }
+}
diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts
new file mode 100644
index 000000000..57c695c48
--- /dev/null
+++ b/packages/opencode/src/util/rpc.ts
@@ -0,0 +1,42 @@
+export namespace Rpc {
+ type Definition = {
+ [method: string]: (input: any) => any
+ }
+
+ export function listen(rpc: Definition) {
+ onmessage = async (evt) => {
+ const parsed = JSON.parse(evt.data)
+ if (parsed.type === "rpc.request") {
+ const result = await rpc[parsed.method](parsed.input)
+ postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
+ }
+ }
+ }
+
+ export function client<T extends Definition>(target: {
+ postMessage: (data: string) => void | null
+ onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
+ }) {
+ const pending = new Map<number, (result: any) => void>()
+ let id = 0
+ target.onmessage = async (evt) => {
+ const parsed = JSON.parse(evt.data)
+ if (parsed.type === "rpc.result") {
+ const resolve = pending.get(parsed.id)
+ if (resolve) {
+ resolve(parsed.result)
+ pending.delete(parsed.id)
+ }
+ }
+ }
+ return {
+ call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
+ const requestId = id++
+ return new Promise((resolve) => {
+ pending.set(requestId, resolve)
+ target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
+ })
+ },
+ }
+ }
+}
diff --git a/packages/opencode/src/util/signal.ts b/packages/opencode/src/util/signal.ts
new file mode 100644
index 000000000..bc633ecc6
--- /dev/null
+++ b/packages/opencode/src/util/signal.ts
@@ -0,0 +1,12 @@
+export function signal() {
+ let resolve: any
+ const promise = new Promise((r) => (resolve = r))
+ return {
+ trigger() {
+ return resolve()
+ },
+ wait() {
+ return promise
+ },
+ }
+}
diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts
index 0d3e0c917..b7793d261 100644
--- a/packages/opencode/test/fixture/fixture.ts
+++ b/packages/opencode/test/fixture/fixture.ts
@@ -11,7 +11,10 @@ type TmpDirOptions<T> = {
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
await $`mkdir -p ${dirpath}`.quiet()
- if (options?.git) await $`git init`.cwd(dirpath).quiet()
+ if (options?.git) {
+ await $`git init`.cwd(dirpath).quiet()
+ await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
+ }
const extra = await options?.init?.(dirpath)
const result = {
[Symbol.asyncDispose]: async () => {
diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts
new file mode 100644
index 000000000..7ef36f2c5
--- /dev/null
+++ b/packages/opencode/test/keybind.test.ts
@@ -0,0 +1,305 @@
+import { describe, test, expect } from "bun:test"
+import { Keybind } from "../src/util/keybind"
+
+describe("Keybind.toString", () => {
+ test("should convert simple key to string", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
+ expect(Keybind.toString(info)).toBe("f")
+ })
+
+ test("should convert ctrl modifier to string", () => {
+ const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
+ expect(Keybind.toString(info)).toBe("ctrl+x")
+ })
+
+ test("should convert leader key to string", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
+ expect(Keybind.toString(info)).toBe("<leader> f")
+ })
+
+ test("should convert multiple modifiers to string", () => {
+ const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
+ expect(Keybind.toString(info)).toBe("ctrl+alt+g")
+ })
+
+ test("should convert all modifiers to string", () => {
+ const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" }
+ expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift+h")
+ })
+
+ test("should convert shift modifier to string", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: true, leader: false, name: "enter" }
+ expect(Keybind.toString(info)).toBe("shift+enter")
+ })
+
+ test("should convert function key to string", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" }
+ expect(Keybind.toString(info)).toBe("f2")
+ })
+
+ test("should convert special key to string", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "pgup" }
+ expect(Keybind.toString(info)).toBe("pgup")
+ })
+
+ test("should handle empty name", () => {
+ const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" }
+ expect(Keybind.toString(info)).toBe("ctrl")
+ })
+
+ test("should handle only modifiers", () => {
+ const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" }
+ expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift")
+ })
+
+ test("should handle only leader with no other parts", () => {
+ const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" }
+ expect(Keybind.toString(info)).toBe("<leader>")
+ })
+})
+
+describe("Keybind.match", () => {
+ test("should match identical keybinds", () => {
+ const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
+ const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
+ expect(Keybind.match(a, b)).toBe(true)
+ })
+
+ test("should not match different key names", () => {
+ const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
+ const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" }
+ expect(Keybind.match(a, b)).toBe(false)
+ })
+
+ test("should not match different modifiers", () => {
+ const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
+ const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" }
+ expect(Keybind.match(a, b)).toBe(false)
+ })
+
+ test("should match leader keybinds", () => {
+ const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
+ const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
+ expect(Keybind.match(a, b)).toBe(true)
+ })
+
+ test("should not match leader vs non-leader", () => {
+ const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
+ const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
+ expect(Keybind.match(a, b)).toBe(false)
+ })
+
+ test("should match complex keybinds", () => {
+ const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
+ const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
+ expect(Keybind.match(a, b)).toBe(true)
+ })
+
+ test("should not match with one modifier different", () => {
+ const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
+ const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" }
+ expect(Keybind.match(a, b)).toBe(false)
+ })
+
+ test("should match simple key without modifiers", () => {
+ const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
+ const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
+ expect(Keybind.match(a, b)).toBe(true)
+ })
+})
+
+describe("Keybind.parse", () => {
+ test("should parse simple key", () => {
+ const result = Keybind.parse("f")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "f",
+ },
+ ])
+ })
+
+ test("should parse leader key syntax", () => {
+ const result = Keybind.parse("<leader>f")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: true,
+ name: "f",
+ },
+ ])
+ })
+
+ test("should parse ctrl modifier", () => {
+ const result = Keybind.parse("ctrl+x")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "x",
+ },
+ ])
+ })
+
+ test("should parse multiple modifiers", () => {
+ const result = Keybind.parse("ctrl+alt+u")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: true,
+ shift: false,
+ leader: false,
+ name: "u",
+ },
+ ])
+ })
+
+ test("should parse shift modifier", () => {
+ const result = Keybind.parse("shift+f2")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: true,
+ leader: false,
+ name: "f2",
+ },
+ ])
+ })
+
+ test("should parse meta/alt modifier", () => {
+ const result = Keybind.parse("meta+g")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: true,
+ shift: false,
+ leader: false,
+ name: "g",
+ },
+ ])
+ })
+
+ test("should parse leader with modifier", () => {
+ const result = Keybind.parse("<leader>h")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: true,
+ name: "h",
+ },
+ ])
+ })
+
+ test("should parse multiple keybinds separated by comma", () => {
+ const result = Keybind.parse("ctrl+c,<leader>q")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "c",
+ },
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: true,
+ name: "q",
+ },
+ ])
+ })
+
+ test("should parse shift+enter combination", () => {
+ const result = Keybind.parse("shift+enter")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: true,
+ leader: false,
+ name: "enter",
+ },
+ ])
+ })
+
+ test("should parse ctrl+j combination", () => {
+ const result = Keybind.parse("ctrl+j")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "j",
+ },
+ ])
+ })
+
+ test("should handle 'none' value", () => {
+ const result = Keybind.parse("none")
+ expect(result).toEqual([])
+ })
+
+ test("should handle special keys", () => {
+ const result = Keybind.parse("pgup")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "pgup",
+ },
+ ])
+ })
+
+ test("should handle function keys", () => {
+ const result = Keybind.parse("f2")
+ expect(result).toEqual([
+ {
+ ctrl: false,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "f2",
+ },
+ ])
+ })
+
+ test("should handle complex multi-modifier combination", () => {
+ const result = Keybind.parse("ctrl+alt+g")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: true,
+ shift: false,
+ leader: false,
+ name: "g",
+ },
+ ])
+ })
+
+ test("should be case insensitive", () => {
+ const result = Keybind.parse("CTRL+X")
+ expect(result).toEqual([
+ {
+ ctrl: true,
+ meta: false,
+ shift: false,
+ leader: false,
+ name: "x",
+ },
+ ])
+ })
+})
diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts
index 649119dce..a34d7718d 100644
--- a/packages/opencode/test/tool/patch.test.ts
+++ b/packages/opencode/test/tool/patch.test.ts
@@ -21,7 +21,9 @@ describe("tool.patch", () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
- await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
+ await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow(
+ "patchText is required",
+ )
},
})
})
@@ -30,7 +32,9 @@ describe("tool.patch", () => {
await Instance.provide({
directory: "/tmp",
fn: async () => {
- await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
+ await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow(
+ "Failed to parse patch",
+ )
},
})
})
@@ -113,7 +117,9 @@ describe("tool.patch", () => {
// Verify file was created with correct content
const filePath = path.join(fixture.path, "config.js")
const content = await fs.readFile(filePath, "utf-8")
- expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
+ expect(content).toBe(
+ 'const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"',
+ )
},
})
})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 81d90cbab..87d472e55 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
-} \ No newline at end of file
+}
diff --git a/packages/plugin/src/example.ts b/packages/plugin/src/example.ts
index 1e4557a68..94745a37b 100644
--- a/packages/plugin/src/example.ts
+++ b/packages/plugin/src/example.ts
@@ -3,10 +3,9 @@ import { tool } from "./tool"
export const ExamplePlugin: Plugin = async (ctx) => {
return {
- permission: {},
tool: {
mytool: tool({
- description: "This is a custom tool tool",
+ description: "This is a custom tool",
args: {
foo: tool.schema.string().describe("foo"),
},
@@ -15,8 +14,5 @@ export const ExamplePlugin: Plugin = async (ctx) => {
},
}),
},
- async "chat.params"(_input, output) {
- output.topP = 1
- },
}
}
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 50c9394f3..ee1558f6f 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -26,4 +26,4 @@
"publishConfig": {
"directory": "dist"
}
-} \ No newline at end of file
+}
diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts
index db7e56f90..c1fe0f423 100755
--- a/packages/sdk/js/script/build.ts
+++ b/packages/sdk/js/script/build.ts
@@ -10,6 +10,8 @@ import { createClient } from "@hey-api/openapi-ts"
await $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
+await $`rm -rf src/gen`
+
await createClient({
input: "./openapi.json",
output: {
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index 5eb12b0f5..1dcdd8067 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -105,6 +105,8 @@ import type {
AppAgentsResponses,
McpStatusData,
McpStatusResponses,
+ LspStatusData,
+ LspStatusResponses,
TuiAppendPromptData,
TuiAppendPromptResponses,
TuiAppendPromptErrors,
@@ -125,6 +127,9 @@ import type {
TuiExecuteCommandErrors,
TuiShowToastData,
TuiShowToastResponses,
+ TuiPublishData,
+ TuiPublishResponses,
+ TuiPublishErrors,
TuiControlNextData,
TuiControlNextResponses,
TuiControlResponseData,
@@ -754,6 +759,20 @@ class Mcp extends _HeyApiClient {
}
}
+class Lsp extends _HeyApiClient {
+ /**
+ * Get LSP server status
+ */
+ public status<ThrowOnError extends boolean = false>(
+ options?: Options<LspStatusData, ThrowOnError>,
+ ) {
+ return (options?.client ?? this._client).get<LspStatusResponses, unknown, ThrowOnError>({
+ url: "/lsp",
+ ...options,
+ })
+ }
+}
+
class Control extends _HeyApiClient {
/**
* Get the next TUI request from the queue
@@ -916,6 +935,26 @@ class Tui extends _HeyApiClient {
},
})
}
+
+ /**
+ * Publish a TUI event
+ */
+ public publish<ThrowOnError extends boolean = false>(
+ options?: Options<TuiPublishData, ThrowOnError>,
+ ) {
+ return (options?.client ?? this._client).post<
+ TuiPublishResponses,
+ TuiPublishErrors,
+ ThrowOnError
+ >({
+ url: "/tui/publish",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ },
+ })
+ }
control = new Control({ client: this._client })
}
@@ -983,6 +1022,7 @@ export class OpencodeClient extends _HeyApiClient {
file = new File({ client: this._client })
app = new App({ client: this._client })
mcp = new Mcp({ client: this._client })
+ lsp = new Lsp({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
event = new Event({ client: this._client })
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index ae492c9af..046dccf68 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -19,10 +19,6 @@ export type KeybindsConfig = {
*/
leader?: string
/**
- * Show help dialog
- */
- app_help?: string
- /**
* Exit the application
*/
app_exit?: string
@@ -35,17 +31,13 @@ export type KeybindsConfig = {
*/
theme_list?: string
/**
- * Create/update AGENTS.md
- */
- project_init?: string
- /**
- * Toggle tool details
+ * Toggle sidebar
*/
- tool_details?: string
+ sidebar_toggle?: string
/**
- * Toggle thinking blocks
+ * View status
*/
- thinking_blocks?: string
+ status_view?: string
/**
* Export session to editor
*/
@@ -79,14 +71,6 @@ export type KeybindsConfig = {
*/
session_compact?: string
/**
- * Cycle to next child session
- */
- session_child_cycle?: string
- /**
- * Cycle to previous child session
- */
- session_child_cycle_reverse?: string
- /**
* Scroll messages up by one page
*/
messages_page_up?: string
@@ -127,13 +111,9 @@ export type KeybindsConfig = {
*/
model_list?: string
/**
- * Next recent model
- */
- model_cycle_recent?: string
- /**
- * Previous recent model
+ * List available commands
*/
- model_cycle_recent_reverse?: string
+ command_list?: string
/**
* List agents
*/
@@ -162,54 +142,6 @@ export type KeybindsConfig = {
* Insert newline in input
*/
input_newline?: string
- /**
- * @deprecated use agent_cycle. Next mode
- */
- switch_mode?: string
- /**
- * @deprecated use agent_cycle_reverse. Previous mode
- */
- switch_mode_reverse?: string
- /**
- * @deprecated use agent_cycle. Next agent
- */
- switch_agent?: string
- /**
- * @deprecated use agent_cycle_reverse. Previous agent
- */
- switch_agent_reverse?: string
- /**
- * @deprecated Currently not available. List files
- */
- file_list?: string
- /**
- * @deprecated Close file
- */
- file_close?: string
- /**
- * @deprecated Search file
- */
- file_search?: string
- /**
- * @deprecated Split/unified diff
- */
- file_diff_toggle?: string
- /**
- * @deprecated Navigate to previous message
- */
- messages_previous?: string
- /**
- * @deprecated Navigate to next message
- */
- messages_next?: string
- /**
- * @deprecated Toggle layout
- */
- messages_layout_toggle?: string
- /**
- * @deprecated use messages_undo. Revert message
- */
- messages_revert?: string
}
export type AgentConfig = {
@@ -781,11 +713,17 @@ export type FilePart = {
export type ToolStatePending = {
status: "pending"
+ input: {
+ [key: string]: unknown
+ }
+ raw: string
}
export type ToolStateRunning = {
status: "running"
- input: unknown
+ input: {
+ [key: string]: unknown
+ }
title?: string
metadata?: {
[key: string]: unknown
@@ -1086,6 +1024,72 @@ export type Agent = {
}
}
+export type McpStatusConnected = {
+ status: "connected"
+}
+
+export type McpStatusDisabled = {
+ status: "disabled"
+}
+
+export type McpStatusFailed = {
+ status: "failed"
+ error: string
+}
+
+export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed
+
+export type LspStatus = {
+ id: string
+ name: string
+ root: string
+ status: "connected" | "error"
+}
+
+export type EventTuiPromptAppend = {
+ type: "tui.prompt.append"
+ properties: {
+ text: string
+ }
+}
+
+export type EventTuiCommandExecute = {
+ type: "tui.command.execute"
+ properties: {
+ command:
+ | (
+ | "session.list"
+ | "session.new"
+ | "session.share"
+ | "session.interrupt"
+ | "session.compact"
+ | "session.page.up"
+ | "session.page.down"
+ | "session.half.page.up"
+ | "session.half.page.down"
+ | "session.first"
+ | "session.last"
+ | "prompt.clear"
+ | "prompt.submit"
+ | "agent.cycle"
+ )
+ | string
+ }
+}
+
+export type EventTuiToastShow = {
+ type: "tui.toast.show"
+ properties: {
+ title?: string
+ message: string
+ variant: "info" | "success" | "warning" | "error"
+ /**
+ * Duration in milliseconds
+ */
+ duration?: number
+ }
+}
+
export type OAuth = {
type: "oauth"
refresh: string
@@ -1121,6 +1125,13 @@ export type EventLspClientDiagnostics = {
}
}
+export type EventLspUpdated = {
+ type: "lsp.updated"
+ properties: {
+ [key: string]: unknown
+ }
+}
+
export type EventMessageUpdated = {
type: "message.updated"
properties: {
@@ -1261,16 +1272,10 @@ export type EventServerConnected = {
}
}
-export type EventIdeInstalled = {
- type: "ide.installed"
- properties: {
- ide: string
- }
-}
-
export type Event =
| EventInstallationUpdated
| EventLspClientDiagnostics
+ | EventLspUpdated
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -1286,8 +1291,10 @@ export type Event =
| EventSessionUpdated
| EventSessionDeleted
| EventSessionError
+ | EventTuiPromptAppend
+ | EventTuiCommandExecute
+ | EventTuiToastShow
| EventServerConnected
- | EventIdeInstalled
export type ProjectListData = {
body?: never
@@ -2455,9 +2462,31 @@ export type McpStatusResponses = {
/**
* MCP server status
*/
- 200: unknown
+ 200: {
+ [key: string]: McpStatus
+ }
+}
+
+export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]
+
+export type LspStatusData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/lsp"
+}
+
+export type LspStatusResponses = {
+ /**
+ * LSP server status
+ */
+ 200: Array<LspStatus>
}
+export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
+
export type TuiAppendPromptData = {
body?: {
text: string
@@ -2629,6 +2658,10 @@ export type TuiShowToastData = {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
+ /**
+ * Duration in milliseconds
+ */
+ duration?: number
}
path?: never
query?: {
@@ -2646,6 +2679,33 @@ export type TuiShowToastResponses = {
export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]
+export type TuiPublishData = {
+ body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/tui/publish"
+}
+
+export type TuiPublishErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]
+
+export type TuiPublishResponses = {
+ /**
+ * Event published successfully
+ */
+ 200: boolean
+}
+
+export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]
+
export type TuiControlNextData = {
body?: never
path?: never
diff --git a/packages/sdk/js/tsconfig.json b/packages/sdk/js/tsconfig.json
index 117381878..1e77472c4 100644
--- a/packages/sdk/js/tsconfig.json
+++ b/packages/sdk/js/tsconfig.json
@@ -8,5 +8,6 @@
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"]
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/gen"]
}
diff --git a/packages/tui/internal/theme/themes/vesper.json b/packages/tui/internal/theme/themes/vesper.json
index cb19ff178..758c8f20c 100644
--- a/packages/tui/internal/theme/themes/vesper.json
+++ b/packages/tui/internal/theme/themes/vesper.json
@@ -3,7 +3,7 @@
"defs": {
"vesperBg": "#101010",
"vesperFg": "#FFF",
- "vesperComment": "#8b8b8b94",
+ "vesperComment": "#8b8b8b",
"vesperKeyword": "#A0A0A0",
"vesperFunction": "#FFC799",
"vesperString": "#99FFE4",
diff --git a/packages/web/src/components/share/content-diff.tsx b/packages/web/src/components/share/content-diff.tsx
index 45249e0cd..9ccd554d0 100644
--- a/packages/web/src/components/share/content-diff.tsx
+++ b/packages/web/src/components/share/content-diff.tsx
@@ -110,7 +110,10 @@ export function ContentDiff(props: Props) {
})
const mobileRows = createMemo(() => {
- const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = []
+ const mobileBlocks: {
+ type: "removed" | "added" | "unchanged"
+ lines: string[]
+ }[] = []
const currentRows = rows()
let i = 0
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx
index ddef206ba..f7a6a9304 100644
--- a/packages/web/src/components/share/part.tsx
+++ b/packages/web/src/components/share/part.tsx
@@ -174,6 +174,12 @@ export function Part(props: PartProps) {
<div data-slot="filename">{props.part.filename}</div>
</div>
)}
+ {props.message.role === "user" && props.part.type === "file" && (
+ <div data-component="attachment">
+ <div data-slot="copy">Attachment</div>
+ <div data-slot="filename">{props.part.filename}</div>
+ </div>
+ )}
{props.part.type === "step-start" && props.message.role === "assistant" && (
<div data-component="step-start">
<div data-slot="provider">{props.message.providerID}</div>