Dax 3 месяцев назад
Родитель
Сommit
96bdeb3c7b
100 измененных файлов с 8225 добавлено и 624 удалено
  1. 1 1
      .husky/pre-push
  2. 3 0
      .opencode/command/commit.md
  3. 53 0
      CHANGES.md
  4. 276 150
      bun.lock
  5. 15 0
      logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json
  6. 48 0
      logs/mcp-puppeteer-2025-10-07.log
  7. 15 1
      opencode.json
  8. 5 2
      package.json
  9. 4 2
      packages/console/app/package.json
  10. 1 1
      packages/console/core/package.json
  11. 13 1
      packages/desktop/src/context/sync.tsx
  12. 16 4
      packages/function/src/api.ts
  13. 2 0
      packages/opencode/bunfig.toml
  14. 15 5
      packages/opencode/package.json
  15. 207 0
      packages/opencode/parsers-config.ts
  16. 23 13
      packages/opencode/script/build.ts
  17. 2 2
      packages/opencode/script/publish.ts
  18. 4 1
      packages/opencode/src/bun/index.ts
  19. 0 65
      packages/opencode/src/cli/cmd/attach.ts
  20. 1 1
      packages/opencode/src/cli/cmd/auth.ts
  21. 1 1
      packages/opencode/src/cli/cmd/github.ts
  22. 0 0
      packages/opencode/src/cli/cmd/opentui/opentui.ts
  23. 327 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  24. 22 0
      packages/opencode/src/cli/cmd/tui/attach.ts
  25. 16 0
      packages/opencode/src/cli/cmd/tui/component/border.tsx
  26. 31 0
      packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
  27. 96 0
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  28. 74 0
      packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
  29. 80 0
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  30. 78 0
      packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
  31. 46 0
      packages/opencode/src/cli/cmd/tui/component/dialog-tag.tsx
  32. 52 0
      packages/opencode/src/cli/cmd/tui/component/dialog-theme-list.tsx
  33. 29 0
      packages/opencode/src/cli/cmd/tui/component/logo.tsx
  34. 403 0
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  35. 78 0
      packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
  36. 703 0
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  37. 14 0
      packages/opencode/src/cli/cmd/tui/context/exit.tsx
  38. 25 0
      packages/opencode/src/cli/cmd/tui/context/helper.tsx
  39. 103 0
      packages/opencode/src/cli/cmd/tui/context/keybind.tsx
  40. 276 0
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  41. 46 0
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  42. 37 0
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  43. 270 0
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  44. 658 0
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  45. 39 0
      packages/opencode/src/cli/cmd/tui/event.ts
  46. 83 0
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  47. 56 0
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
  48. 37 0
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
  49. 81 0
      packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
  50. 1270 0
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  51. 175 0
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  52. 57 0
      packages/opencode/src/cli/cmd/tui/spawn.ts
  53. 105 0
      packages/opencode/src/cli/cmd/tui/thread.ts
  54. 55 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx
  55. 79 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
  56. 39 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
  57. 275 0
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  58. 171 0
      packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
  59. 56 0
      packages/opencode/src/cli/cmd/tui/ui/shimmer.tsx
  60. 83 0
      packages/opencode/src/cli/cmd/tui/ui/toast.tsx
  61. 127 0
      packages/opencode/src/cli/cmd/tui/util/clipboard.ts
  62. 31 0
      packages/opencode/src/cli/cmd/tui/util/editor.ts
  63. 48 0
      packages/opencode/src/cli/cmd/tui/worker.ts
  64. 17 0
      packages/opencode/src/cli/upgrade.ts
  65. 32 103
      packages/opencode/src/config/config.ts
  66. 3 1
      packages/opencode/src/file/index.ts
  67. 8 1
      packages/opencode/src/global/index.ts
  68. 5 3
      packages/opencode/src/index.ts
  69. 4 1
      packages/opencode/src/lsp/client.ts
  70. 36 1
      packages/opencode/src/lsp/index.ts
  71. 2 2
      packages/opencode/src/lsp/server.ts
  72. 113 85
      packages/opencode/src/mcp/index.ts
  73. 10 6
      packages/opencode/src/permission/index.ts
  74. 1 0
      packages/opencode/src/plugin/index.ts
  75. 10 0
      packages/opencode/src/project/instance.ts
  76. 6 3
      packages/opencode/src/project/state.ts
  77. 145 32
      packages/opencode/src/server/server.ts
  78. 1 0
      packages/opencode/src/session/compaction.ts
  79. 5 1
      packages/opencode/src/session/message-v2.ts
  80. 10 2
      packages/opencode/src/session/prompt.ts
  81. 10 5
      packages/opencode/src/session/summary.ts
  82. 2 1
      packages/opencode/src/session/system.ts
  83. 28 31
      packages/opencode/src/tool/bash.ts
  84. 1 1
      packages/opencode/src/tool/write.ts
  85. 41 0
      packages/opencode/src/util/binary.ts
  86. 20 0
      packages/opencode/src/util/eventloop.ts
  87. 3 0
      packages/opencode/src/util/iife.ts
  88. 76 0
      packages/opencode/src/util/keybind.ts
  89. 39 0
      packages/opencode/src/util/locale.ts
  90. 42 0
      packages/opencode/src/util/rpc.ts
  91. 12 0
      packages/opencode/src/util/signal.ts
  92. 4 1
      packages/opencode/test/fixture/fixture.ts
  93. 305 0
      packages/opencode/test/keybind.test.ts
  94. 9 3
      packages/opencode/test/tool/patch.test.ts
  95. 1 1
      packages/plugin/package.json
  96. 1 5
      packages/plugin/src/example.ts
  97. 1 1
      packages/sdk/js/package.json
  98. 2 0
      packages/sdk/js/script/build.ts
  99. 40 0
      packages/sdk/js/src/gen/sdk.gen.ts
  100. 144 84
      packages/sdk/js/src/gen/types.gen.ts

+ 1 - 1
.husky/pre-push

@@ -1,2 +1,2 @@
 #!/bin/sh
 #!/bin/sh
-bun run typecheck
+bun typecheck

+ 3 - 0
.opencode/command/commit.md

@@ -18,3 +18,6 @@ For anything in the packages/app use the ignore: prefix.
 
 
 prefer to explain WHY something was done from an end user perspective instead of
 prefer to explain WHY something was done from an end user perspective instead of
 WHAT was done.
 WHAT was done.
+
+do not do generic messages like "improvied agent experience" be very specific
+about what user facing changes were made

+ 53 - 0
CHANGES.md

