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

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

@@ -11,9 +11,11 @@
   },
   "dependencies": {
     "@ibm/plex": "6.4.1",
-    "@kobalte/core": "catalog:",
-    "@openauthjs/openauth": "0.0.0-20250322224806",
     "@opencode-ai/console-core": "workspace:*",
+    "@opencode-ai/console-mail": "workspace:*",
+    "@openauthjs/openauth": "catalog:",
+    "@kobalte/core": "catalog:",
+    "@jsx-email/render": "1.1.1",
     "@opencode-ai/console-resource": "workspace:*",
     "@solidjs/meta": "^0.29.4",
     "@solidjs/router": "^0.15.0",

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

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

+ 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 { createMemo } from "solid-js"
 import { Binary } from "@/utils/binary"

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

@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
 
     // Lookup installation
     const octokit = new Octokit({ auth: appAuth.token })
-    const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
+    const { data: installation } = await octokit.apps.getRepoInstallation({
+      owner,
+      repo,
+    })
 
     // Get installation token
-    const installationAuth = await auth({ type: "installation", installationId: installation.id })
+    const installationAuth = await auth({
+      type: "installation",
+      installationId: installation.id,
+    })
 
     return c.json({ token: installationAuth.token })
   })
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
 
       // Lookup installation
       const appClient = new Octokit({ auth: appAuth.token })
-      const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
+      const { data: installation } = await appClient.apps.getRepoInstallation({
+        owner,
+        repo,
+      })
 
       // Get installation token