@@ -0,0 +1,53 @@
+# OpenCode 1.0
+
+OpenCode 1.0 is a rewrite of the TUI
+
+We went from the go+bubbletea based TUI which suffered from both performance and capability issues to an in-house
+framework (OpenTUI) written in zig+solidjs.
+
+The new TUI mostly works like the old one as it's connecting to the same
+opencode server.
+
+There are some notable UX changes:
+
+1. The session history is more compressed, only showing the full details of the edit
+   and bash tool.
+
+2. We've added a command bar which almost everything flows through. Can press
+   ctrl+p to bring it up in any context and see everything you can do.
+
+3. Added a session sidebar (can be toggled) with some useful information.
+
+We've also stripped out some functionality that we were not sure if anyone
+actually used - if something important is missing please open an issue and we'll add it back
+quickly.
+
+### Breaking Changes
+
+## Keybinds
+
+### Renamed
+
+- messages_revert -> messages_undo
+- switch_agent -> agent_cycle
+- switch_agent_reverse -> agent_cycle_reverse
+- switch_mode -> agent_cycle
+- switch_mode_reverse -> agent_cycle_reverse
+
+### Removed
+
+- messages_layout_toggle
+- messages_next
+- messages_previous
+- file_diff_toggle
+- file_search
+- file_close
+- file_list
+- app_help
+- project_init
+- tool_details
+- thinking_blocks
+- session_child_cycle
+- session_child_cycle_reverse
+- model_cycle_recent
+- model_cycle_recent_reverse

Разница между файлами не показана из-за своего большого размера
+ 276 - 150
bun.lock


+ 15 - 0
logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json

@@ -0,0 +1,15 @@
+{
+    "keep": {
+        "days": true,
+        "amount": 14
+    },
+    "auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
+    "files": [
+        {
+            "date": 1759827172859,
+            "name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
+            "hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
+        }
+    ],
+    "hashType": "sha256"
+}

+ 48 - 0
logs/mcp-puppeteer-2025-10-07.log

@@ -0,0 +1,48 @@
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
+{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
+{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
+{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
+{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}

+ 15 - 1
opencode.json

@@ -1,3 +1,17 @@
 {
 {
-  "$schema": "https://opencode.ai/config.json"
+  "$schema": "https://opencode.ai/config.json",
+  "plugin": ["opencode-openai-codex-auth"],
+  "mcp": {
+    "weather": {
+      "type": "local",
+      "command": ["bun", "x", "@h1deya/mcp-server-weather"]
+    },
+    "context7": {
+      "type": "remote",
+      "url": "https://mcp.context7.com/mcp",
+      "headers": {
+        "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
+      }
+    }
+  }
 }
 }

+ 5 - 2
package.json

@@ -1,13 +1,15 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "opencode",
   "name": "opencode",
+  "description": "AI-powered development tool",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "packageManager": "[email protected]",
   "packageManager": "[email protected]",
   "scripts": {
   "scripts": {
-    "dev": "bun run packages/opencode/src/index.ts",
+    "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "typecheck": "bun turbo typecheck",
     "typecheck": "bun turbo typecheck",
-    "prepare": "husky"
+    "prepare": "husky",
+    "random": "echo 'Random script'"
   },
   },
   "workspaces": {
   "workspaces": {
     "packages": [
     "packages": [
@@ -19,6 +21,7 @@
     "catalog": {
     "catalog": {
       "@types/bun": "1.3.0",
       "@types/bun": "1.3.0",
       "@hono/zod-validator": "0.4.2",
       "@hono/zod-validator": "0.4.2",
+      "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@kobalte/core": "0.13.11",
       "@types/node": "22.13.9",
       "@types/node": "22.13.9",
       "@tsconfig/node22": "22.0.2",
       "@tsconfig/node22": "22.0.2",

+ 4 - 2
packages/console/app/package.json

@@ -11,9 +11,11 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@ibm/plex": "6.4.1",
     "@ibm/plex": "6.4.1",
-    "@kobalte/core": "catalog:",
-    "@openauthjs/openauth": "0.0.0-20250322224806",
     "@opencode-ai/console-core": "workspace:*",
     "@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:*",
     "@opencode-ai/console-resource": "workspace:*",
     "@solidjs/meta": "^0.29.4",
     "@solidjs/meta": "^0.29.4",
     "@solidjs/router": "^0.15.0",
     "@solidjs/router": "^0.15.0",

+ 1 - 1
packages/console/core/package.json

@@ -14,7 +14,7 @@
     "drizzle-orm": "0.41.0",
     "drizzle-orm": "0.41.0",
     "postgres": "3.4.7",
     "postgres": "3.4.7",
     "stripe": "18.0.0",
     "stripe": "18.0.0",
-    "ulid": "3.0.0",
+    "ulid": "catalog:",
     "zod": "catalog:"
     "zod": "catalog:"
   },
   },
   "exports": {
   "exports": {

+ 13 - 1
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 { createStore, produce, reconcile } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { createMemo } from "solid-js"
 import { Binary } from "@/utils/binary"
 import { Binary } from "@/utils/binary"

+ 16 - 4
packages/function/src/api.ts

@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
 
 
     // Lookup installation
     // Lookup installation
     const octokit = new Octokit({ auth: appAuth.token })
     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
     // 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 })
     return c.json({ token: installationAuth.token })
   })
   })
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
 
 
       // Lookup installation
       // Lookup installation
       const appClient = new Octokit({ auth: appAuth.token })
       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
       // 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 })
       return c.json({ token: installationAuth.token })
     } catch (e: any) {
     } catch (e: any) {

+ 2 - 0
packages/opencode/bunfig.toml

@@ -1,2 +1,4 @@
+preload = ["@opentui/solid/preload"]
+
 [test]
 [test]
 preload = ["./test/preload.ts"]
 preload = ["./test/preload.ts"]

+ 15 - 5
packages/opencode/package.json

@@ -8,7 +8,8 @@
     "typecheck": "tsgo --noEmit",
     "typecheck": "tsgo --noEmit",
     "test": "bun test",
     "test": "bun test",
     "build": "./script/build.ts",
     "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": {
   "bin": {
     "opencode": "./bin/opencode"
     "opencode": "./bin/opencode"
@@ -19,6 +20,7 @@
   "devDependencies": {
   "devDependencies": {
     "@ai-sdk/amazon-bedrock": "2.2.10",
     "@ai-sdk/amazon-bedrock": "2.2.10",
     "@ai-sdk/google-vertex": "3.0.16",
     "@ai-sdk/google-vertex": "3.0.16",
+    "@babel/core": "7.28.4",
     "@octokit/webhooks-types": "7.6.1",
     "@octokit/webhooks-types": "7.6.1",
     "@parcel/watcher-darwin-arm64": "2.5.1",
     "@parcel/watcher-darwin-arm64": "2.5.1",
     "@parcel/watcher-darwin-x64": "2.5.1",
     "@parcel/watcher-darwin-x64": "2.5.1",
@@ -27,12 +29,15 @@
     "@parcel/watcher-win32-x64": "2.5.1",
     "@parcel/watcher-win32-x64": "2.5.1",
     "@standard-schema/spec": "1.0.0",
     "@standard-schema/spec": "1.0.0",
     "@tsconfig/bun": "catalog:",
     "@tsconfig/bun": "catalog:",
+    "@types/babel__core": "7.20.5",
     "@types/bun": "catalog:",
     "@types/bun": "catalog:",
     "@types/turndown": "5.0.5",
     "@types/turndown": "5.0.5",
     "@types/yargs": "17.0.33",
     "@types/yargs": "17.0.33",
     "typescript": "catalog:",
     "typescript": "catalog:",
     "@typescript/native-preview": "catalog:",
     "@typescript/native-preview": "catalog:",
     "vscode-languageserver-types": "3.17.5",
     "vscode-languageserver-types": "3.17.5",
+    "why-is-node-running": "3.2.2",
+    "zod-to-json-schema": "3.24.5",
     "@opencode-ai/script": "workspace:*"
     "@opencode-ai/script": "workspace:*"
   },
   },
   "dependencies": {
   "dependencies": {
@@ -49,12 +54,16 @@
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
+    "@opentui/core": "0.0.0-20251031-fc297165",
+    "@opentui/solid": "0.0.0-20251031-fc297165",
     "@parcel/watcher": "2.5.1",
     "@parcel/watcher": "2.5.1",
+    "@solid-primitives/event-bus": "1.1.2",
     "@pierre/precision-diffs": "catalog:",
     "@pierre/precision-diffs": "catalog:",
     "@standard-schema/spec": "1.0.0",
     "@standard-schema/spec": "1.0.0",
     "@zip.js/zip.js": "2.7.62",
     "@zip.js/zip.js": "2.7.62",
     "ai": "catalog:",
     "ai": "catalog:",
     "chokidar": "4.0.3",
     "chokidar": "4.0.3",
+    "clipboardy": "4.0.0",
     "decimal.js": "10.5.0",
     "decimal.js": "10.5.0",
     "diff": "catalog:",
     "diff": "catalog:",
     "fuzzysort": "3.1.0",
     "fuzzysort": "3.1.0",
@@ -65,13 +74,14 @@
     "jsonc-parser": "3.3.1",
     "jsonc-parser": "3.3.1",
     "minimatch": "10.0.3",
     "minimatch": "10.0.3",
     "open": "10.1.2",
     "open": "10.1.2",
+    "partial-json": "0.1.7",
     "remeda": "catalog:",
     "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",
     "turndown": "7.2.0",
-    "ulid": "3.0.1",
+    "ulid": "catalog:",
     "vscode-jsonrpc": "8.2.1",
     "vscode-jsonrpc": "8.2.1",
-    "web-tree-sitter": "0.22.6",
+    "web-tree-sitter": "0.25.10",
     "xdg-basedir": "5.1.0",
     "xdg-basedir": "5.1.0",
     "yargs": "18.0.0",
     "yargs": "18.0.0",
     "zod": "catalog:",
     "zod": "catalog:",

+ 207 - 0
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",
+        ],
+      },
+    },
+  ],
+}

+ 23 - 13
packages/opencode/script/build.ts

@@ -1,5 +1,9 @@
 #!/usr/bin/env bun
 #!/usr/bin/env bun
+
+import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
 import path from "path"
 import path from "path"
+import fs from "fs"
+import { $ } from "bun"
 import { fileURLToPath } from "url"
 import { fileURLToPath } from "url"
 
 
 const __filename = fileURLToPath(import.meta.url)
 const __filename = fileURLToPath(import.meta.url)
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
 const dir = path.resolve(__dirname, "..")
 const dir = path.resolve(__dirname, "..")
 
 
 process.chdir(dir)
 process.chdir(dir)
-import { $ } from "bun"
 
 
 import pkg from "../package.json"
 import pkg from "../package.json"
 import { Script } from "@opencode-ai/script"
 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"],
   ["windows", "x64"],
   ["linux", "arm64"],
   ["linux", "arm64"],
   ["linux", "x64"],
   ["linux", "x64"],
@@ -28,6 +27,10 @@ const targets = [
   ["darwin", "arm64"],
   ["darwin", "arm64"],
 ]
 ]
 
 
+const targets = singleFlag
+  ? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
+  : allTargets
+
 await $`rm -rf dist`
 await $`rm -rf dist`
 
 
 const binaries: Record<string, string> = {}
 const binaries: Record<string, string> = {}
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
   console.log(`building ${os}-${arch}`)
   console.log(`building ${os}-${arch}`)
   const name = `${pkg.name}-${os}-${arch}`
   const name = `${pkg.name}-${os}-${arch}`
   await $`mkdir -p dist/${name}/bin`
   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" : ""}`
   const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
   await $`mkdir -p ../../node_modules/${watcher}`
   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`
   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({
   await Bun.build({
+    conditions: ["browser"],
+    tsconfig: "./tsconfig.json",
+    plugins: [solidPlugin],
     sourcemap: "external",
     sourcemap: "external",
     compile: {
     compile: {
       target: `bun-${os}-${arch}` as any,
       target: `bun-${os}-${arch}` as any,
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
       execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
       execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
       windows: {},
       windows: {},
     },
     },
-    entrypoints: ["./src/index.ts"],
+    entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
     define: {
     define: {
       OPENCODE_VERSION: `'${Script.version}'`,
       OPENCODE_VERSION: `'${Script.version}'`,
+      OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
       OPENCODE_CHANNEL: `'${Script.channel}'`,
       OPENCODE_CHANNEL: `'${Script.channel}'`,
-      OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
     },
     },
   })
   })
+
   await $`rm -rf ./dist/${name}/bin/tui`
   await $`rm -rf ./dist/${name}/bin/tui`
   await Bun.file(`dist/${name}/package.json`).write(
   await Bun.file(`dist/${name}/package.json`).write(
     JSON.stringify(
     JSON.stringify(

+ 2 - 2
packages/opencode/script/publish.ts

@@ -25,8 +25,8 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
         [pkg.name]: `./bin/${pkg.name}`,
         [pkg.name]: `./bin/${pkg.name}`,
       },
       },
       scripts: {
       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,
       version: Script.version,
       optionalDependencies: binaries,
       optionalDependencies: binaries,

+ 4 - 1
packages/opencode/src/bun/index.ts

@@ -74,7 +74,10 @@ export namespace BunProc {
     // - If .npmrc files exist, Bun will use them automatically
     // - If .npmrc files exist, Bun will use them automatically
     // - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
     // - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
     // - No need to pass --registry flag
     // - 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, {
     await BunProc.run(args, {
       cwd: Global.Path.cache,
       cwd: Global.Path.cache,

+ 0 - 65
packages/opencode/src/cli/cmd/attach.ts

@@ -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
-  },
-})

+ 1 - 1
packages/opencode/src/cli/cmd/auth.ts

@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
         UI.empty()
         UI.empty()
         prompts.intro("Add credential")
         prompts.intro("Add credential")
         if (args.url) {
         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(" ")}\``)
           prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
           const proc = Bun.spawn({
           const proc = Bun.spawn({
             cmd: wellknown.auth.command,
             cmd: wellknown.auth.command,

+ 1 - 1
packages/opencode/src/cli/cmd/github.ts

@@ -1,5 +1,4 @@
 import path from "path"
 import path from "path"
-import { $ } from "bun"
 import { exec } from "child_process"
 import { exec } from "child_process"
 import * as prompts from "@clack/prompts"
 import * as prompts from "@clack/prompts"
 import { map, pipe, sortBy, values } from "remeda"
 import { map, pipe, sortBy, values } from "remeda"
@@ -20,6 +19,7 @@ import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
 import { MessageV2 } from "../../session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
 import { SessionPrompt } from "@/session/prompt"
+import { $ } from "bun"
 
 
 type GitHubAuthor = {
 type GitHubAuthor = {
   login: string
   login: string

+ 0 - 0
packages/opencode/src/cli/cmd/opentui/opentui.ts


+ 327 - 0
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>
+  )
+}

+ 22 - 0
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)
+  },
+})

+ 16 - 0
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: "",
+  },
+}

+ 31 - 0
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()
+      }}
+    />
+  )
+}