-      const installationAuth = await auth({ type: "installation", installationId: installation.id })
+      const installationAuth = await auth({
+        type: "installation",
+        installationId: installation.id,
+      })
 
       return c.json({ token: installationAuth.token })
     } catch (e: any) {

+ 2 - 0
packages/opencode/bunfig.toml

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

+ 15 - 5
packages/opencode/package.json

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

+ 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
+
+import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
 import path from "path"
+import fs from "fs"
+import { $ } from "bun"
 import { fileURLToPath } from "url"
 
 const __filename = fileURLToPath(import.meta.url)
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
 const dir = path.resolve(__dirname, "..")
 
 process.chdir(dir)
-import { $ } from "bun"
 
 import pkg from "../package.json"
 import { Script } from "@opencode-ai/script"
 
-const GOARCH: Record<string, string> = {
-  arm64: "arm64",
-  x64: "amd64",
-  "x64-baseline": "amd64",
-}
+const singleFlag = process.argv.includes("--single")
 
-const targets = [
+const allTargets = [
   ["windows", "x64"],
   ["linux", "arm64"],
   ["linux", "x64"],
@@ -28,6 +27,10 @@ const targets = [
   ["darwin", "arm64"],
 ]
 
+const targets = singleFlag
+  ? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
+  : allTargets
+
 await $`rm -rf dist`
 
 const binaries: Record<string, string> = {}
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
   console.log(`building ${os}-${arch}`)
   const name = `${pkg.name}-${os}-${arch}`
   await $`mkdir -p dist/${name}/bin`
-  await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${Script.version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
-    .cwd("../tui")
-    .quiet()
+
+  const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
+  await $`mkdir -p ../../node_modules/${opentui}`
+  await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
+  await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
 
   const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
   await $`mkdir -p ../../node_modules/${watcher}`
-  await $`npm pack npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
+  await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
   await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
 
+  const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
   await Bun.build({
+    conditions: ["browser"],
+    tsconfig: "./tsconfig.json",
+    plugins: [solidPlugin],
     sourcemap: "external",
     compile: {
       target: `bun-${os}-${arch}` as any,
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
       execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
       windows: {},
     },
-    entrypoints: ["./src/index.ts"],
+    entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
     define: {
       OPENCODE_VERSION: `'${Script.version}'`,
+      OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
       OPENCODE_CHANNEL: `'${Script.channel}'`,
-      OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
     },
   })
+
   await $`rm -rf ./dist/${name}/bin/tui`
   await Bun.file(`dist/${name}/package.json`).write(
     JSON.stringify(

+ 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}`,
       },
       scripts: {
-        preinstall: "node ./preinstall.mjs",
-        postinstall: "node ./postinstall.mjs",
+        preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
+        postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
       },
       version: Script.version,
       optionalDependencies: binaries,

+ 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 no .npmrc files exist, Bun will default to https://registry.npmjs.org
     // - No need to pass --registry flag
-    log.info("installing package using Bun's default registry resolution", { pkg, version })
+    log.info("installing package using Bun's default registry resolution", {
+      pkg,
+      version,
+    })
 
     await BunProc.run(args, {
       cwd: Global.Path.cache,

+ 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()
         prompts.intro("Add credential")
         if (args.url) {
-          const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
+          const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
           prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
           const proc = Bun.spawn({
             cmd: wellknown.auth.command,

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

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

+ 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)) {
       if (value.type === "wellknown") {
         process.env[value.key] = value.token
-        const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
+        const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
         result = mergeDeep(
           result,
           await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
@@ -108,29 +108,13 @@ export namespace Config {
     if (result.autoshare === true && !result.share) {
       result.share = "auto"
     }
-    if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
-      result.keybinds.messages_undo = result.keybinds.messages_revert
-    }
 
     // Handle migration from autoshare to share field
     if (result.autoshare === true && !result.share) {
       result.share = "auto"
     }
-    if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
-      result.keybinds.messages_undo = result.keybinds.messages_revert
-    }
-    if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
-      result.keybinds.switch_agent = result.keybinds.switch_mode
-    }
-    if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
-      result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
-    }
-    if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
-      result.keybinds.agent_cycle = result.keybinds.switch_agent
-    }
-    if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
-      result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
-    }
+
+    if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
 
     return {
       config: result,
@@ -181,7 +165,7 @@ export namespace Config {
       {
         cwd: dir,
       },
-    )
+    ).catch(() => {})
   }
 
   const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
@@ -401,17 +385,11 @@ export namespace Config {
         .optional()
         .default("ctrl+x")
         .describe("Leader key for keybind combinations"),
-      app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
       app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
       editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
       theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
-      project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
-      tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
-      thinking_blocks: z
-        .string()
-        .optional()
-        .default("<leader>b")
-        .describe("Toggle thinking blocks"),
+      sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
+      status_view: z.string().optional().default("<leader>s").describe("View status"),
       session_export: z
         .string()
         .optional()
@@ -424,29 +402,23 @@ export namespace Config {
         .optional()
         .default("<leader>g")
         .describe("Show session timeline"),
-      session_share: z.string().optional().default("<leader>s").describe("Share current session"),
+      session_share: z.string().optional().default("none").describe("Share current session"),
       session_unshare: z.string().optional().default("none").describe("Unshare current session"),
-      session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
-      session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
-      session_child_cycle: z
-        .string()
-        .optional()
-        .default("ctrl+right")
-        .describe("Cycle to next child session"),
-      session_child_cycle_reverse: z
+      session_interrupt: z
         .string()
         .optional()
-        .default("ctrl+left")
-        .describe("Cycle to previous child session"),
+        .default("escape")
+        .describe("Interrupt current session"),
+      session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
       messages_page_up: z
         .string()
         .optional()
-        .default("pgup")
+        .default("pageup")
         .describe("Scroll messages up by one page"),
       messages_page_down: z
         .string()
         .optional()
-        .default("pgdown")
+        .default("pagedown")
         .describe("Scroll messages down by one page"),
       messages_half_page_up: z
         .string()
@@ -458,22 +430,26 @@ export namespace Config {
         .optional()
         .default("ctrl+alt+d")
         .describe("Scroll messages down by half page"),
-      messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
+      messages_first: z
+        .string()
+        .optional()
+        .default("ctrl+g,home")
+        .describe("Navigate to first message"),
       messages_last: z
         .string()
         .optional()
-        .default("ctrl+alt+g")
+        .default("ctrl+alt+g,end")
         .describe("Navigate to last message"),
       messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
       messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
       messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
-      model_list: z.string().optional().default("<leader>m").describe("List available models"),
-      model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
-      model_cycle_recent_reverse: z
+      messages_toggle_conceal: z
         .string()
         .optional()
-        .default("shift+f2")
-        .describe("Previous recent model"),
+        .default("<leader>h")
+        .describe("Toggle code block concealment in messages"),
+      model_list: z.string().optional().default("<leader>m").describe("List available models"),
+      command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
       agent_list: z.string().optional().default("<leader>a").describe("List agents"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),
       agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
@@ -485,59 +461,6 @@ export namespace Config {
         .optional()
         .default("shift+enter,ctrl+j")
         .describe("Insert newline in input"),
-      // Deprecated commands
-      switch_mode: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated use agent_cycle. Next mode"),
-      switch_mode_reverse: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated use agent_cycle_reverse. Previous mode"),
-      switch_agent: z
-        .string()
-        .optional()
-        .default("tab")
-        .describe("@deprecated use agent_cycle. Next agent"),
-      switch_agent_reverse: z
-        .string()
-        .optional()
-        .default("shift+tab")
-        .describe("@deprecated use agent_cycle_reverse. Previous agent"),
-      file_list: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated Currently not available. List files"),
-      file_close: z.string().optional().default("none").describe("@deprecated Close file"),
-      file_search: z.string().optional().default("none").describe("@deprecated Search file"),
-      file_diff_toggle: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated Split/unified diff"),
-      messages_previous: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated Navigate to previous message"),
-      messages_next: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated Navigate to next message"),
-      messages_layout_toggle: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated Toggle layout"),
-      messages_revert: z
-        .string()
-        .optional()
-        .default("none")
-        .describe("@deprecated use messages_undo. Revert message"),
     })
     .strict()
     .meta({
@@ -820,7 +743,10 @@ export namespace Config {
               const errMsg = `bad file reference: "${match}"`
               if (error.code === "ENOENT") {
                 throw new InvalidError(
-                  { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
+                  {
+                    path: configFilepath,
+                    message: errMsg + ` ${resolvedPath} does not exist`,
+                  },
                   { cause: error },
                 )
               }
@@ -874,7 +800,10 @@ export namespace Config {
       return data
     }
 
-    throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
+    throw new InvalidError({
+      path: configFilepath,
+      issues: parsed.error.issues,
+    })
   }
   export const JsonError = NamedError.create(
     "ConfigJsonError",

+ 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 nodes: Node[] = []
-    for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
+    for (const entry of await fs.promises.readdir(resolved, {
+      withFileTypes: true,
+    })) {
       if (exclude.includes(entry.name)) continue
       const fullPath = path.join(resolved, entry.name)
       const relativePath = path.relative(Instance.directory, fullPath)

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

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

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

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

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

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

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

@@ -6,10 +6,15 @@ import z from "zod"
 import { Config } from "../config/config"
 import { spawn } from "child_process"
 import { Instance } from "../project/instance"
+import { Bus } from "../bus"
 
 export namespace LSP {
   const log = Log.create({ service: "lsp" })
 
+  export const Event = {
+    Updated: Bus.event("lsp.updated", z.object({})),
+  }
+
   export const Range = z
     .object({
       start: z.object({
@@ -109,6 +114,33 @@ export namespace LSP {
     return state()
   }
 
+  export const Status = z
+    .object({
+      id: z.string(),
+      name: z.string(),
+      root: z.string(),
+      status: z.union([z.literal("connected"), z.literal("error")]),
+    })
+    .meta({
+      ref: "LSPStatus",
+    })
+  export type Status = z.infer<typeof Status>
+
+  export async function status() {
+    return state().then((x) => {
+      const result: Status[] = []
+      for (const client of x.clients) {
+        result.push({
+          id: client.serverID,
+          name: x.servers[client.serverID].id,
+          root: path.relative(Instance.directory, client.root),
+          status: "connected",
+        })
+      }
+      return result
+    })
+  }
+
   async function getClients(file: string) {
     const s = await state()
     const extension = path.parse(file).ext || file
@@ -147,12 +179,15 @@ export namespace LSP {
       }).catch((err) => {
         s.broken.add(root + server.id)
         handle.process.kill()
-        log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
+        log.error(`Failed to initialize LSP client ${server.id}`, {
+          error: err,
+        })
         return undefined
       })
       if (!client) continue
       s.clients.push(client)
       result.push(client)
+      Bus.publish(Event.Updated, {})
     }
     return result
   }

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

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

+ 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 { Log } from "../util/log"
 import { NamedError } from "../util/error"
-import z from "zod"
-import { Session } from "../session"
-import { Bus } from "../bus"
+import z from "zod/v4"
 import { Instance } from "../project/instance"
 import { withTimeout } from "@/util/timeout"
 
@@ -21,27 +19,61 @@ export namespace MCP {
     }),
   )
 
+  type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
+
+  export const Status = z
+    .discriminatedUnion("status", [
+      z
+        .object({
+          status: z.literal("connected"),
+        })
+        .meta({
+          ref: "MCPStatusConnected",
+        }),
+      z
+        .object({
+          status: z.literal("disabled"),
+        })
+        .meta({
+          ref: "MCPStatusDisabled",
+        }),
+      z
+        .object({
+          status: z.literal("failed"),
+          error: z.string(),
+        })
+        .meta({
+          ref: "MCPStatusFailed",
+        }),
+    ])
+    .meta({
+      ref: "MCPStatus",
+    })
+  export type Status = z.infer<typeof Status>
   type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
 
   const state = Instance.state(
     async () => {
       const cfg = await Config.get()
       const config = cfg.mcp ?? {}
-      const clients: {
-        [name: string]: MCPClient
-      } = {}
+      const clients: Record<string, Client> = {}
+      const status: Record<string, Status> = {}
 
       await Promise.all(
         Object.entries(config).map(async ([key, mcp]) => {
           const result = await create(key, mcp).catch(() => undefined)
           if (!result) return
-          clients[key] = result.client
+
+          status[key] = result.status
+
+          if (result.mcpClient) {
+            clients[key] = result.mcpClient
+          }
         }),
       )
-
       return {
+        status,
         clients,
-        config,
       }
     },
     async (state) => {
@@ -53,17 +85,22 @@ export namespace MCP {
     const s = await state()
     const result = await create(name, mcp)
     if (!result) return
-    s.clients[name] = result.client
+    if (!result.mcpClient) {
+      s.status[name] = result.status
+      return
+    }
+    s.clients[name] = result.mcpClient
+    s.status[name] = result.status
   }
 
-  async function create(name: string, mcp: Config.Mcp) {
+  async function create(key: string, mcp: Config.Mcp) {
     if (mcp.enabled === false) {
-      log.info("mcp server disabled", { name })
+      log.info("mcp server disabled", { key })
       return
     }
-    log.info("found", { name, type: mcp.type })
-
+    log.info("found", { key, type: mcp.type })
     let mcpClient: MCPClient | undefined
+    let status: Status | undefined
 
     if (mcp.type === "remote") {
       const transports = [
@@ -86,44 +123,37 @@ export namespace MCP {
       ]
       let lastError: Error | undefined
       for (const { name, transport } of transports) {
-        const client = await experimental_createMCPClient({
+        const result = await experimental_createMCPClient({
           name: "opencode",
           transport,
-        }).catch((error) => {
-          lastError = error instanceof Error ? error : new Error(String(error))
-          log.debug("transport connection failed", {
-            name,
-            transport: name,
-            url: mcp.url,
-            error: lastError.message,
-          })
-          return null
-        })
-        if (client) {
-          log.debug("transport connection succeeded", { name, transport: name })
-          mcpClient = client
-          break
-        }
-      }
-      if (!mcpClient) {
-        const errorMessage = lastError
-          ? `MCP server ${name} failed to connect: ${lastError.message}`
-          : `MCP server ${name} failed to connect to ${mcp.url}`
-        log.error("remote mcp connection failed", { name, url: mcp.url, error: lastError?.message })
-        Bus.publish(Session.Event.Error, {
-          error: {
-            name: "UnknownError",
-            data: {
-              message: errorMessage,
-            },
-          },
         })
+          .then((client) => {
+            log.info("connected", { key, transport: name })
+            mcpClient = client
+            status = { status: "connected" }
+            return true
+          })
+          .catch((error) => {
+            lastError = error instanceof Error ? error : new Error(String(error))
+            log.debug("transport connection failed", {
+              key,
+              transport: name,
+              url: mcp.url,
+              error: lastError.message,
+            })
+            status = {
+              status: "failed",
+              error: lastError.message,
+            }
+            return false
+          })
+        if (result) break
       }
     }
 
     if (mcp.type === "local") {
       const [cmd, ...args] = mcp.command
-      const client = await experimental_createMCPClient({
+      await experimental_createMCPClient({
         name: "opencode",
         transport: new StdioClientTransport({
           stderr: "ignore",
@@ -135,63 +165,61 @@ export namespace MCP {
             ...mcp.environment,
           },
         }),
-      }).catch((error) => {
-        const errorMessage =
-          error instanceof Error
-            ? `MCP server ${name} failed to start: ${error.message}`
-            : `MCP server ${name} failed to start`
-        log.error("local mcp startup failed", {
-          name,
-          command: mcp.command,
-          error: error instanceof Error ? error.message : String(error),
+      })
+        .then((client) => {
+          mcpClient = client
+          status = {
+            status: "connected",
+          }
         })
-        Bus.publish(Session.Event.Error, {
-          error: {
-            name: "UnknownError",
-            data: {
-              message: errorMessage,
-            },
-          },
+        .catch((error) => {
+          log.error("local mcp startup failed", {
+            key,
+            command: mcp.command,
+            error: error instanceof Error ? error.message : String(error),
+          })
+          status = {
+            status: "failed",
+            error: error instanceof Error ? error.message : String(error),
+          }
         })
-        return null
-      })
-      if (client) {
-        mcpClient = client
+    }
+
+    if (!status) {
+      status = {
+        status: "failed",
+        error: "Unknown error",
       }
     }
 
     if (!mcpClient) {
-      log.warn("mcp client not initialized", { name })
-      return
+      return {
+        mcpClient: undefined,
+        status,
+      }
     }
 
-    const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => { })
+    const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
     if (!result) {
-      log.warn("mcp client verification failed, dropping client", { name })
-      return
+      await mcpClient.close()
+      status = {
+        status: "failed",
+        error: "Failed to get tools",
+      }
+      return {
+        mcpClient: undefined,
+        status,
+      }
     }
 
     return {
-      client: mcpClient,
+      mcpClient,
+      status,
     }
   }
 
   export async function status() {
-    return state().then((state) => {
-      const result: Record<string, "connected" | "failed" | "disabled"> = {}
-      for (const [key, client] of Object.entries(state.config)) {
-        if (client.enabled === false) {
-          result[key] = "disabled"
-          continue
-        }
-        if (state.clients[key]) {
-          result[key] = "connected"
-          continue
-        }
-        result[key] = "failed"
-      }
-      return result
-    })
+    return state().then((state) => state.status)
   }
 
   export async function clients() {

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

@@ -41,7 +41,11 @@ export namespace Permission {
     Updated: Bus.event("permission.updated", Info),
     Replied: Bus.event(
       "permission.replied",
-      z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
+      z.object({
+        sessionID: z.string(),
+        permissionID: z.string(),
+        response: z.string(),
+      }),
     ),
   }
 
@@ -141,16 +145,16 @@ export namespace Permission {
     const match = pending[input.sessionID]?.[input.permissionID]
     if (!match) return
     delete pending[input.sessionID][input.permissionID]
-    if (input.response === "reject") {
-      match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
-      return
-    }
-    match.resolve()
     Bus.publish(Event.Replied, {
       sessionID: input.sessionID,
       permissionID: input.permissionID,
       response: input.response,
     })
+    if (input.response === "reject") {
+      match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
+      return
+    }
+    match.resolve()
     if (input.response === "always") {
       approved[input.sessionID] = approved[input.sessionID] || {}
       const approveKeys = toKeys(match.info.pattern, match.info.type)

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

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

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

@@ -1,3 +1,4 @@
+import { Log } from "@/util/log"
 import { Context } from "../util/context"
 import { Project } from "./project"
 import { State } from "./state"
@@ -42,6 +43,15 @@ export const Instance = {
     return State.create(() => Instance.directory, init, dispose)
   },
   async dispose() {
+    Log.Default.info("disposing instance", { directory: Instance.directory })
     await State.dispose(Instance.directory)
   },
+  async disposeAll() {
+    for (const [_key, value] of cache) {
+      await context.provide(value, async () => {
+        await Instance.dispose()
+      })
+    }
+    cache.clear()
+  },
 }

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

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

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

@@ -1,6 +1,12 @@
 import { Log } from "../util/log"
 import { Bus } from "../bus"
-import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
+import {
+  describeRoute,
+  generateSpecs,
+  validator,
+  resolver,
+  openAPIRouteHandler,
+} from "hono-openapi"
 import { Hono } from "hono"
 import { cors } from "hono/cors"
 import { stream, streamSSE } from "hono/streaming"
@@ -15,7 +21,7 @@ import { Config } from "../config/config"
 import { File } from "../file"
 import { LSP } from "../lsp"
 import { MessageV2 } from "../session/message-v2"
-import { callTui, TuiRoute } from "./tui"
+import { TuiRoute } from "./tui"
 import { Permission } from "../permission"
 import { Instance } from "../project/instance"
 import { Agent } from "../agent/agent"
@@ -35,6 +41,7 @@ import { InstanceBootstrap } from "../project/bootstrap"
 import { MCP } from "../mcp"
 import { Storage } from "../storage/storage"
 import type { ContentfulStatusCode } from "hono/utils/http-status"
+import { TuiEvent } from "@/cli/cmd/tui/event"
 import { Snapshot } from "@/snapshot"
 import { SessionSummary } from "@/session/summary"
 
@@ -248,7 +255,9 @@ export namespace Server {
               id: t.id,
               description: t.description,
               // Handle both Zod schemas and plain JSON schemas
-              parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+              parameters: (t.parameters as any)?._def
+                ? zodToJsonSchema(t.parameters as any)
+                : t.parameters,
             })),
           )
         },
@@ -446,7 +455,11 @@ export namespace Server {
           }),
         ),
         async (c) => {
-          await Session.remove(c.req.valid("param").id)
+          const sessionID = c.req.valid("param").id
+          await Session.remove(sessionID)
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "session.list",
+          })
           return c.json(true)
         },
       )
@@ -1033,7 +1046,10 @@ export namespace Server {
           const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
           return c.json({
             providers: Object.values(providers),
-            default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+            default: mapValues(
+              providers,
+              (item) => Provider.sort(Object.values(item.models))[0].id,
+            ),
           })
         },
       )
@@ -1290,7 +1306,7 @@ export namespace Server {
               description: "MCP server status",
               content: {
                 "application/json": {
-                  schema: resolver(z.any()),
+                  schema: resolver(z.record(z.string(), MCP.Status)),
                 },
               },
             },
@@ -1300,6 +1316,26 @@ export namespace Server {
           return c.json(await MCP.status())
         },
       )
+      .get(
+        "/lsp",
+        describeRoute({
+          description: "Get LSP server status",
+          operationId: "lsp.status",
+          responses: {
+            200: {
+              description: "LSP server status",
+              content: {
+                "application/json": {
+                  schema: resolver(LSP.Status.array()),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          return c.json(await LSP.status())
+        },
+      )
       .post(
         "/tui/append-prompt",
         describeRoute({
@@ -1317,13 +1353,11 @@ export namespace Server {
             ...errors(400),
           },
         }),
-        validator(
-          "json",
-          z.object({
-            text: z.string(),
-          }),
-        ),
-        async (c) => c.json(await callTui(c)),
+        validator("json", TuiEvent.PromptAppend.properties),
+        async (c) => {
+          await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
+          return c.json(true)
+        },
       )
       .post(
         "/tui/open-help",
@@ -1341,7 +1375,10 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          // TODO: open dialog
+          return c.json(true)
+        },
       )
       .post(
         "/tui/open-sessions",
@@ -1359,7 +1396,12 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "session.list",
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/open-themes",
@@ -1377,7 +1419,12 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "session.list",
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/open-models",
@@ -1395,7 +1442,12 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "model.list",
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/submit-prompt",
@@ -1413,7 +1465,12 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "prompt.submit",
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/clear-prompt",
@@ -1431,7 +1488,12 @@ export namespace Server {
             },
           },
         }),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          await Bus.publish(TuiEvent.CommandExecute, {
+            command: "prompt.clear",
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/execute-command",
@@ -1450,13 +1512,27 @@ export namespace Server {
             ...errors(400),
           },
         }),
-        validator(
-          "json",
-          z.object({
-            command: z.string(),
-          }),
-        ),
-        async (c) => c.json(await callTui(c)),
+        validator("json", z.object({ command: z.string() })),
+        async (c) => {
+          const command = c.req.valid("json").command
+          await Bus.publish(TuiEvent.CommandExecute, {
+            // @ts-expect-error
+            command: {
+              session_new: "session.new",
+              session_share: "session.share",
+              session_interrupt: "session.interrupt",
+              session_compact: "session.compact",
+              messages_page_up: "session.page.up",
+              messages_page_down: "session.page.down",
+              messages_half_page_up: "session.half.page.up",
+              messages_half_page_down: "session.half.page.down",
+              messages_first: "session.first",
+              messages_last: "session.last",
+              agent_cycle: "agent.cycle",
+            }[command],
+          })
+          return c.json(true)
+        },
       )
       .post(
         "/tui/show-toast",
@@ -1474,15 +1550,52 @@ export namespace Server {
             },
           },
         }),
+        validator("json", TuiEvent.ToastShow.properties),
+        async (c) => {
+          await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
+          return c.json(true)
+        },
+      )
+      .post(
+        "/tui/publish",
+        describeRoute({
+          description: "Publish a TUI event",
+          operationId: "tui.publish",
+          responses: {
+            200: {
+              description: "Event published successfully",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+            ...errors(400),
+          },
+        }),
         validator(
           "json",
-          z.object({
-            title: z.string().optional(),
-            message: z.string(),
-            variant: z.enum(["info", "success", "warning", "error"]),
-          }),
+          z.union(
+            Object.values(TuiEvent).map((def) => {
+              return z
+                .object({
+                  type: z.literal(def.type),
+                  properties: def.properties,
+                })
+                .meta({
+                  ref: "Event" + "." + def.type,
+                })
+            }),
+          ),
         ),
-        async (c) => c.json(await callTui(c)),
+        async (c) => {
+          const evt = c.req.valid("json")
+          await Bus.publish(
+            Object.values(TuiEvent).find((def) => def.type === evt.type)!,
+            evt.properties,
+          )
+          return c.json(true)
+        },
       )
       .route("/tui/control", TuiRoute)
       .put(

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

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

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

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

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

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

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

@@ -81,10 +81,15 @@ export namespace SessionSummary {
           ),
           {
             role: "user" as const,
-            content: textPart?.text ?? "",
+            content: `
+              The following is the text to summarize:
+              <text>
+              ${textPart?.text ?? ""}
+              </text>
+            `,
           },
         ],
-        headers:small.info.headers,
+        headers: small.info.headers,
         model: small.language,
       })
       log.info("title", { title: result.text })
@@ -117,9 +122,9 @@ export namespace SessionSummary {
             `,
             },
           ],
-          headers: small.info.headers
-        })
-        summary = result.text
+          headers: small.info.headers,
+        }).catch(() => {})
+        if (result) summary = result.text
       }
       userMsg.summary.body = summary
       log.info("body", { body: summary })

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

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

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

@@ -2,47 +2,40 @@ import z from "zod"
 import { spawn } from "child_process"
 import { Tool } from "./tool"
 import DESCRIPTION from "./bash.txt"
-import { Permission } from "../permission"
-import { Filesystem } from "../util/filesystem"
-import { lazy } from "../util/lazy"
 import { Log } from "../util/log"
-import { Wildcard } from "../util/wildcard"
-import { $ } from "bun"
 import { Instance } from "../project/instance"
-import { Agent } from "../agent/agent"
+import { lazy } from "@/util/lazy"
+import { Language } from "web-tree-sitter"
+import { Agent } from "@/agent/agent"
+import { $ } from "bun"
+import { Filesystem } from "@/util/filesystem"
+import { Wildcard } from "@/util/wildcard"
+import { Permission } from "@/permission"
 
 const MAX_OUTPUT_LENGTH = 30_000
 const DEFAULT_TIMEOUT = 1 * 60 * 1000
 const MAX_TIMEOUT = 10 * 60 * 1000
 const SIGKILL_TIMEOUT_MS = 200
 
-const log = Log.create({ service: "bash-tool" })
+export const log = Log.create({ service: "bash-tool" })
 
 const parser = lazy(async () => {
-  try {
-    const { default: Parser } = await import("tree-sitter")
-    const Bash = await import("tree-sitter-bash")
-    const p = new Parser()
-    p.setLanguage(Bash.language as any)
-    return p
-  } catch (e) {
-    const { default: Parser } = await import("web-tree-sitter")
-    const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
-      with: { type: "wasm" },
-    })
-    await Parser.init({
-      locateFile() {
-        return treeWasm
-      },
-    })
-    const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
-      with: { type: "wasm" },
-    })
-    const bashLanguage = await Parser.Language.load(bashWasm)
-    const p = new Parser()
-    p.setLanguage(bashLanguage)
-    return p
-  }
+  const { Parser } = await import("web-tree-sitter")
+  const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
+    with: { type: "wasm" },
+  })
+  await Parser.init({
+    locateFile() {
+      return treeWasm
+    },
+  })
+  const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
+    with: { type: "wasm" },
+  })
+  const bashLanguage = await Language.load(bashWasm)
+  const p = new Parser()
+  p.setLanguage(bashLanguage)
+  return p
 })
 
 export const BashTool = Tool.define("bash", {
@@ -64,10 +57,14 @@ export const BashTool = Tool.define("bash", {
     }
     const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
     const tree = await parser().then((p) => p.parse(params.command))
+    if (!tree) {
+      throw new Error("Failed to parse command")
+    }
     const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
 
     const askPatterns = new Set<string>()
     for (const node of tree.rootNode.descendantsOfType("command")) {
+      if (!node) continue
       const command = []
       for (let i = 0; i < node.childCount; i++) {
         const child = node.child(i)

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

@@ -14,8 +14,8 @@ import { Agent } from "../agent/agent"
 export const WriteTool = Tool.define("write", {
   description: DESCRIPTION,
   parameters: z.object({
-    filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
     content: z.string().describe("The content to write to the file"),
+    filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
   }),
   async execute(params, ctx) {
     const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)

+ 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>) {
   const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2))
   await $`mkdir -p ${dirpath}`.quiet()
-  if (options?.git) await $`git init`.cwd(dirpath).quiet()
+  if (options?.git) {
+    await $`git init`.cwd(dirpath).quiet()
+    await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
+  }
   const extra = await options?.init?.(dirpath)
   const result = {
     [Symbol.asyncDispose]: async () => {

+ 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({
       directory: "/tmp",
       fn: async () => {
-        await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
+        await expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow(
+          "patchText is required",
+        )
       },
     })
   })
@@ -30,7 +32,9 @@ describe("tool.patch", () => {
     await Instance.provide({
       directory: "/tmp",
       fn: async () => {
-        await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
+        await expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow(
+          "Failed to parse patch",
+        )
       },
     })
   })
@@ -113,7 +117,9 @@ describe("tool.patch", () => {
         // Verify file was created with correct content
         const filePath = path.join(fixture.path, "config.js")
         const content = await fs.readFile(filePath, "utf-8")
-        expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
+        expect(content).toBe(
+          'const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"',
+        )
       },
     })
   })

+ 1 - 1
packages/plugin/package.json

@@ -24,4 +24,4 @@
     "typescript": "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) => {
   return {
-    permission: {},
     tool: {
       mytool: tool({
-        description: "This is a custom tool tool",
+        description: "This is a custom tool",
         args: {
           foo: tool.schema.string().describe("foo"),
         },
@@ -15,8 +14,5 @@ export const ExamplePlugin: Plugin = async (ctx) => {
         },
       }),
     },
-    async "chat.params"(_input, output) {
-      output.topP = 1
-    },
   }
 }

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

@@ -26,4 +26,4 @@
   "publishConfig": {
     "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 $`rm -rf src/gen`
+
 await createClient({
   input: "./openapi.json",
   output: {

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

@@ -105,6 +105,8 @@ import type {
   AppAgentsResponses,
   McpStatusData,
   McpStatusResponses,
+  LspStatusData,
+  LspStatusResponses,
   TuiAppendPromptData,
   TuiAppendPromptResponses,
   TuiAppendPromptErrors,
@@ -125,6 +127,9 @@ import type {
   TuiExecuteCommandErrors,
   TuiShowToastData,
   TuiShowToastResponses,
+  TuiPublishData,
+  TuiPublishResponses,
+  TuiPublishErrors,
   TuiControlNextData,
   TuiControlNextResponses,
   TuiControlResponseData,
@@ -754,6 +759,20 @@ class Mcp extends _HeyApiClient {
   }
 }
 
+class Lsp extends _HeyApiClient {
+  /**
+   * Get LSP server status
+   */
+  public status<ThrowOnError extends boolean = false>(
+    options?: Options<LspStatusData, ThrowOnError>,
+  ) {
+    return (options?.client ?? this._client).get<LspStatusResponses, unknown, ThrowOnError>({
+      url: "/lsp",
+      ...options,
+    })
+  }
+}
+
 class Control extends _HeyApiClient {
   /**
    * Get the next TUI request from the queue
@@ -916,6 +935,26 @@ class Tui extends _HeyApiClient {
       },
     })
   }
+
+  /**
+   * Publish a TUI event
+   */
+  public publish<ThrowOnError extends boolean = false>(
+    options?: Options<TuiPublishData, ThrowOnError>,
+  ) {
+    return (options?.client ?? this._client).post<
+      TuiPublishResponses,
+      TuiPublishErrors,
+      ThrowOnError
+    >({
+      url: "/tui/publish",
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+      },
+    })
+  }
   control = new Control({ client: this._client })
 }
 
@@ -983,6 +1022,7 @@ export class OpencodeClient extends _HeyApiClient {
   file = new File({ client: this._client })
   app = new App({ client: this._client })
   mcp = new Mcp({ client: this._client })
+  lsp = new Lsp({ client: this._client })
   tui = new Tui({ client: this._client })
   auth = new Auth({ client: this._client })
   event = new Event({ client: this._client })

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

@@ -18,10 +18,6 @@ export type KeybindsConfig = {
    * Leader key for keybind combinations
    */
   leader?: string
-  /**
-   * Show help dialog
-   */
-  app_help?: string
   /**
    * Exit the application
    */
@@ -35,17 +31,13 @@ export type KeybindsConfig = {
    */
   theme_list?: string
   /**
-   * Create/update AGENTS.md
-   */
-  project_init?: string
-  /**
-   * Toggle tool details
+   * Toggle sidebar
    */
-  tool_details?: string
+  sidebar_toggle?: string
   /**
-   * Toggle thinking blocks
+   * View status
    */
-  thinking_blocks?: string
+  status_view?: string
   /**
    * Export session to editor
    */
@@ -78,14 +70,6 @@ export type KeybindsConfig = {
    * Compact the session
    */
   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
    */
@@ -127,13 +111,9 @@ export type KeybindsConfig = {
    */
   model_list?: string
   /**
-   * Next recent model
-   */
-  model_cycle_recent?: string
-  /**
-   * Previous recent model
+   * List available commands
    */
-  model_cycle_recent_reverse?: string
+  command_list?: string
   /**
    * List agents
    */
@@ -162,54 +142,6 @@ export type KeybindsConfig = {
    * Insert newline in input
    */
   input_newline?: string
-  /**
-   * @deprecated use agent_cycle. Next mode
-   */
-  switch_mode?: string
-  /**
-   * @deprecated use agent_cycle_reverse. Previous mode
-   */
-  switch_mode_reverse?: string
-  /**
-   * @deprecated use agent_cycle. Next agent
-   */
-  switch_agent?: string
-  /**
-   * @deprecated use agent_cycle_reverse. Previous agent
-   */
-  switch_agent_reverse?: string
-  /**
-   * @deprecated Currently not available. List files
-   */
-  file_list?: string
-  /**
-   * @deprecated Close file
-   */
-  file_close?: string
-  /**
-   * @deprecated Search file
-   */
-  file_search?: string
-  /**
-   * @deprecated Split/unified diff
-   */
-  file_diff_toggle?: string
-  /**
-   * @deprecated Navigate to previous message
-   */
-  messages_previous?: string
-  /**
-   * @deprecated Navigate to next message
-   */
-  messages_next?: string
-  /**
-   * @deprecated Toggle layout
-   */
-  messages_layout_toggle?: string
-  /**
-   * @deprecated use messages_undo. Revert message
-   */
-  messages_revert?: string
 }
 
 export type AgentConfig = {
@@ -781,11 +713,17 @@ export type FilePart = {
 
 export type ToolStatePending = {
   status: "pending"
+  input: {
+    [key: string]: unknown
+  }
+  raw: string
 }
 
 export type ToolStateRunning = {
   status: "running"
-  input: unknown
+  input: {
+    [key: string]: unknown
+  }
   title?: string
   metadata?: {
     [key: string]: unknown
@@ -1086,6 +1024,72 @@ export type Agent = {
   }
 }
 
+export type McpStatusConnected = {
+  status: "connected"
+}
+
+export type McpStatusDisabled = {
+  status: "disabled"
+}
+
+export type McpStatusFailed = {
+  status: "failed"
+  error: string
+}
+
+export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed
+
+export type LspStatus = {
+  id: string
+  name: string
+  root: string
+  status: "connected" | "error"
+}
+
+export type EventTuiPromptAppend = {
+  type: "tui.prompt.append"
+  properties: {
+    text: string
+  }
+}
+
+export type EventTuiCommandExecute = {
+  type: "tui.command.execute"
+  properties: {
+    command:
+      | (
+          | "session.list"
+          | "session.new"
+          | "session.share"
+          | "session.interrupt"
+          | "session.compact"
+          | "session.page.up"
+          | "session.page.down"
+          | "session.half.page.up"
+          | "session.half.page.down"
+          | "session.first"
+          | "session.last"
+          | "prompt.clear"
+          | "prompt.submit"
+          | "agent.cycle"
+        )
+      | string
+  }
+}
+
+export type EventTuiToastShow = {
+  type: "tui.toast.show"
+  properties: {
+    title?: string
+    message: string
+    variant: "info" | "success" | "warning" | "error"
+    /**
+     * Duration in milliseconds
+     */
+    duration?: number
+  }
+}
+
 export type OAuth = {
   type: "oauth"
   refresh: string
@@ -1121,6 +1125,13 @@ export type EventLspClientDiagnostics = {
   }
 }
 
+export type EventLspUpdated = {
+  type: "lsp.updated"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
 export type EventMessageUpdated = {
   type: "message.updated"
   properties: {
@@ -1261,16 +1272,10 @@ export type EventServerConnected = {
   }
 }
 
-export type EventIdeInstalled = {
-  type: "ide.installed"
-  properties: {
-    ide: string
-  }
-}
-
 export type Event =
   | EventInstallationUpdated
   | EventLspClientDiagnostics
+  | EventLspUpdated
   | EventMessageUpdated
   | EventMessageRemoved
   | EventMessagePartUpdated
@@ -1286,8 +1291,10 @@ export type Event =
   | EventSessionUpdated
   | EventSessionDeleted
   | EventSessionError
+  | EventTuiPromptAppend
+  | EventTuiCommandExecute
+  | EventTuiToastShow
   | EventServerConnected
-  | EventIdeInstalled
 
 export type ProjectListData = {
   body?: never
@@ -2455,9 +2462,31 @@ export type McpStatusResponses = {
   /**
    * MCP server status
    */
-  200: unknown
+  200: {
+    [key: string]: McpStatus
+  }
+}
+
+export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]
+
+export type LspStatusData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/lsp"
+}
+
+export type LspStatusResponses = {
+  /**
+   * LSP server status
+   */
+  200: Array<LspStatus>
 }
 
+export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
+
 export type TuiAppendPromptData = {
   body?: {
     text: string
@@ -2629,6 +2658,10 @@ export type TuiShowToastData = {
     title?: string
     message: string
     variant: "info" | "success" | "warning" | "error"
+    /**
+     * Duration in milliseconds
+     */
+    duration?: number
   }
   path?: never
   query?: {
@@ -2646,6 +2679,33 @@ export type TuiShowToastResponses = {
 
 export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]
 
+export type TuiPublishData = {
+  body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/tui/publish"
+}
+
+export type TuiPublishErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]
+
+export type TuiPublishResponses = {
+  /**
+   * Event published successfully
+   */
+  200: boolean
+}
+
+export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]
+
 export type TuiControlNextData = {
   body?: never
   path?: never

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