+ 96 - 0
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 }))}
+    />
+  )
+}

+ 74 - 0
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 })
+      }}
+    />
+  )
+}

+ 80 - 0
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)
+          },
+        },
+      ]}
+    />
+  )
+}

+ 78 - 0
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>
+  )
+}

+ 46 - 0
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()
+      }}
+    />
+  )
+}

+ 52 - 0
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)
+      }}
+    />
+  )
+}

+ 29 - 0
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>
+  )
+}

+ 403 - 0
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>
+  )
+}

+ 78 - 0
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
+          }),
+        )
+      },
+    }
+  },
+})

+ 703 - 0
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>
+    </>
+  )
+}

+ 14 - 0
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)
+    }
+  },
+})

+ 25 - 0
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
+    },
+  }
+}

+ 103 - 0
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
+  },
+})

+ 276 - 0
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
+  },
+})

+ 46 - 0
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 }>
+}

+ 37 - 0
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 }
+  },
+})

+ 270 - 0
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
+  },
+})

+ 658 - 0
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
+      },
+    }
+  },
+})

+ 39 - 0
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"),
+    }),
+  ),
+}

+ 83 - 0
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>
+  )
+}

+ 56 - 0
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()
+          },
+        },
+      ]}
+    />
+  )
+}

+ 37 - 0
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()} />
+}

+ 81 - 0
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>
+  )
+}

+ 1270 - 0
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
+}

+ 175 - 0
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>
+  )
+}

+ 57 - 0
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)
+  },
+})

+ 105 - 0
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)
+        },
+      })
+    })
+  },
+})

+ 55 - 0
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(),
+    )
+  })
+}

+ 79 - 0
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),
+    )
+  })
+}

+ 39 - 0
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>
+  )
+}

+ 275 - 0
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>
+    </>
+  )
+}

+ 171 - 0
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
+}

+ 56 - 0
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>
+  )
+}

+ 83 - 0
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
+}

+ 127 - 0
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)
+  }
+}

+ 31 - 0
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
+  }
+}

+ 48 - 0
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)

+ 17 - 0
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(() => {})
+}

+ 32 - 103
packages/opencode/src/config/config.ts

@@ -49,7 +49,7 @@ export namespace Config {
     for (const [key, value] of Object.entries(auth)) {
     for (const [key, value] of Object.entries(auth)) {
       if (value.type === "wellknown") {
       if (value.type === "wellknown") {
         process.env[value.key] = value.token
         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 = mergeDeep(
           result,
           result,
           await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
           await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
@@ -108,29 +108,13 @@ export namespace Config {
     if (result.autoshare === true && !result.share) {
     if (result.autoshare === true && !result.share) {
       result.share = "auto"
       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
     // Handle migration from autoshare to share field
     if (result.autoshare === true && !result.share) {
     if (result.autoshare === true && !result.share) {
       result.share = "auto"
       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 {
     return {
       config: result,
       config: result,
@@ -181,7 +165,7 @@ export namespace Config {
       {
       {
         cwd: dir,
         cwd: dir,
       },
       },
-    )
+    ).catch(() => {})
   }
   }
 
 
   const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
   const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
@@ -401,17 +385,11 @@ export namespace Config {
         .optional()
         .optional()
         .default("ctrl+x")
         .default("ctrl+x")
         .describe("Leader key for keybind combinations"),
         .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"),
       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"),
       editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
       theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
       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
       session_export: z
         .string()
         .string()
         .optional()
         .optional()
@@ -424,29 +402,23 @@ export namespace Config {
         .optional()
         .optional()
         .default("<leader>g")
         .default("<leader>g")
         .describe("Show session timeline"),
         .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_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()
         .string()
         .optional()
         .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
       messages_page_up: z
         .string()
         .string()
         .optional()
         .optional()
-        .default("pgup")
+        .default("pageup")
         .describe("Scroll messages up by one page"),
         .describe("Scroll messages up by one page"),
       messages_page_down: z
       messages_page_down: z
         .string()
         .string()
         .optional()
         .optional()
-        .default("pgdown")
+        .default("pagedown")
         .describe("Scroll messages down by one page"),
         .describe("Scroll messages down by one page"),
       messages_half_page_up: z
       messages_half_page_up: z
         .string()
         .string()
@@ -458,22 +430,26 @@ export namespace Config {
         .optional()
         .optional()
         .default("ctrl+alt+d")
         .default("ctrl+alt+d")
         .describe("Scroll messages down by half page"),
         .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
       messages_last: z
         .string()
         .string()
         .optional()
         .optional()
-        .default("ctrl+alt+g")
+        .default("ctrl+alt+g,end")
         .describe("Navigate to last message"),
         .describe("Navigate to last message"),
       messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
       messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
       messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
       messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
       messages_redo: z.string().optional().default("<leader>r").describe("Redo 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()
         .string()
         .optional()
         .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_list: z.string().optional().default("<leader>a").describe("List agents"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),
       agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
       agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
@@ -485,59 +461,6 @@ export namespace Config {
         .optional()
         .optional()
         .default("shift+enter,ctrl+j")
         .default("shift+enter,ctrl+j")
         .describe("Insert newline in input"),
         .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()
     .strict()
     .meta({
     .meta({
@@ -820,7 +743,10 @@ export namespace Config {
               const errMsg = `bad file reference: "${match}"`
               const errMsg = `bad file reference: "${match}"`
               if (error.code === "ENOENT") {
               if (error.code === "ENOENT") {
                 throw new InvalidError(
                 throw new InvalidError(
-                  { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
+                  {
+                    path: configFilepath,
+                    message: errMsg + ` ${resolvedPath} does not exist`,
+                  },
                   { cause: error },
                   { cause: error },
                 )
                 )
               }
               }
@@ -874,7 +800,10 @@ export namespace Config {
       return data
       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(
   export const JsonError = NamedError.create(
     "ConfigJsonError",
     "ConfigJsonError",

+ 3 - 1
packages/opencode/src/file/index.ts

@@ -284,7 +284,9 @@ export namespace File {
     }
     }
     const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
     const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
     const nodes: Node[] = []
     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
       if (exclude.includes(entry.name)) continue
       const fullPath = path.join(resolved, entry.name)
       const fullPath = path.join(resolved, entry.name)
       const relativePath = path.relative(Instance.directory, fullPath)
       const relativePath = path.relative(Instance.directory, fullPath)

+ 8 - 1
packages/opencode/src/global/index.ts

@@ -1,6 +1,7 @@
 import fs from "fs/promises"
 import fs from "fs/promises"
 import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
 import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
 import path from "path"
 import path from "path"
+import os from "os"
 
 
 const app = "opencode"
 const app = "opencode"
 
 
@@ -11,6 +12,7 @@ const state = path.join(xdgState!, app)
 
 
 export namespace Global {
 export namespace Global {
   export const Path = {
   export const Path = {
+    home: os.homedir(),
     data,
     data,
     bin: path.join(data, "bin"),
     bin: path.join(data, "bin"),
     log: path.join(data, "log"),
     log: path.join(data, "log"),
@@ -38,7 +40,12 @@ if (version !== CACHE_VERSION) {
   try {
   try {
     const contents = await fs.readdir(Global.Path.cache)
     const contents = await fs.readdir(Global.Path.cache)
     await Promise.all(
     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) {}
   } catch (e) {}
   await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
   await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)

+ 5 - 3
packages/opencode/src/index.ts

@@ -12,13 +12,14 @@ import { Installation } from "./installation"
 import { NamedError } from "./util/error"
 import { NamedError } from "./util/error"
 import { FormatError } from "./cli/error"
 import { FormatError } from "./cli/error"
 import { ServeCommand } from "./cli/cmd/serve"
 import { ServeCommand } from "./cli/cmd/serve"
-import { TuiCommand } from "./cli/cmd/tui"
 import { DebugCommand } from "./cli/cmd/debug"
 import { DebugCommand } from "./cli/cmd/debug"
 import { StatsCommand } from "./cli/cmd/stats"
 import { StatsCommand } from "./cli/cmd/stats"
 import { McpCommand } from "./cli/cmd/mcp"
 import { McpCommand } from "./cli/cmd/mcp"
 import { GithubCommand } from "./cli/cmd/github"
 import { GithubCommand } from "./cli/cmd/github"
 import { ExportCommand } from "./cli/cmd/export"
 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 { AcpCommand } from "./cli/cmd/acp"
 import { EOL } from "os"
 import { EOL } from "os"
 
 
@@ -69,7 +70,8 @@ const cli = yargs(hideBin(process.argv))
   .usage("\n" + UI.logo())
   .usage("\n" + UI.logo())
   .command(AcpCommand)
   .command(AcpCommand)
   .command(McpCommand)
   .command(McpCommand)
-  .command(TuiCommand)
+  .command(TuiThreadCommand)
+  .command(TuiSpawnCommand)
   .command(AttachCommand)
   .command(AttachCommand)
   .command(RunCommand)
   .command(RunCommand)
   .command(GenerateCommand)
   .command(GenerateCommand)

+ 4 - 1
packages/opencode/src/lsp/client.ts

@@ -139,7 +139,10 @@ export namespace LSPClient {
           if (version !== undefined) {
           if (version !== undefined) {
             const next = version + 1
             const next = version + 1
             files[input.path] = next
             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", {
             await connection.sendNotification("textDocument/didChange", {
               textDocument: {
               textDocument: {
                 uri: `file://` + input.path,
                 uri: `file://` + input.path,

+ 36 - 1
packages/opencode/src/lsp/index.ts

@@ -6,10 +6,15 @@ import z from "zod"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
 import { spawn } from "child_process"
 import { spawn } from "child_process"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
+import { Bus } from "../bus"
 
 
 export namespace LSP {
 export namespace LSP {
   const log = Log.create({ service: "lsp" })
   const log = Log.create({ service: "lsp" })
 
 
+  export const Event = {
+    Updated: Bus.event("lsp.updated", z.object({})),
+  }
+
   export const Range = z
   export const Range = z
     .object({
     .object({
       start: z.object({
       start: z.object({
@@ -109,6 +114,33 @@ export namespace LSP {
     return state()
     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) {
   async function getClients(file: string) {
     const s = await state()
     const s = await state()
     const extension = path.parse(file).ext || file
     const extension = path.parse(file).ext || file
@@ -147,12 +179,15 @@ export namespace LSP {
       }).catch((err) => {
       }).catch((err) => {
         s.broken.add(root + server.id)
         s.broken.add(root + server.id)
         handle.process.kill()
         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
         return undefined
       })
       })
       if (!client) continue
       if (!client) continue
       s.clients.push(client)
       s.clients.push(client)
       result.push(client)
       result.push(client)
+      Bus.publish(Event.Updated, {})
     }
     }
     return result
     return result
   }
   }

+ 2 - 2
packages/opencode/src/lsp/server.ts

@@ -467,7 +467,7 @@ export namespace LSPServer {
           return
           return
         }
         }
 
 
-        const release = await releaseResponse.json()
+        const release = (await releaseResponse.json()) as any
 
 
         const platform = process.platform
         const platform = process.platform
         const arch = process.arch
         const arch = process.arch
@@ -660,7 +660,7 @@ export namespace LSPServer {
           return
           return
         }
         }
 
 
-        const release = await releaseResponse.json()
+        const release = (await releaseResponse.json()) as any
 
 
         const platform = process.platform
         const platform = process.platform
         let assetName = ""
         let assetName = ""

+ 113 - 85
packages/opencode/src/mcp/index.ts

@@ -5,9 +5,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { NamedError } from "../util/error"
 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 { Instance } from "../project/instance"
 import { withTimeout } from "@/util/timeout"
 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>>
   type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
 
 
   const state = Instance.state(
   const state = Instance.state(
     async () => {
     async () => {
       const cfg = await Config.get()
       const cfg = await Config.get()
       const config = cfg.mcp ?? {}
       const config = cfg.mcp ?? {}
-      const clients: {
-        [name: string]: MCPClient
-      } = {}
+      const clients: Record<string, Client> = {}
+      const status: Record<string, Status> = {}
 
 
       await Promise.all(
       await Promise.all(
         Object.entries(config).map(async ([key, mcp]) => {
         Object.entries(config).map(async ([key, mcp]) => {
           const result = await create(key, mcp).catch(() => undefined)
           const result = await create(key, mcp).catch(() => undefined)
           if (!result) return
           if (!result) return
-          clients[key] = result.client
+
+          status[key] = result.status
+
+          if (result.mcpClient) {
+            clients[key] = result.mcpClient
+          }
         }),
         }),
       )
       )
-
       return {
       return {
+        status,
         clients,
         clients,
-        config,
       }
       }
     },
     },
     async (state) => {
     async (state) => {
@@ -53,17 +85,22 @@ export namespace MCP {
     const s = await state()
     const s = await state()
     const result = await create(name, mcp)
     const result = await create(name, mcp)
     if (!result) return
     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) {
     if (mcp.enabled === false) {
-      log.info("mcp server disabled", { name })
+      log.info("mcp server disabled", { key })
       return
       return
     }
     }
-    log.info("found", { name, type: mcp.type })
-
+    log.info("found", { key, type: mcp.type })
     let mcpClient: MCPClient | undefined
     let mcpClient: MCPClient | undefined
+    let status: Status | undefined
 
 
     if (mcp.type === "remote") {
     if (mcp.type === "remote") {
       const transports = [
       const transports = [
@@ -86,44 +123,37 @@ export namespace MCP {
       ]
       ]
       let lastError: Error | undefined
       let lastError: Error | undefined
       for (const { name, transport } of transports) {
       for (const { name, transport } of transports) {
-        const client = await experimental_createMCPClient({
+        const result = await experimental_createMCPClient({
           name: "opencode",
           name: "opencode",
           transport,
           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") {
     if (mcp.type === "local") {
       const [cmd, ...args] = mcp.command
       const [cmd, ...args] = mcp.command
-      const client = await experimental_createMCPClient({
+      await experimental_createMCPClient({
         name: "opencode",
         name: "opencode",
         transport: new StdioClientTransport({
         transport: new StdioClientTransport({
           stderr: "ignore",
           stderr: "ignore",
@@ -135,63 +165,61 @@ export namespace MCP {
             ...mcp.environment,
             ...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) {
     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) {
     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 {
     return {
-      client: mcpClient,
+      mcpClient,
+      status,
     }
     }
   }
   }
 
 
   export async function 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() {
   export async function clients() {

+ 10 - 6
packages/opencode/src/permission/index.ts

@@ -41,7 +41,11 @@ export namespace Permission {
     Updated: Bus.event("permission.updated", Info),
     Updated: Bus.event("permission.updated", Info),
     Replied: Bus.event(
     Replied: Bus.event(
       "permission.replied",
       "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]
     const match = pending[input.sessionID]?.[input.permissionID]
     if (!match) return
     if (!match) return
     delete pending[input.sessionID][input.permissionID]
     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, {
     Bus.publish(Event.Replied, {
       sessionID: input.sessionID,
       sessionID: input.sessionID,
       permissionID: input.permissionID,
       permissionID: input.permissionID,
       response: input.response,
       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") {
     if (input.response === "always") {
       approved[input.sessionID] = approved[input.sessionID] || {}
       approved[input.sessionID] = approved[input.sessionID] || {}
       const approveKeys = toKeys(match.info.pattern, match.info.type)
       const approveKeys = toKeys(match.info.pattern, match.info.type)

+ 1 - 0
packages/opencode/src/plugin/index.ts

@@ -14,6 +14,7 @@ export namespace Plugin {
   const state = Instance.state(async () => {
   const state = Instance.state(async () => {
     const client = createOpencodeClient({
     const client = createOpencodeClient({
       baseUrl: "http://localhost:4096",
       baseUrl: "http://localhost:4096",
+      // @ts-ignore - fetch type incompatibility
       fetch: async (...args) => Server.App().fetch(...args),
       fetch: async (...args) => Server.App().fetch(...args),
     })
     })
     const config = await Config.get()
     const config = await Config.get()

+ 10 - 0
packages/opencode/src/project/instance.ts

@@ -1,3 +1,4 @@
+import { Log } from "@/util/log"
 import { Context } from "../util/context"
 import { Context } from "../util/context"
 import { Project } from "./project"
 import { Project } from "./project"
 import { State } from "./state"
 import { State } from "./state"
@@ -42,6 +43,15 @@ export const Instance = {
     return State.create(() => Instance.directory, init, dispose)
     return State.create(() => Instance.directory, init, dispose)
   },
   },
   async dispose() {
   async dispose() {
+    Log.Default.info("disposing instance", { directory: Instance.directory })
     await State.dispose(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()
+  },
 }
 }

+ 6 - 3
packages/opencode/src/project/state.ts

@@ -9,7 +9,11 @@ export namespace State {
   const log = Log.create({ service: "state" })
   const log = Log.create({ service: "state" })
   const recordsByKey = new Map<string, Map<any, Entry>>()
   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 () => {
     return () => {
       const key = root()
       const key = root()
       let entries = recordsByKey.get(key)
       let entries = recordsByKey.get(key)
@@ -57,9 +61,8 @@ export namespace State {
 
 
       tasks.push(task)
       tasks.push(task)
     }
     }
-
+    entries.delete(key)
     await Promise.all(tasks)
     await Promise.all(tasks)
-
     disposalFinished = true
     disposalFinished = true
     log.info("state disposal completed", { key })
     log.info("state disposal completed", { key })
   }
   }

+ 145 - 32
packages/opencode/src/server/server.ts

@@ -1,6 +1,12 @@
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { Bus } from "../bus"
 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 { Hono } from "hono"
 import { cors } from "hono/cors"
 import { cors } from "hono/cors"
 import { stream, streamSSE } from "hono/streaming"
 import { stream, streamSSE } from "hono/streaming"
@@ -15,7 +21,7 @@ import { Config } from "../config/config"
 import { File } from "../file"
 import { File } from "../file"
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
 import { MessageV2 } from "../session/message-v2"
 import { MessageV2 } from "../session/message-v2"
-import { callTui, TuiRoute } from "./tui"
+import { TuiRoute } from "./tui"
 import { Permission } from "../permission"
 import { Permission } from "../permission"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
 import { Agent } from "../agent/agent"
 import { Agent } from "../agent/agent"
@@ -35,6 +41,7 @@ import { InstanceBootstrap } from "../project/bootstrap"
 import { MCP } from "../mcp"
 import { MCP } from "../mcp"
 import { Storage } from "../storage/storage"
 import { Storage } from "../storage/storage"
 import type { ContentfulStatusCode } from "hono/utils/http-status"
 import type { ContentfulStatusCode } from "hono/utils/http-status"
+import { TuiEvent } from "@/cli/cmd/tui/event"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
 import { SessionSummary } from "@/session/summary"
 import { SessionSummary } from "@/session/summary"
 
 
@@ -248,7 +255,9 @@ export namespace Server {
               id: t.id,
               id: t.id,
               description: t.description,
               description: t.description,
               // Handle both Zod schemas and plain JSON schemas
               // 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) => {
         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)
           return c.json(true)
         },
         },
       )
       )
@@ -1033,7 +1046,10 @@ export namespace Server {
           const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
           const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
           return c.json({
           return c.json({
             providers: Object.values(providers),
             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",
               description: "MCP server status",
               content: {
               content: {
                 "application/json": {
                 "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())
           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(
       .post(
         "/tui/append-prompt",
         "/tui/append-prompt",
         describeRoute({
         describeRoute({
@@ -1317,13 +1353,11 @@ export namespace Server {
             ...errors(400),
             ...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(
       .post(
         "/tui/open-help",
         "/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(
       .post(
         "/tui/open-sessions",
         "/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(
       .post(
         "/tui/open-themes",
         "/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(
       .post(
         "/tui/open-models",
         "/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(
       .post(
         "/tui/submit-prompt",
         "/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(
       .post(
         "/tui/clear-prompt",
         "/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(
       .post(
         "/tui/execute-command",
         "/tui/execute-command",
@@ -1450,13 +1512,27 @@ export namespace Server {
             ...errors(400),
             ...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(
       .post(
         "/tui/show-toast",
         "/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(
         validator(
           "json",
           "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)
       .route("/tui/control", TuiRoute)
       .put(
       .put(

+ 1 - 0
packages/opencode/src/session/compaction.ts

@@ -119,6 +119,7 @@ export namespace SessionCompaction {
         cwd: Instance.directory,
         cwd: Instance.directory,
         root: Instance.worktree,
         root: Instance.worktree,
       },
       },
+      summary: true,
       cost: 0,
       cost: 0,
       tokens: {
       tokens: {
         output: 0,
         output: 0,

+ 5 - 1
packages/opencode/src/session/message-v2.ts

@@ -182,6 +182,8 @@ export namespace MessageV2 {
   export const ToolStatePending = z
   export const ToolStatePending = z
     .object({
     .object({
       status: z.literal("pending"),
       status: z.literal("pending"),
+      input: z.record(z.string(), z.any()),
+      raw: z.string(),
     })
     })
     .meta({
     .meta({
       ref: "ToolStatePending",
       ref: "ToolStatePending",
@@ -192,7 +194,7 @@ export namespace MessageV2 {
   export const ToolStateRunning = z
   export const ToolStateRunning = z
     .object({
     .object({
       status: z.literal("running"),
       status: z.literal("running"),
-      input: z.any(),
+      input: z.record(z.string(), z.any()),
       title: z.string().optional(),
       title: z.string().optional(),
       metadata: z.record(z.string(), z.any()).optional(),
       metadata: z.record(z.string(), z.any()).optional(),
       time: z.object({
       time: z.object({
@@ -433,6 +435,8 @@ export namespace MessageV2 {
                 if (part.toolInvocation.state === "partial-call") {
                 if (part.toolInvocation.state === "partial-call") {
                   return {
                   return {
                     status: "pending",
                     status: "pending",
+                    input: {},
+                    raw: "",
                   }
                   }
                 }
                 }
 
 

+ 10 - 2
packages/opencode/src/session/prompt.ts

@@ -1054,6 +1054,8 @@ export namespace SessionPrompt {
                   callID: value.id,
                   callID: value.id,
                   state: {
                   state: {
                     status: "pending",
                     status: "pending",
+                    input: {},
+                    raw: "",
                   },
                   },
                 })
                 })
                 toolcalls[value.id] = part as MessageV2.ToolPart
                 toolcalls[value.id] = part as MessageV2.ToolPart
@@ -1302,16 +1304,16 @@ export namespace SessionPrompt {
             part.state.status !== "completed" &&
             part.state.status !== "completed" &&
             part.state.status !== "error"
             part.state.status !== "error"
           ) {
           ) {
-            Session.updatePart({
+            await Session.updatePart({
               ...part,
               ...part,
               state: {
               state: {
+                ...part.state,
                 status: "error",
                 status: "error",
                 error: "Tool execution aborted",
                 error: "Tool execution aborted",
                 time: {
                 time: {
                   start: Date.now(),
                   start: Date.now(),
                   end: Date.now(),
                   end: Date.now(),
                 },
                 },
-                input: {},
               },
               },
             })
             })
           }
           }
@@ -1815,6 +1817,12 @@ export namespace SessionPrompt {
             content: x,
             content: x,
           }),
           }),
         ),
         ),
+        {
+          role: "user" as const,
+          content: `
+              The following is the text to summarize:
+            `,
+        },
         ...MessageV2.toModelMessage([
         ...MessageV2.toModelMessage([
           {
           {
             info: {
             info: {

+ 10 - 5
packages/opencode/src/session/summary.ts

@@ -81,10 +81,15 @@ export namespace SessionSummary {
           ),
           ),
           {
           {
             role: "user" as const,
             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,
         model: small.language,
       })
       })
       log.info("title", { title: result.text })
       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
       userMsg.summary.body = summary
       log.info("body", { body: summary })
       log.info("body", { body: summary })

+ 2 - 1
packages/opencode/src/session/system.ts

@@ -108,7 +108,8 @@ export namespace SystemPrompt {
     const found = Array.from(paths).map((p) =>
     const found = Array.from(paths).map((p) =>
       Bun.file(p)
       Bun.file(p)
         .text()
         .text()
-        .catch(() => ""),
+        .catch(() => "")
+        .then((x) => "Instructions from: " + p + "\n" + x),
     )
     )
     return Promise.all(found).then((result) => result.filter(Boolean))
     return Promise.all(found).then((result) => result.filter(Boolean))
   }
   }

+ 28 - 31
packages/opencode/src/tool/bash.ts

@@ -2,47 +2,40 @@ import z from "zod"
 import { spawn } from "child_process"
 import { spawn } from "child_process"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
 import DESCRIPTION from "./bash.txt"
 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 { Log } from "../util/log"
-import { Wildcard } from "../util/wildcard"
-import { $ } from "bun"
 import { Instance } from "../project/instance"
 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 MAX_OUTPUT_LENGTH = 30_000
 const DEFAULT_TIMEOUT = 1 * 60 * 1000
 const DEFAULT_TIMEOUT = 1 * 60 * 1000
 const MAX_TIMEOUT = 10 * 60 * 1000
 const MAX_TIMEOUT = 10 * 60 * 1000
 const SIGKILL_TIMEOUT_MS = 200
 const SIGKILL_TIMEOUT_MS = 200
 
 
-const log = Log.create({ service: "bash-tool" })
+export const log = Log.create({ service: "bash-tool" })
 
 
 const parser = lazy(async () => {
 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", {
 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 timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
     const tree = await parser().then((p) => p.parse(params.command))
     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 permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
 
 
     const askPatterns = new Set<string>()
     const askPatterns = new Set<string>()
     for (const node of tree.rootNode.descendantsOfType("command")) {
     for (const node of tree.rootNode.descendantsOfType("command")) {
+      if (!node) continue
       const command = []
       const command = []
       for (let i = 0; i < node.childCount; i++) {
       for (let i = 0; i < node.childCount; i++) {
         const child = node.child(i)
         const child = node.child(i)

+ 1 - 1
packages/opencode/src/tool/write.ts

@@ -14,8 +14,8 @@ import { Agent } from "../agent/agent"
 export const WriteTool = Tool.define("write", {
 export const WriteTool = Tool.define("write", {
   description: DESCRIPTION,
   description: DESCRIPTION,
   parameters: z.object({
   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"),
     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) {
   async execute(params, ctx) {
     const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
     const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)

+ 41 - 0
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
+  }
+}

+ 20 - 0
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()
+    })
+  }
+}

+ 3 - 0
packages/opencode/src/util/iife.ts

@@ -0,0 +1,3 @@
+export function iife<T>(fn: () => T) {
+  return fn()
+}

+ 76 - 0
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
+    })
+  }
+}

+ 39 - 0
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())
+  }
+}

+ 42 - 0
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 }))
+        })
+      },
+    }
+  }
+}

+ 12 - 0
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
+    },
+  }
+}

+ 4 - 1
packages/opencode/test/fixture/fixture.ts

@@ -11,7 +11,10 @@ type TmpDirOptions<T> = {
 export async function tmpdir<T>(options?: TmpDirOptions<T>) {
 export async function tmpdir<T>(options?: TmpDirOptions<T>) {
   const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
   const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
   await $`mkdir -p ${dirpath}`.quiet()
   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 extra = await options?.init?.(dirpath)
   const result = {
   const result = {
     [Symbol.asyncDispose]: async () => {
     [Symbol.asyncDispose]: async () => {

+ 305 - 0
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",
+      },
+    ])
+  })
+})

+ 9 - 3
packages/opencode/test/tool/patch.test.ts

@@ -21,7 +21,9 @@ describe("tool.patch", () => {
     await Instance.provide({
     await Instance.provide({
       directory: "/tmp",
       directory: "/tmp",
       fn: async () => {
       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({
     await Instance.provide({
       directory: "/tmp",
       directory: "/tmp",
       fn: async () => {
       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
         // Verify file was created with correct content
         const filePath = path.join(fixture.path, "config.js")
         const filePath = path.join(fixture.path, "config.js")
         const content = await fs.readFile(filePath, "utf-8")
         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"',
+        )
       },
       },
     })
     })
   })
   })

+ 1 - 1
packages/plugin/package.json

@@ -24,4 +24,4 @@
     "typescript": "catalog:",
     "typescript": "catalog:",
     "@typescript/native-preview": "catalog:"
     "@typescript/native-preview": "catalog:"
   }
   }
-}
+}

+ 1 - 5
packages/plugin/src/example.ts

@@ -3,10 +3,9 @@ import { tool } from "./tool"
 
 
 export const ExamplePlugin: Plugin = async (ctx) => {
 export const ExamplePlugin: Plugin = async (ctx) => {
   return {
   return {
-    permission: {},
     tool: {
     tool: {
       mytool: tool({
       mytool: tool({
-        description: "This is a custom tool tool",
+        description: "This is a custom tool",
         args: {
         args: {
           foo: tool.schema.string().describe("foo"),
           foo: tool.schema.string().describe("foo"),
         },
         },
@@ -15,8 +14,5 @@ export const ExamplePlugin: Plugin = async (ctx) => {
         },
         },
       }),
       }),
     },
     },
-    async "chat.params"(_input, output) {
-      output.topP = 1
-    },
   }
   }
 }
 }

+ 1 - 1
packages/sdk/js/package.json

@@ -26,4 +26,4 @@
   "publishConfig": {
   "publishConfig": {
     "directory": "dist"
     "directory": "dist"
   }
   }
-}
+}

+ 2 - 0
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 $`bun dev generate > ${dir}/openapi.json`.cwd(path.resolve(dir, "../../opencode"))
 
 
+await $`rm -rf src/gen`
+
 await createClient({
 await createClient({
   input: "./openapi.json",
   input: "./openapi.json",
   output: {
   output: {

+ 40 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -105,6 +105,8 @@ import type {
   AppAgentsResponses,
   AppAgentsResponses,
   McpStatusData,
   McpStatusData,
   McpStatusResponses,
   McpStatusResponses,
+  LspStatusData,
+  LspStatusResponses,
   TuiAppendPromptData,
   TuiAppendPromptData,
   TuiAppendPromptResponses,
   TuiAppendPromptResponses,
   TuiAppendPromptErrors,
   TuiAppendPromptErrors,
@@ -125,6 +127,9 @@ import type {
   TuiExecuteCommandErrors,
   TuiExecuteCommandErrors,
   TuiShowToastData,
   TuiShowToastData,
   TuiShowToastResponses,
   TuiShowToastResponses,
+  TuiPublishData,
+  TuiPublishResponses,
+  TuiPublishErrors,
   TuiControlNextData,
   TuiControlNextData,
   TuiControlNextResponses,
   TuiControlNextResponses,
   TuiControlResponseData,
   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 {
 class Control extends _HeyApiClient {
   /**
   /**
    * Get the next TUI request from the queue
    * 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 })
   control = new Control({ client: this._client })
 }
 }
 
 
@@ -983,6 +1022,7 @@ export class OpencodeClient extends _HeyApiClient {
   file = new File({ client: this._client })
   file = new File({ client: this._client })
   app = new App({ client: this._client })
   app = new App({ client: this._client })
   mcp = new Mcp({ client: this._client })
   mcp = new Mcp({ client: this._client })
+  lsp = new Lsp({ client: this._client })
   tui = new Tui({ client: this._client })
   tui = new Tui({ client: this._client })
   auth = new Auth({ client: this._client })
   auth = new Auth({ client: this._client })
   event = new Event({ client: this._client })
   event = new Event({ client: this._client })

+ 144 - 84
packages/sdk/js/src/gen/types.gen.ts

@@ -18,10 +18,6 @@ export type KeybindsConfig = {
    * Leader key for keybind combinations
    * Leader key for keybind combinations
    */
    */
   leader?: string
   leader?: string
-  /**
-   * Show help dialog
-   */
-  app_help?: string
   /**
   /**
    * Exit the application
    * Exit the application
    */
    */
@@ -35,17 +31,13 @@ export type KeybindsConfig = {
    */
    */
   theme_list?: string
   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
    * Export session to editor
    */
    */
@@ -78,14 +70,6 @@ export type KeybindsConfig = {
    * Compact the session
    * Compact the session
    */
    */
   session_compact?: string
   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
    * Scroll messages up by one page
    */
    */
@@ -127,13 +111,9 @@ export type KeybindsConfig = {
    */
    */
   model_list?: string
   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
    * List agents
    */
    */
@@ -162,54 +142,6 @@ export type KeybindsConfig = {
    * Insert newline in input
    * Insert newline in input
    */
    */
   input_newline?: string
   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 = {
 export type AgentConfig = {
@@ -781,11 +713,17 @@ export type FilePart = {
 
 
 export type ToolStatePending = {
 export type ToolStatePending = {
   status: "pending"
   status: "pending"
+  input: {
+    [key: string]: unknown
+  }
+  raw: string
 }
 }
 
 
 export type ToolStateRunning = {
 export type ToolStateRunning = {
   status: "running"
   status: "running"
-  input: unknown
+  input: {
+    [key: string]: unknown
+  }
   title?: string
   title?: string
   metadata?: {
   metadata?: {
     [key: string]: unknown
     [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 = {
 export type OAuth = {
   type: "oauth"
   type: "oauth"
   refresh: string
   refresh: string
@@ -1121,6 +1125,13 @@ export type EventLspClientDiagnostics = {
   }
   }
 }
 }
 
 
+export type EventLspUpdated = {
+  type: "lsp.updated"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
 export type EventMessageUpdated = {
 export type EventMessageUpdated = {
   type: "message.updated"
   type: "message.updated"
   properties: {
   properties: {
@@ -1261,16 +1272,10 @@ export type EventServerConnected = {
   }
   }
 }
 }
 
 
-export type EventIdeInstalled = {
-  type: "ide.installed"
-  properties: {
-    ide: string
-  }
-}
-
 export type Event =
 export type Event =
   | EventInstallationUpdated
   | EventInstallationUpdated
   | EventLspClientDiagnostics
   | EventLspClientDiagnostics
+  | EventLspUpdated
   | EventMessageUpdated
   | EventMessageUpdated
   | EventMessageRemoved
   | EventMessageRemoved
   | EventMessagePartUpdated
   | EventMessagePartUpdated
@@ -1286,8 +1291,10 @@ export type Event =
   | EventSessionUpdated
   | EventSessionUpdated
   | EventSessionDeleted
   | EventSessionDeleted
   | EventSessionError
   | EventSessionError
+  | EventTuiPromptAppend
+  | EventTuiCommandExecute
+  | EventTuiToastShow
   | EventServerConnected
   | EventServerConnected
-  | EventIdeInstalled
 
 
 export type ProjectListData = {
 export type ProjectListData = {
   body?: never
   body?: never
@@ -2455,9 +2462,31 @@ export type McpStatusResponses = {
   /**
   /**
    * MCP server status
    * 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 = {
 export type TuiAppendPromptData = {
   body?: {
   body?: {
     text: string
     text: string
@@ -2629,6 +2658,10 @@ export type TuiShowToastData = {
     title?: string
     title?: string
     message: string
     message: string
     variant: "info" | "success" | "warning" | "error"
     variant: "info" | "success" | "warning" | "error"
+    /**
+     * Duration in milliseconds
+     */
+    duration?: number
   }
   }
   path?: never
   path?: never
   query?: {
   query?: {
@@ -2646,6 +2679,33 @@ export type TuiShowToastResponses = {
 
 
 export type TuiShowToastResponse = TuiShowToastResponses[keyof 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 = {
 export type TuiControlNextData = {
   body?: never
   body?: never
   path?: never
   path?: never

Некоторые файлы не были показаны из-за большого количества измененных файлов