Browse Source

refactor(server): canonicalize service API (#31049)

Dax 2 months ago
parent
commit
fe0c4f8c74
100 changed files with 985 additions and 1486 deletions
  1. 43 5
      bun.lock
  2. 1 0
      packages/cli/bunfig.toml
  3. 5 1
      packages/cli/package.json
  4. 19 1
      packages/cli/script/build.ts
  5. 13 0
      packages/cli/src/commands/handlers/default.ts
  6. 2 0
      packages/cli/src/commands/handlers/serve.ts
  7. 10 10
      packages/cli/src/framework/runtime.ts
  8. 1 0
      packages/cli/src/index.ts
  9. 21 10
      packages/cli/src/services/daemon.ts
  10. 115 0
      packages/cli/src/tui.ts
  11. 2 0
      packages/cli/tsconfig.json
  12. 2 5
      packages/core/src/agent.ts
  13. 1 1
      packages/core/src/catalog.ts
  14. 2 0
      packages/core/src/command.ts
  15. 2 0
      packages/core/src/control-plane/move-session.ts
  16. 1 1
      packages/core/src/instruction-context.ts
  17. 6 14
      packages/core/src/session.ts
  18. 2 0
      packages/core/src/session/execution/local.ts
  19. 2 0
      packages/core/src/session/store.ts
  20. 13 6
      packages/core/src/state.ts
  21. 1 1
      packages/core/src/system-context/builtins.ts
  22. 12 12
      packages/core/src/system-context/registry.ts
  23. 2 2
      packages/core/src/tool/builtins.ts
  24. 1 1
      packages/core/test/agent.test.ts
  25. 1 1
      packages/core/test/plugin.test.ts
  26. 1 1
      packages/core/test/session-runner.test.ts
  27. 19 19
      packages/core/test/system-context/registry.test.ts
  28. 1 0
      packages/opencode/package.json
  29. 1 386
      packages/opencode/parsers-config.ts
  30. 1 1
      packages/opencode/script/build.ts
  31. 2 2
      packages/opencode/script/schema.ts
  32. 1 1
      packages/opencode/script/trace-imports.ts
  33. 14 7
      packages/opencode/src/cli/cmd/attach.ts
  34. 1 48
      packages/opencode/src/cli/cmd/prompt-display.ts
  35. 1 1
      packages/opencode/src/cli/cmd/run/footer.prompt.tsx
  36. 1 1
      packages/opencode/src/cli/cmd/run/footer.subagent.tsx
  37. 1 1
      packages/opencode/src/cli/cmd/run/footer.ts
  38. 2 2
      packages/opencode/src/cli/cmd/run/footer.view.tsx
  39. 3 11
      packages/opencode/src/cli/cmd/run/runtime.boot.ts
  40. 1 1
      packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts
  41. 1 1
      packages/opencode/src/cli/cmd/run/theme.ts
  42. 1 1
      packages/opencode/src/cli/cmd/run/types.ts
  43. 17 10
      packages/opencode/src/cli/cmd/tui.ts
  44. 0 90
      packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx
  45. 0 31
      packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
  46. 0 101
      packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx
  47. 0 88
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  48. 0 9
      packages/opencode/src/cli/cmd/tui/context/tui-config.tsx
  49. 0 6
      packages/opencode/src/cli/cmd/tui/layer.ts
  50. 0 42
      packages/opencode/src/cli/cmd/tui/plugin/internal.ts
  51. 0 60
      packages/opencode/src/cli/cmd/tui/plugin/slots.tsx
  52. 1 11
      packages/opencode/src/cli/logo.ts
  53. 9 5
      packages/opencode/src/cli/tui/attention.ts
  54. 0 0
      packages/opencode/src/cli/tui/audio.ts
  55. 3 3
      packages/opencode/src/cli/tui/clipboard.ts
  56. 3 3
      packages/opencode/src/cli/tui/editor-zed.ts
  57. 0 0
      packages/opencode/src/cli/tui/editor.ts
  58. 36 0
      packages/opencode/src/cli/tui/host.ts
  59. 120 0
      packages/opencode/src/cli/tui/platform.ts
  60. 57 0
      packages/opencode/src/cli/tui/runtime.ts
  61. 0 0
      packages/opencode/src/cli/tui/validate-session.ts
  62. 0 0
      packages/opencode/src/cli/tui/win32.ts
  63. 0 0
      packages/opencode/src/cli/tui/worker.ts
  64. 0 0
      packages/opencode/src/config/tui-cwd.ts
  65. 21 0
      packages/opencode/src/config/tui-host-attention.ts
  66. 4 4
      packages/opencode/src/config/tui-migrate.ts
  67. 37 53
      packages/opencode/src/config/tui.ts
  68. 2 2
      packages/opencode/src/index.ts
  69. 1 1
      packages/opencode/src/mcp/index.ts
  70. 12 0
      packages/opencode/src/plugin/tui/internal.ts
  71. 65 22
      packages/opencode/src/plugin/tui/runtime.ts
  72. 4 2
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  73. 60 0
      packages/opencode/src/server/routes/instance/httpapi/groups/reference.ts
  74. 1 1
      packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts
  75. 27 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/reference.ts
  76. 1 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
  77. 1 1
      packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts
  78. 12 8
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  79. 0 0
      packages/opencode/src/server/tui-event.ts
  80. 2 0
      packages/opencode/src/session/session.ts
  81. 1 1
      packages/opencode/src/temporary.ts
  82. 1 88
      packages/opencode/src/util/error.ts
  83. 2 86
      packages/opencode/src/util/locale.ts
  84. 1 3
      packages/opencode/src/util/record.ts
  85. 2 2
      packages/opencode/test/cli/cmd/tui/attention.test.ts
  86. 0 44
      packages/opencode/test/cli/cmd/tui/prompt-history.test.ts
  87. 0 77
      packages/opencode/test/cli/cmd/tui/prompt-part.test.ts
  88. 1 1
      packages/opencode/test/cli/run/footer.view.test.tsx
  89. 0 32
      packages/opencode/test/cli/run/prompt.shared.test.ts
  90. 2 1
      packages/opencode/test/cli/run/runtime.boot.test.ts
  91. 94 25
      packages/opencode/test/cli/tui/app-lifecycle.test.ts
  92. 12 0
      packages/opencode/test/cli/tui/attach.test.ts
  93. 1 1
      packages/opencode/test/cli/tui/editor-context-zed.test.ts
  94. 28 4
      packages/opencode/test/cli/tui/editor-context.test.tsx
  95. 2 2
      packages/opencode/test/cli/tui/plugin-add.test.ts
  96. 2 2
      packages/opencode/test/cli/tui/plugin-install.test.ts
  97. 1 1
      packages/opencode/test/cli/tui/plugin-lifecycle.test.ts
  98. 2 2
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  99. 2 2
      packages/opencode/test/cli/tui/plugin-loader-pure.test.ts
  100. 3 3
      packages/opencode/test/cli/tui/plugin-loader.test.ts

+ 43 - 5
bun.lock

@@ -94,8 +94,12 @@
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/server": "workspace:*",
+        "@opencode-ai/tui": "workspace:*",
+        "@opentui/core": "catalog:",
+        "@opentui/solid": "catalog:",
         "@parcel/watcher": "2.5.1",
         "effect": "catalog:",
+        "solid-js": "catalog:",
       },
       "devDependencies": {
         "@opencode-ai/script": "workspace:*",
@@ -531,6 +535,7 @@
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/server": "workspace:*",
+        "@opencode-ai/tui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@openrouter/ai-sdk-provider": "2.9.0",
         "@opentelemetry/api": "1.9.0",
@@ -787,6 +792,31 @@
         "vite": "catalog:",
       },
     },
+    "packages/tui": {
+      "name": "@opencode-ai/tui",
+      "version": "0.0.0",
+      "dependencies": {
+        "@opencode-ai/plugin": "workspace:*",
+        "@opencode-ai/sdk": "workspace:*",
+        "@opentui/core": "catalog:",
+        "@opentui/keymap": "catalog:",
+        "@opentui/solid": "catalog:",
+        "@solid-primitives/scheduled": "1.5.2",
+        "diff": "catalog:",
+        "effect": "catalog:",
+        "fuzzysort": "catalog:",
+        "open": "10.1.2",
+        "opentui-spinner": "catalog:",
+        "remeda": "catalog:",
+        "solid-js": "catalog:",
+        "strip-ansi": "7.1.2",
+      },
+      "devDependencies": {
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+      },
+    },
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "version": "1.16.2",
@@ -1776,6 +1806,8 @@
 
     "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
 
+    "@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
+
     "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
 
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@@ -3336,7 +3368,7 @@
 
     "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
 
-    "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+    "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
 
     "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
 
@@ -5722,6 +5754,8 @@
 
     "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
 
+    "@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
+
     "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
 
     "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5730,8 +5764,6 @@
 
     "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
 
-    "@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
-
     "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
 
     "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
@@ -5920,6 +5952,8 @@
 
     "dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
 
+    "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
     "dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
 
     "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
@@ -5978,10 +6012,12 @@
 
     "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
 
-    "happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
-
     "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
 
+    "html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
+    "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
     "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
 
     "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
@@ -6388,6 +6424,8 @@
 
     "@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
 
+    "@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
     "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
 
     "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],

+ 1 - 0
packages/cli/bunfig.toml

@@ -0,0 +1 @@
+preload = ["@opentui/solid/preload"]

+ 5 - 1
packages/cli/package.json

@@ -20,8 +20,12 @@
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/server": "workspace:*",
+    "@opencode-ai/tui": "workspace:*",
+    "@opentui/core": "catalog:",
+    "@opentui/solid": "catalog:",
     "@parcel/watcher": "2.5.1",
-    "effect": "catalog:"
+    "effect": "catalog:",
+    "solid-js": "catalog:"
   },
   "devDependencies": {
     "@opencode-ai/script": "workspace:*",

+ 19 - 1
packages/cli/script/build.ts

@@ -1,8 +1,12 @@
 #!/usr/bin/env bun
 
+import { $ } from "bun"
+import fs from "fs"
 import { rm } from "fs/promises"
 import path from "path"
 import { Script } from "@opencode-ai/script"
+import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
+import pkg from "../package.json"
 import { modelsData } from "./generate"
 
 const dir = path.resolve(import.meta.dirname, "..")
@@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
 
 const singleFlag = process.argv.includes("--single")
 const baselineFlag = process.argv.includes("--baseline")
+const skipInstall = process.argv.includes("--skip-install")
 const sourcemapsFlag = process.argv.includes("--sourcemaps")
+const plugin = createSolidTransformPlugin()
 
 const allTargets: {
   os: string
@@ -43,6 +49,12 @@ const targets = singleFlag
     })
   : allTargets
 
+if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
+
+const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
+const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
+const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
+
 for (const item of targets) {
   const target = [
     binary,
@@ -56,8 +68,9 @@ for (const item of targets) {
   const name = target.replace(binary, "cli")
   console.log(`building ${name}`)
   const result = await Bun.build({
-    entrypoints: ["./src/index.ts"],
+    entrypoints: ["./src/index.ts", parserWorker],
     tsconfig: "./tsconfig.json",
+    plugins: [plugin],
     external: ["node-gyp"],
     format: "esm",
     minify: true,
@@ -79,6 +92,11 @@ for (const item of targets) {
       OPENCODE_MODELS_DEV: modelsData,
       OPENCODE_CHANNEL: `'${Script.channel}'`,
       OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
+      OTUI_TREE_SITTER_WORKER_PATH:
+        (item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
+        path.relative(dir, parserWorker).replaceAll("\\", "/") +
+        '"',
+      ...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
     },
   })
 

+ 13 - 0
packages/cli/src/commands/handlers/default.ts

@@ -0,0 +1,13 @@
+import { Commands } from "../commands"
+import { Runtime } from "../../framework/runtime"
+import { Effect } from "effect"
+import { Daemon } from "../../services/daemon"
+
+export default Runtime.handler(Commands, () =>
+  Effect.gen(function* () {
+    const daemon = yield* Daemon.Service
+    const transport = yield* daemon.transport()
+    const { runTui } = yield* Effect.promise(() => import("../../tui"))
+    yield* Effect.promise(() => runTui(transport))
+  }),
+)

+ 2 - 0
packages/cli/src/commands/handlers/serve.ts

@@ -1,4 +1,5 @@
 import { NodeHttpServer } from "@effect/platform-node"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
 import { Context, Layer, Option } from "effect"
 import * as Effect from "effect/Effect"
 import { HttpRouter, HttpServer } from "effect/unstable/http"
@@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
   return Layer.build(
     HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
       Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
+      Layer.provide(PermissionSaved.defaultLayer),
     ),
   ).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
 }

+ 10 - 10
packages/cli/src/framework/runtime.ts

@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
 }
 
 function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
-  const spec: Command.Command.Any = Object.keys(node.commands).length
-    ? (node.spec as Command.Command<string, unknown>).pipe(
-        Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
+  const handler = handlers.find((handler) => handler.spec === node.spec)
+  const spec = handler
+    ? node.spec.pipe(
+        Command.withHandler((input) =>
+          Effect.gen(function* () {
+            yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
+          }),
+        ),
       )
     : node.spec
-  const handler = handlers.find((handler) => handler.spec === node.spec)
-  if (!handler) return spec as ProvidedCommand
+  if (!Object.keys(node.commands).length) return spec as ProvidedCommand
   return spec.pipe(
-    Command.withHandler((input) =>
-      Effect.gen(function* () {
-        yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
-      }),
-    ),
+    Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
   ) as ProvidedCommand
 }
 

+ 1 - 0
packages/cli/src/index.ts

@@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
 import { Daemon } from "./services/daemon"
 
 const Handlers = Runtime.handlers(Commands, {
+  $: () => import("./commands/handlers/default"),
   debug: {
     agents: () => import("./commands/handlers/debug/agents"),
   },

+ 21 - 10
packages/cli/src/services/daemon.ts

@@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
 import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
 import { HttpServer } from "effect/unstable/http"
 import { randomBytes, randomUUID } from "crypto"
+import { spawn } from "node:child_process"
 import path from "path"
 
 export interface Interface {
   readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
+  readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
   readonly start: () => Effect.Effect<string, Error>
   readonly status: () => Effect.Effect<string | undefined>
   readonly stop: () => Effect.Effect<void, unknown>
@@ -108,16 +110,20 @@ export const layer = Layer.effect(
     const start = Effect.fn("cli.daemon.start")(function* () {
       const existing = yield* healthy().pipe(Effect.option)
       const found = Option.getOrUndefined(existing)
-      if (found?.version === InstallationVersion) return found.url
+      const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
+      if (found?.version === InstallationVersion && compiled) return found.url
       if (found) yield* stopProcess(found).pipe(Effect.ignore)
 
-      yield* Effect.sync(() => {
-        const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
-        Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
-          stdin: "ignore",
-          stdout: "ignore",
-          stderr: "ignore",
-        }).unref()
+      const entrypoint = compiled ? undefined : process.argv[1]
+      if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
+      yield* Effect.try({
+        try: () => {
+          spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
+            detached: true,
+            stdio: "ignore",
+          }).unref()
+        },
+        catch: (cause) => new Error("Failed to start server", { cause }),
       })
 
       return yield* compatible().pipe(
@@ -127,8 +133,13 @@ export const layer = Layer.effect(
       )
     })
 
+    const transport = Effect.fn("cli.daemon.transport")(function* () {
+      return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
+    })
+
     const client = Effect.fn("cli.daemon.client")(function* () {
-      return yield* createClient(yield* start())
+      const connection = yield* transport()
+      return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
     })
 
     const status = Effect.fn("cli.daemon.status")(function* () {
@@ -173,7 +184,7 @@ export const layer = Layer.effect(
       )
     })
 
-    return Service.of({ client, start, status, stop, password, register })
+    return Service.of({ client, transport, start, status, stop, password, register })
   }),
 )
 

+ 115 - 0
packages/cli/src/tui.ts

@@ -0,0 +1,115 @@
+import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui"
+import { TuiConfig } from "@opencode-ai/tui/config"
+import type { TuiPlatform } from "@opencode-ai/tui/platform"
+import os from "node:os"
+import path from "node:path"
+
+declare const OPENCODE_VERSION: string | undefined
+declare const OPENCODE_CHANNEL: string | undefined
+
+export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
+  const config = TuiConfig.resolve({}, { terminalSuspend: false })
+  const state = path.join(os.homedir(), ".local", "state", "opencode")
+  const environment = createTuiEnvironment({
+    cwd: process.cwd(),
+    platform: process.platform,
+    paths: {
+      home: os.homedir(),
+      state,
+      worktree: path.join(state, "worktree"),
+    },
+    capabilities: {
+      mouse: config.mouse,
+      copyOnSelect: true,
+      terminalTitle: true,
+      terminalSuspend: false,
+      workspaces: false,
+      showTimeToFirstDraw: false,
+    },
+    terminal: {
+      multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
+      displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
+    },
+    editor: { zedTerminal: false },
+    skipInitialLoading: false,
+  })
+  const build = createTuiBuildInfo({
+    version: typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local",
+    channel: typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local",
+  })
+  const renderer = await createTuiRenderer(config, { environment, build })
+  const handle = run({
+    ...transport,
+    args: {},
+    config,
+    environment,
+    build,
+    renderer,
+    fetch: gracefulFetch,
+    pluginHost: {
+      async start() {},
+      async dispose() {},
+    },
+    host: createHost(),
+  })
+  await handle.done
+}
+
+function createHost(): TuiHost {
+  return {
+    platform,
+    attention() {
+      return {
+        async notify() {
+          return { ok: false, notification: false, sound: false, skipped: "attention_disabled" }
+        },
+        soundboard: {
+          registerPack: () => () => {},
+          activate: () => false,
+          current: () => "",
+          list: () => [],
+        },
+        dispose() {},
+      }
+    },
+    logger: { error: (message, extra) => console.error(message, extra ?? "") },
+    lifecycle: {
+      onSighup(handler) {
+        process.on("SIGHUP", handler)
+        return () => process.off("SIGHUP", handler)
+      },
+      writeStdout: (text) => process.stdout.write(text),
+      writeStderr: (text) => process.stderr.write(text),
+    },
+    formatError: () => undefined,
+    formatUnknownError(error) {
+      if (error instanceof Error) return error.message
+      return String(error)
+    },
+  }
+}
+
+const platform: TuiPlatform = {
+  files: {
+    readText: (file) => Bun.file(file).text(),
+    readBytes: async (file) => new Uint8Array(await Bun.file(file).arrayBuffer()),
+    async mime(file) {
+      return Bun.file(file).type || "application/octet-stream"
+    },
+  },
+}
+
+const legacyDefaults: Record<string, unknown> = {
+  "/config/providers": { providers: [], default: {} },
+  "/provider": { all: [], default: {}, connected: [] },
+  "/agent": [],
+  "/config": {},
+}
+
+const gracefulFetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => {
+  const response = await fetch(input, init)
+  if (response.status !== 404) return response
+  const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
+  if (fallback === undefined) return response
+  return Response.json(fallback)
+}, { preconnect: fetch.preconnect })

+ 2 - 0
packages/cli/tsconfig.json

@@ -2,6 +2,8 @@
   "$schema": "https://json.schemastore.org/tsconfig",
   "extends": "@tsconfig/bun/tsconfig.json",
   "compilerOptions": {
+    "jsx": "preserve",
+    "jsxImportSource": "@opentui/solid",
     "lib": ["ESNext", "DOM", "DOM.Iterable"],
     "noUncheckedIndexedAccess": false
   }

+ 2 - 5
packages/core/src/agent.ts

@@ -63,7 +63,7 @@ export type Editor = {
 
 export interface Interface {
   readonly transform: State.Interface<Data, Editor>["transform"]
-  readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
+  readonly update: State.Interface<Data, Editor>["update"]
   readonly get: (id: ID) => Effect.Effect<Info | undefined>
   readonly default: () => Effect.Effect<Info | undefined>
   readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
@@ -113,10 +113,7 @@ export const layer = Layer.effect(
 
     return Service.of({
       transform: state.transform,
-      update: Effect.fn("AgentV2.update")(function* (update) {
-        const transform = yield* state.transform()
-        yield* transform(update)
-      }),
+      update: state.update,
       get: Effect.fn("AgentV2.get")(function* (id) {
         return state.get().agents.get(id)
       }),

+ 1 - 1
packages/core/src/catalog.ts

@@ -203,7 +203,7 @@ export const layer = Layer.effect(
           event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
       ),
       Stream.runForEach((event) =>
-        state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
+        state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
       ),
       Effect.forkIn(scope, { startImmediately: true }),
     )

+ 2 - 0
packages/core/src/command.ts

@@ -27,6 +27,7 @@ export type Editor = {
 
 export interface Interface {
   readonly transform: State.Interface<Data, Editor>["transform"]
+  readonly update: State.Interface<Data, Editor>["update"]
   readonly get: (name: string) => Effect.Effect<Info | undefined>
   readonly list: () => Effect.Effect<Info[]>
 }
@@ -54,6 +55,7 @@ export const layer = Layer.effect(
     })
 
     return Service.of({
+      update: state.update,
       transform: state.transform,
       get: Effect.fn("CommandV2.get")(function* (name) {
         return state.get().commands.get(name)

+ 2 - 0
packages/core/src/control-plane/move-session.ts

@@ -6,6 +6,7 @@ import { Git } from "../git"
 import { Location } from "../location"
 import { ProjectV2 } from "../project"
 import { SessionV2 } from "../session"
+import { SessionExecution } from "../session/execution"
 import { SessionEvent } from "../session/event"
 import { SessionSchema } from "../session/schema"
 import { AbsolutePath, RelativePath } from "../schema"
@@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Git.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
   Layer.provide(ProjectV2.defaultLayer),
+  Layer.provide(SessionExecution.noopLayer),
   Layer.provide(SessionV2.defaultLayer),
 )

+ 1 - 1
packages/core/src/instruction-context.ts

@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
       return files.filter((file): file is File => file !== undefined)
     })
 
-    yield* registry.contribute({
+    yield* registry.register({
       key,
       load: observe().pipe(
         Effect.map((files) =>

+ 6 - 14
packages/core/src/session.ts

@@ -425,20 +425,12 @@ export const layer = Layer.effect(
   }),
 )
 
-const DefaultDatabase = Database.defaultLayer
-const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
-const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
-const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
 export const defaultLayer = layer.pipe(
-  Layer.provide(
-    Layer.mergeAll(
-      DefaultDatabase,
-      DefaultEvents,
-      DefaultProjector,
-      DefaultStore,
-      SessionExecution.noopLayer,
-      ProjectV2.defaultLayer,
-    ),
-  ),
+  Layer.provide(SessionExecution.noopLayer),
+  Layer.provide(SessionStore.defaultLayer),
+  Layer.provide(SessionProjector.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
+  Layer.provide(Database.defaultLayer),
+  Layer.provide(ProjectV2.defaultLayer),
   Layer.orDie,
 )

+ 2 - 0
packages/core/src/session/execution/local.ts

@@ -31,3 +31,5 @@ export const layer = Layer.effect(
     })
   }),
 )
+
+export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))

+ 2 - 0
packages/core/src/session/store.ts

@@ -58,3 +58,5 @@ export const layer = Layer.effect(
     })
   }),
 )
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

+ 13 - 6
packages/core/src/state.ts

@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
 import type { Draft, Objectish } from "immer"
 
 /**
- * A replayable contribution applied to an editor during rebuild.
+ * A replayable transform applied to an editor during rebuild.
  *
  * Transforms are intentionally synchronous and mutation-shaped: domain editors
  * hide the draft representation while preserving concise plugin/config code.
@@ -39,15 +39,17 @@ export interface Interface<State extends Objectish, Editor> {
    * registration order. Closing the owning Scope removes the slot and rebuilds.
    */
   readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
+  /** Registers and applies a replayable transform in the current Scope. */
+  readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
   /**
-   * Mutates the current materialized state directly.
+   * Mutates the current materialized state directly, once.
    *
-   * This is not replayable contribution state: a later rebuild starts again
+   * This is not replayable transform state: a later rebuild starts again
    * from `initial()` plus active transforms, so direct edits must be reserved
    * for current-state adjustments that are intentionally outside the transform
    * fold.
    */
-  readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
+  readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
 }
 
 export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
@@ -69,7 +71,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
     yield* commit(next)
   })
 
-  return {
+  const result: Interface<State, Editor> = {
     get: () => state,
     transform: Effect.fn("State.transform")(function* () {
       const scope = yield* Scope.Scope
@@ -96,10 +98,15 @@ export function create<State extends Objectish, Editor>(options: Options<State,
         }),
       )
     }),
-    update: Effect.fn("State.update")(function* (update, reason) {
+    update: Effect.fn("State.update")(function* (update) {
+      const transform = yield* result.transform()
+      yield* transform(update)
+    }),
+    mutate: Effect.fn("State.mutate")(function* (update, reason) {
       const api = options.editor(state as Draft<State>)
       yield* update(api)
       if (options.finalize) yield* options.finalize(api, reason)
     }, semaphore.withPermit),
   }
+  return result
 }

+ 1 - 1
packages/core/src/system-context/builtins.ts

@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
       }),
     ])
 
-    yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
+    yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
   }),
 )
 

+ 12 - 12
packages/core/src/system-context/registry.ts

@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
 import { Context, Effect, Layer, Ref, Scope } from "effect"
 import { SystemContext } from "./index"
 
-export interface Contribution {
+export interface Entry {
   readonly key: SystemContext.Key
   readonly load: Effect.Effect<SystemContext.SystemContext>
 }
 
 export interface Interface {
-  readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
+  readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
   readonly load: () => Effect.Effect<SystemContext.SystemContext>
 }
 
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
+    const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
 
     return Service.of({
-      contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
+      register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
         yield* Effect.acquireRelease(
-          Ref.modify(contributions, (current) => {
-            if (current.some((item) => item.key === contribution.key)) return [false, current]
-            return [true, [...current, contribution]]
+          Ref.modify(entries, (current) => {
+            if (current.some((item) => item.key === entry.key)) return [false, current]
+            return [true, [...current, entry]]
           }).pipe(
             Effect.flatMap((added) =>
-              added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
+              added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
             ),
-            Effect.as(contribution),
+            Effect.as(entry),
           ),
-          (entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
+          (entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
         )
       }),
       load: Effect.fn("SystemContextRegistry.load")(function* () {
-        const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
+        const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
         return SystemContext.combine(
-          yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
+          yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
         )
       }),
     })

+ 2 - 2
packages/core/src/tool/builtins.ts

@@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
 import { WriteTool } from "./write"
 
 /**
- * Composes only the shipped Location-scoped built-in tool contributions.
+ * Composes only the shipped Location-scoped built-in tool transforms.
  * Each tool retains its implementation and focused tests independently. Dynamic
  * MCP and plugin tools later use separate scoped canonical registrations, while
  * provider/model filtering belongs to a future materialization phase rather
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
  * TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
  * parity, task, LSP,
  * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
- * contributions separate from this static built-in list.
+ * transforms separate from this static built-in list.
  */
 export const locationLayer = Layer.mergeAll(
   ApplyPatchTool.layer,

+ 1 - 1
packages/core/test/agent.test.ts

@@ -59,7 +59,7 @@ describe("AgentV2", () => {
     }),
   )
 
-  it.effect("removes a transform contribution when its scope closes", () =>
+  it.effect("removes a transform when its scope closes", () =>
     Effect.gen(function* () {
       const agent = yield* AgentV2.Service
       const id = AgentV2.ID.make("scoped")

+ 1 - 1
packages/core/test/plugin.test.ts

@@ -45,7 +45,7 @@ describe("PluginV2", () => {
     }),
   )
 
-  it.effect("serializes same-ID additions and leaves one removable contribution", () =>
+  it.effect("serializes same-ID additions and leaves one removable attachment", () =>
     Effect.gen(function* () {
       const values = state()
       const layerScope = yield* Scope.fork(yield* Scope.Scope)

+ 1 - 1
packages/core/test/session-runner.test.ts

@@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
 const systemContext = Layer.effectDiscard(
   SystemContextRegistry.Service.pipe(
     Effect.flatMap((registry) =>
-      registry.contribute({
+      registry.register({
         key: systemContextKey,
         load: Effect.sync(() =>
           SystemContext.combine(

+ 19 - 19
packages/core/test/system-context/registry.test.ts

@@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
 import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
 import { testEffect } from "../lib/effect"
 
-const contribution = (key: string, text: string, sourceKey = key) => ({
+const entry = (key: string, text: string, sourceKey = key) => ({
   key: SystemContext.Key.make(key),
   load: Effect.succeed(
     SystemContext.make({
@@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
 const it = testEffect(SystemContextRegistry.layer)
 
 describe("SystemContextRegistry", () => {
-  it.effect("loads empty system context when there are no contributions", () =>
+  it.effect("loads empty system context when there are no entries", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
 
@@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
     }),
   )
 
-  it.effect("loads scoped contributions in stable key order", () =>
+  it.effect("loads scoped entries in stable key order", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
-      yield* registry.contribute(contribution("test/second", "second"))
-      yield* registry.contribute(contribution("test/first", "first"))
+      yield* registry.register(entry("test/second", "second"))
+      yield* registry.register(entry("test/first", "first"))
 
       expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
     }),
   )
 
-  it.effect("re-evaluates contribution producers on each load", () =>
+  it.effect("re-evaluates entry producers on each load", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
       let loads = 0
-      yield* registry.contribute({
+      yield* registry.register({
         key: SystemContext.Key.make("test/dynamic"),
         load: Effect.sync(() => {
           loads++
@@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
     }),
   )
 
-  it.effect("propagates contribution producer failures", () =>
+  it.effect("propagates entry producer failures", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
-      const failure = new Error("contribution failed")
-      yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
+      const failure = new Error("entry failed")
+      yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
 
       const exit = yield* registry.load().pipe(Effect.exit)
 
@@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
     }),
   )
 
-  it.effect("rejects duplicate source keys from separate contributions", () =>
+  it.effect("rejects duplicate source keys from separate entries", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
-      yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
-      yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
+      yield* registry.register(entry("test/first", "first", "test/duplicate"))
+      yield* registry.register(entry("test/second", "second", "test/duplicate"))
 
       const exit = yield* registry.load().pipe(Effect.exit)
 
@@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
     }),
   )
 
-  it.effect("rejects duplicate contribution keys", () =>
+  it.effect("rejects duplicate entry keys", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
-      yield* registry.contribute(contribution("test/duplicate", "first"))
+      yield* registry.register(entry("test/duplicate", "first"))
 
-      const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
+      const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
 
       expect(Exit.isFailure(exit)).toBe(true)
-      if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
+      if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
     }),
   )
 
-  it.effect("removes a contribution when its owning scope closes", () =>
+  it.effect("removes an entry when its owning scope closes", () =>
     Effect.gen(function* () {
       const registry = yield* SystemContextRegistry.Service
       const scope = yield* Scope.make()
-      yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
+      yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
 
       expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
 

+ 1 - 0
packages/opencode/package.json

@@ -89,6 +89,7 @@
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/server": "workspace:*",
+    "@opencode-ai/tui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@openrouter/ai-sdk-provider": "2.9.0",
     "@opentelemetry/api": "1.9.0",

+ 1 - 386
packages/opencode/parsers-config.ts

@@ -1,386 +1 @@
-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: "kotlin",
-      wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
-      queries: {
-        highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
-        locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/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: "vue",
-      wasm: "https://github.com/anomalyco/tree-sitter-vue/releases/download/v0.1.2/tree-sitter-vue.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/html_tags/highlights.scm",
-          "https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/vue/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "hcl",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
-        ],
-      },
-    },
-    {
-      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: "yaml",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/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: "lua",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
-        ],
-        locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.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",
-        ],
-      },
-    },
-    {
-      filetype: "clojure",
-      // temporarily using fork to fix issues
-      wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "swift",
-      wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
-      queries: {
-        highlights: [
-          // NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
-          // "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
-          "https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "toml",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "nix",
-      // TODO: Replace with official tree-sitter-nix WASM when published
-      // See: https://github.com/nix-community/tree-sitter-nix/issues/66
-      wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "diff",
-      aliases: ["udiff", "patch"],
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "elixir",
-      wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "fsharp",
-      wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "r",
-      wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "make",
-      aliases: ["makefile"],
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
-        ],
-      },
-    },
-    {
-      filetype: "vim",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "xml",
-      wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
-        ],
-        locals: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
-        ],
-      },
-    },
-    {
-      filetype: "agda",
-      wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
-      queries: {
-        highlights: [
-          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
-        ],
-      },
-    },
-  ],
-}
+export { default } from "@opencode-ai/tui/parsers-config"

+ 1 - 1
packages/opencode/script/build.ts

@@ -158,7 +158,7 @@ for (const item of targets) {
   const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
   const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
   const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
-  const workerPath = "./src/cli/cmd/tui/worker.ts"
+  const workerPath = "./src/cli/tui/worker.ts"
 
   // Use platform-specific bunfs root path based on target OS
   const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"

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

@@ -2,8 +2,8 @@
 
 import { Config } from "@/config/config"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
+import { TuiConfig } from "@opencode-ai/tui/config"
 import { Schema } from "effect"
-import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
 
 type JsonSchema = Record<string, unknown>
 const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
@@ -73,5 +73,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null,
 
 if (tuiFile) {
   console.log(tuiFile)
-  await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2))
+  await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2))
 }

+ 1 - 1
packages/opencode/script/trace-imports.ts

@@ -5,7 +5,7 @@ import * as ts from "typescript"
 const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
 
 // Get entry file from command line arg or use default
-const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
+const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts"
 
 const visited = new Set<string>()
 

+ 14 - 7
packages/opencode/src/cli/cmd/tui/attach.ts → packages/opencode/src/cli/cmd/attach.ts

@@ -1,9 +1,10 @@
-import { cmd } from "../cmd"
+import { cmd } from "./cmd"
 import { UI } from "@/cli/ui"
-import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
-import { errorMessage } from "@/util/error"
-import { validateSession } from "./validate-session"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
+import { errorMessage } from "@opencode-ai/tui/util/error"
+import { validateSession } from "../tui/validate-session"
 import { ServerAuth } from "@/server/auth"
+import { resolveTuiRuntime } from "../tui/runtime"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -44,7 +45,7 @@ export const AttachCommand = cmd({
         describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
       }),
   handler: async (args) => {
-    const { TuiConfig } = await import("@/cli/cmd/tui/config/tui")
+    const { TuiConfig } = await import("@/config/tui")
     const unguard = win32InstallCtrlCGuard()
     try {
       win32DisableProcessedInput()
@@ -67,6 +68,7 @@ export const AttachCommand = cmd({
       })()
       const headers = ServerAuth.headers({ password: args.password, username: args.username })
       const config = await TuiConfig.get()
+      const runtime = resolveTuiRuntime(config)
 
       try {
         await validateSession({
@@ -81,11 +83,16 @@ export const AttachCommand = cmd({
         return
       }
 
-      const { createTuiRenderer, tui } = await import("./app")
-      const renderer = await createTuiRenderer(config)
+      const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
+      const { createLegacyTuiHost } = await import("../tui/host")
+      const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
+      const renderer = await createTuiRenderer(config, runtime)
       const handle = tui({
+        ...runtime,
         url: args.url,
         config,
+        host: createLegacyTuiHost(renderer),
+        pluginHost: createLegacyTuiPluginHost(),
         renderer,
         args: {
           continue: args.continue,

+ 1 - 48
packages/opencode/src/cli/cmd/prompt-display.ts

@@ -1,48 +1 @@
-const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
-
-export function promptOffsetWidth(value: string) {
-  let width = 0
-  for (const part of graphemes.segment(value)) {
-    // Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
-    width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
-  }
-  return width
-}
-
-function displayOffsetIndex(value: string, offset: number) {
-  if (offset <= 0) return 0
-
-  let width = 0
-  for (const part of graphemes.segment(value)) {
-    const next = width + promptOffsetWidth(part.segment)
-    if (next > offset) return part.index
-    width = next
-  }
-
-  return value.length
-}
-
-export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
-  return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
-}
-
-export function displayCharAt(value: string, offset: number) {
-  let width = 0
-  for (const part of graphemes.segment(value)) {
-    const next = width + promptOffsetWidth(part.segment)
-    if (offset === width || offset < next) return part.segment
-    width = next
-  }
-}
-
-export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
-  const text = displaySlice(value, 0, offset)
-  const index = text.lastIndexOf("@")
-  if (index === -1) return
-
-  const before = index === 0 ? undefined : text[index - 1]
-  const query = text.slice(index)
-  if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
-    return promptOffsetWidth(text.slice(0, index))
-  }
-}
+export * from "@opencode-ai/tui/prompt/display"

+ 1 - 1
packages/opencode/src/cli/cmd/run/footer.prompt.tsx

@@ -22,7 +22,7 @@ import {
   movePromptHistory,
   pushPromptHistory,
 } from "./prompt.shared"
-import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap"
+import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
 import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
 import type { RunFooterTheme } from "./theme"
 import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"

+ 1 - 1
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -3,7 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"
 import { useKeyboard } from "@opentui/solid"
 import "opentui-spinner/solid"
 import { Show, createMemo, indexArray } from "solid-js"
-import { SPINNER_FRAMES } from "../tui/component/spinner"
+import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
 import { RunEntryContent, separatorRows } from "./scrollback.writer"
 import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
 import type { RunFooterTheme, RunTheme } from "./theme"

+ 1 - 1
packages/opencode/src/cli/cmd/run/footer.ts

@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
 import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
-import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap"
+import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
 import { withRunSpan } from "./otel"
 import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
 import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"

+ 2 - 2
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -13,7 +13,7 @@
 import { useTerminalDimensions } from "@opentui/solid"
 import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import "opentui-spinner/solid"
-import { createColors, createFrames } from "../tui/ui/spinner"
+import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
 import {
   RUN_SUBAGENT_PANEL_ROWS,
   RunCommandMenuBody,
@@ -33,7 +33,7 @@ import {
   useBindings,
   useKeymapSelector,
   type OpenTuiKeymap,
-} from "@/cli/cmd/tui/keymap"
+} from "@opencode-ai/tui/keymap"
 import type {
   FooterPromptRoute,
   FooterQueuedPrompt,

+ 3 - 11
packages/opencode/src/cli/cmd/run/runtime.boot.ts

@@ -6,17 +6,14 @@
 // history ring. All are async because they read config or hit the SDK, but
 // none block each other.
 import { Context, Effect, Layer } from "effect"
-import { createBindingLookup } from "@opentui/keymap/extras"
-import { TuiConfig } from "@/cli/cmd/tui/config/tui"
-import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
+import { resolve } from "@opencode-ai/tui/config"
+import { TuiConfig } from "@/config/tui"
 import { makeRuntime } from "@/effect/run-service"
 import { reusePendingTask } from "./runtime.shared"
 import { resolveSession, sessionHistory } from "./session.shared"
 import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
 import { pickVariant } from "./variant.shared"
 
-const DEFAULT_LEADER_TIMEOUT = 2000
-
 export type ModelInfo = {
   providers: RunProvider[]
   variants: string[]
@@ -70,13 +67,8 @@ function emptySessionInfo(): SessionInfo {
 }
 
 function defaultRunTuiConfig(): RunTuiConfig {
-  const keybinds = TuiKeybind.parse({})
   return {
-    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
-      commandMap: TuiKeybind.CommandMap,
-      bindingDefaults: TuiKeybind.bindingDefaults(),
-    }),
-    leader_timeout: DEFAULT_LEADER_TIMEOUT,
+    ...resolve({}, { terminalSuspend: process.platform !== "win32" }),
     diff_style: "auto",
   }
 }

+ 1 - 1
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -11,7 +11,7 @@
 import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import { Session as SessionApi } from "@/session/session"
-import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
+import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
 import * as Locale from "@/util/locale"
 import { withRunSpan } from "./otel"
 import { resolveInteractiveStdin } from "./runtime.stdin"

+ 1 - 1
packages/opencode/src/cli/cmd/run/theme.ts

@@ -590,7 +590,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
       : (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
     const theme = resolveTheme(generateSystem(colors, pick), pick)
     const indexed = indexedPalette(colors, 256)
-    const shared = await import("../tui/context/theme")
+    const shared = await import("@opencode-ai/tui/context/theme")
     const syntaxTheme: SharedSyntaxTheme = {
       ...theme,
       _hasSelectedListItemText: true,

+ 1 - 1
packages/opencode/src/cli/cmd/run/types.ts

@@ -12,7 +12,7 @@
 //       → footer.ts queues commits and patches the footer view
 //         → OpenTUI split-footer renderer writes to terminal
 import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
-import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import type { TuiConfig } from "@opencode-ai/tui/config"
 
 export type RunFilePart = {
   type: "file"

+ 17 - 10
packages/opencode/src/cli/cmd/tui/thread.ts → packages/opencode/src/cli/cmd/tui.ts

@@ -1,17 +1,17 @@
 import { cmd } from "@/cli/cmd/cmd"
 import { Rpc } from "@/util/rpc"
-import { type rpc } from "./worker"
+import { type rpc } from "../tui/worker"
 import path from "path"
 import { fileURLToPath } from "url"
 import { UI } from "@/cli/ui"
 import * as Log from "@opencode-ai/core/util/log"
-import { errorMessage } from "@/util/error"
+import { errorMessage } from "@opencode-ai/tui/util/error"
 import { withTimeout } from "@/util/timeout"
 import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import type { EventSource } from "./context/sdk"
-import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
+import type { EventSource } from "@opencode-ai/tui/context/sdk"
+import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
 import { writeHeapSnapshot } from "v8"
 import {
   OPENCODE_PROCESS_ROLE,
@@ -19,7 +19,8 @@ import {
   ensureRunID,
   sanitizedProcessEnv,
 } from "@opencode-ai/core/util/opencode-process"
-import { validateSession } from "./validate-session"
+import { validateSession } from "../tui/validate-session"
+import { resolveTuiRuntime } from "../tui/runtime"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -57,9 +58,9 @@ function createEventSource(client: RpcClient): EventSource {
 
 async function target() {
   if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
-  const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
+  const dist = new URL("./cli/tui/worker.js", import.meta.url)
   if (await Filesystem.exists(fileURLToPath(dist))) return dist
-  return new URL("./worker.ts", import.meta.url)
+  return new URL("../tui/worker.ts", import.meta.url)
 }
 
 async function input(value?: string) {
@@ -112,7 +113,7 @@ export const TuiThreadCommand = cmd({
         describe: "agent to use",
       }),
   handler: async (args) => {
-    const { TuiConfig } = await import("./config/tui")
+    const { TuiConfig } = await import("@/config/tui")
     // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
     // (Important when running under `bun run` wrappers on Windows.)
     const unguard = win32InstallCtrlCGuard()
@@ -188,6 +189,7 @@ export const TuiThreadCommand = cmd({
 
       const prompt = await input(args.prompt)
       const config = await TuiConfig.get()
+      const runtime = resolveTuiRuntime(config)
 
       const network = resolveNetworkOptionsNoConfig(args)
       const external =
@@ -228,9 +230,12 @@ export const TuiThreadCommand = cmd({
       }, 1000).unref?.()
 
       try {
-        const { createTuiRenderer, tui } = await import("./app")
-        const renderer = await createTuiRenderer(config)
+        const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
+        const { createLegacyTuiHost } = await import("../tui/host")
+        const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
+        const renderer = await createTuiRenderer(config, runtime)
         const handle = tui({
+          ...runtime,
           url: transport.url,
           renderer,
           async onSnapshot() {
@@ -239,6 +244,8 @@ export const TuiThreadCommand = cmd({
             return [tui, server]
           },
           config,
+          host: createLegacyTuiHost(renderer),
+          pluginHost: createLegacyTuiPluginHost(),
           directory: cwd,
           fetch: transport.fetch,
           events: transport.events,

+ 0 - 90
packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx

@@ -1,90 +0,0 @@
-import path from "path"
-import { Global } from "@opencode-ai/core/global"
-import { Filesystem } from "@/util/filesystem"
-import { onMount } from "solid-js"
-import { createStore } from "solid-js/store"
-import { createSimpleContext } from "../../context/helper"
-import { appendFile, writeFile } from "fs/promises"
-
-function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
-  if (!entry) return 0
-  const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
-  const weight = 1 / (1 + daysSince)
-  return entry.frequency * weight
-}
-
-const MAX_FRECENCY_ENTRIES = 1000
-
-export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
-  name: "Frecency",
-  init: () => {
-    const frecencyPath = path.join(Global.Path.state, "frecency.jsonl")
-    onMount(async () => {
-      const text = await Filesystem.readText(frecencyPath).catch(() => "")
-      const lines = text
-        .split("\n")
-        .filter(Boolean)
-        .map((line) => {
-          try {
-            return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
-          } catch {
-            return null
-          }
-        })
-        .filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
-
-      const latest = lines.reduce(
-        (acc, entry) => {
-          acc[entry.path] = entry
-          return acc
-        },
-        {} as Record<string, { path: string; frequency: number; lastOpen: number }>,
-      )
-
-      const sorted = Object.values(latest)
-        .sort((a, b) => b.lastOpen - a.lastOpen)
-        .slice(0, MAX_FRECENCY_ENTRIES)
-
-      setStore(
-        "data",
-        Object.fromEntries(
-          sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
-        ),
-      )
-
-      if (sorted.length > 0) {
-        const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
-        writeFile(frecencyPath, content).catch(() => {})
-      }
-    })
-
-    const [store, setStore] = createStore({
-      data: {} as Record<string, { frequency: number; lastOpen: number }>,
-    })
-
-    function updateFrecency(filePath: string) {
-      const absolutePath = path.resolve(process.cwd(), filePath)
-      const newEntry = {
-        frequency: (store.data[absolutePath]?.frequency || 0) + 1,
-        lastOpen: Date.now(),
-      }
-      setStore("data", absolutePath, newEntry)
-      appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
-
-      if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
-        const sorted = Object.entries(store.data)
-          .sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
-          .slice(0, MAX_FRECENCY_ENTRIES)
-        setStore("data", Object.fromEntries(sorted))
-        const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
-        writeFile(frecencyPath, content).catch(() => {})
-      }
-    }
-
-    return {
-      getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]),
-      updateFrecency,
-      data: () => store.data,
-    }
-  },
-})

+ 0 - 31
packages/opencode/src/cli/cmd/tui/component/prompt/part.ts

@@ -1,31 +0,0 @@
-import { PartID } from "@/session/schema"
-import { displaySlice } from "@/cli/cmd/prompt-display"
-import type { PromptInfo } from "./history"
-
-type Item = PromptInfo["parts"][number]
-
-export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
-  const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
-  return rest
-}
-
-export function assign(part: Item): Item & { id: PartID } {
-  return {
-    ...part,
-    id: PartID.ascending(),
-  }
-}
-
-export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
-  return parts.reduce((result, part) => {
-    if (part.type !== "text" || !part.source?.text) return result
-    return result.replace(part.source.text.value, part.text)
-  }, text)
-}
-
-export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
-  return ranges
-    .slice()
-    .sort((a, b) => b.start - a.start)
-    .reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
-}

+ 0 - 101
packages/opencode/src/cli/cmd/tui/component/prompt/stash.tsx

@@ -1,101 +0,0 @@
-import path from "path"
-import { Global } from "@opencode-ai/core/global"
-import { Filesystem } from "@/util/filesystem"
-import { onMount } from "solid-js"
-import { createStore, produce, unwrap } from "solid-js/store"
-import { createSimpleContext } from "../../context/helper"
-import { appendFile, writeFile } from "fs/promises"
-import type { PromptInfo } from "./history"
-
-export type StashEntry = {
-  input: string
-  parts: PromptInfo["parts"]
-  timestamp: number
-}
-
-const MAX_STASH_ENTRIES = 50
-
-export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
-  name: "PromptStash",
-  init: () => {
-    const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
-    onMount(async () => {
-      const text = await Filesystem.readText(stashPath).catch(() => "")
-      const lines = text
-        .split("\n")
-        .filter(Boolean)
-        .map((line) => {
-          try {
-            return JSON.parse(line)
-          } catch {
-            return null
-          }
-        })
-        .filter((line): line is StashEntry => line !== null)
-        .slice(-MAX_STASH_ENTRIES)
-
-      setStore("entries", lines)
-
-      // Rewrite file with only valid entries to self-heal corruption
-      if (lines.length > 0) {
-        const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
-        writeFile(stashPath, content).catch(() => {})
-      }
-    })
-
-    const [store, setStore] = createStore({
-      entries: [] as StashEntry[],
-    })
-
-    return {
-      list() {
-        return store.entries
-      },
-      push(entry: Omit<StashEntry, "timestamp">) {
-        const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
-        let trimmed = false
-        setStore(
-          produce((draft) => {
-            draft.entries.push(stash)
-            if (draft.entries.length > MAX_STASH_ENTRIES) {
-              draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
-              trimmed = true
-            }
-          }),
-        )
-
-        if (trimmed) {
-          const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
-          writeFile(stashPath, content).catch(() => {})
-          return
-        }
-
-        appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
-      },
-      pop() {
-        if (store.entries.length === 0) return undefined
-        const entry = store.entries[store.entries.length - 1]
-        setStore(
-          produce((draft) => {
-            draft.entries.pop()
-          }),
-        )
-        const content =
-          store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
-        writeFile(stashPath, content).catch(() => {})
-        return entry
-      },
-      remove(index: number) {
-        if (index < 0 || index >= store.entries.length) return
-        setStore(
-          produce((draft) => {
-            draft.entries.splice(index, 1)
-          }),
-        )
-        const content =
-          store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
-        writeFile(stashPath, content).catch(() => {})
-      },
-    }
-  },
-})

+ 0 - 88
packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

@@ -1,88 +0,0 @@
-import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
-import { TuiKeybind } from "./keybind"
-import { Schema } from "effect"
-import { isRecord } from "@/util/record"
-import { Filesystem } from "@/util/filesystem"
-import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
-
-export type TuiAttentionSoundPaths = Partial<Record<TuiAttentionSoundName, string>>
-
-export function isAttentionSoundName(value: string): value is TuiAttentionSoundName {
-  return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName)
-}
-
-export function resolveAttentionSoundPaths(
-  root: string,
-  sounds: unknown,
-  options?: { trim?: boolean },
-): TuiAttentionSoundPaths {
-  if (!isRecord(sounds)) return {}
-  return Object.fromEntries(
-    Object.entries(sounds).flatMap(([name, file]) => {
-      if (!isAttentionSoundName(name)) return []
-      if (typeof file !== "string") return []
-      const value = options?.trim ? file.trim() : file
-      if (!value) return []
-      return [[name, Filesystem.resolveFilePath(root, value)]]
-    }),
-  )
-}
-
-export const KeymapLeaderTimeoutDefault = 2000
-const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
-  description: "Leader key timeout in milliseconds",
-})
-
-const TuiAttentionSounds = Schema.Struct({
-  default: Schema.optional(Schema.String),
-  question: Schema.optional(Schema.String),
-  permission: Schema.optional(Schema.String),
-  error: Schema.optional(Schema.String),
-  done: Schema.optional(Schema.String),
-  subagent_done: Schema.optional(Schema.String),
-})
-
-export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
-
-export const ScrollAcceleration = Schema.Struct({
-  enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
-}).annotate({ description: "Scroll acceleration settings" })
-
-export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
-  description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
-})
-
-export const Attention = Schema.Struct({
-  enabled: Schema.optional(Schema.Boolean),
-  notifications: Schema.optional(Schema.Boolean),
-  sound: Schema.optional(Schema.Boolean),
-  volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
-  sound_pack: Schema.optional(Schema.String),
-  sounds: Schema.optional(TuiAttentionSounds),
-}).annotate({ description: "Attention notification and sound settings" })
-
-const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
-
-export const Prompt = Schema.Struct({
-  max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
-  max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
-    description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
-  }),
-}).annotate({ description: "Prompt size settings" })
-
-export const TuiInfo = Schema.Struct({
-  $schema: Schema.optional(Schema.String),
-  theme: Schema.optional(Schema.String),
-  keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
-  plugin: Schema.optional(Schema.Array(ConfigPluginV1.Spec)),
-  plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
-  leader_timeout: Schema.optional(KeymapLeaderTimeout),
-  attention: Schema.optional(Attention),
-  prompt: Schema.optional(Prompt),
-  scroll_speed: Schema.optional(ScrollSpeed).annotate({
-    description: "TUI scroll speed",
-  }),
-  scroll_acceleration: Schema.optional(ScrollAcceleration),
-  diff_style: Schema.optional(DiffStyle),
-  mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
-})

+ 0 - 9
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx

@@ -1,9 +0,0 @@
-import { TuiConfig } from "@/cli/cmd/tui/config/tui"
-import { createSimpleContext } from "./helper"
-
-export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
-  name: "TuiConfig",
-  init: (props: { config: TuiConfig.Resolved }) => {
-    return props.config
-  },
-})

+ 0 - 6
packages/opencode/src/cli/cmd/tui/layer.ts

@@ -1,6 +0,0 @@
-import { Layer } from "effect"
-import { TuiConfig } from "./config/tui"
-import { Npm } from "@opencode-ai/core/npm"
-import { Observability } from "@opencode-ai/core/effect/observability"
-
-export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

+ 0 - 42
packages/opencode/src/cli/cmd/tui/plugin/internal.ts

@@ -1,42 +0,0 @@
-import HomeFooter from "../feature-plugins/home/footer"
-import HomeTips from "../feature-plugins/home/tips"
-import SidebarContext from "../feature-plugins/sidebar/context"
-import SidebarMcp from "../feature-plugins/sidebar/mcp"
-import SidebarLsp from "../feature-plugins/sidebar/lsp"
-import SidebarTodo from "../feature-plugins/sidebar/todo"
-import SidebarFiles from "../feature-plugins/sidebar/files"
-import SidebarFooter from "../feature-plugins/sidebar/footer"
-import PluginManager from "../feature-plugins/system/plugins"
-import Notifications from "../feature-plugins/system/notifications"
-import SessionV2Debug from "../feature-plugins/system/session-v2"
-import WhichKey from "../feature-plugins/system/which-key"
-import DiffViewer from "../feature-plugins/system/diff-viewer"
-import SessionSwitcher from "../feature-plugins/session"
-import { Flag } from "@opencode-ai/core/flag/flag"
-import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
-import type { RuntimeFlags } from "@/effect/runtime-flags"
-
-export type InternalTuiPlugin = Omit<TuiPluginModule, "id"> & {
-  id: string
-  tui: TuiPlugin
-  enabled?: boolean
-}
-
-export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
-  return [
-    HomeFooter,
-    HomeTips,
-    SidebarContext,
-    SidebarMcp,
-    SidebarLsp,
-    SidebarTodo,
-    SidebarFiles,
-    SidebarFooter,
-    Notifications,
-    PluginManager,
-    WhichKey,
-    DiffViewer,
-    ...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
-    ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER ? [SessionSwitcher] : []),
-  ]
-}

+ 0 - 60
packages/opencode/src/cli/cmd/tui/plugin/slots.tsx

@@ -1,60 +0,0 @@
-import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
-import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
-import { isRecord } from "@/util/record"
-
-type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
-
-type Slot = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
-export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
-
-export type HostPluginApi = TuiPluginApi
-export type HostSlots = {
-  register: {
-    (plugin: HostSlotPlugin): () => void
-    <Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
-  }
-}
-
-function empty<Name extends string>(_props: TuiSlotProps<Name>) {
-  return null
-}
-
-let view: Slot = empty
-
-export const Slot: Slot = (props) => view(props)
-
-function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
-  if (!isRecord(value)) return false
-  if (typeof value.id !== "string") return false
-  if (!isRecord(value.slots)) return false
-  return true
-}
-
-export function setupSlots(api: HostPluginApi): HostSlots {
-  const reg = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
-    api.renderer,
-    {
-      theme: api.theme,
-    },
-    {
-      onPluginError(event) {
-        console.error("[tui.slot] plugin error", {
-          plugin: event.pluginId,
-          slot: event.slot,
-          phase: event.phase,
-          source: event.source,
-          message: event.error.message,
-        })
-      },
-    },
-  )
-
-  const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(reg)
-  view = (props) => slot(props)
-  return {
-    register(plugin: HostSlotPlugin) {
-      if (!isHostSlotPlugin(plugin)) return () => {}
-      return reg.register(plugin)
-    },
-  }
-}

+ 1 - 11
packages/opencode/src/cli/logo.ts

@@ -1,11 +1 @@
-export const logo = {
-  left: ["                   ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
-  right: ["             ▄     ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
-}
-
-export const go = {
-  left: ["    ", "█▀▀▀", "█_^█", "▀▀▀▀"],
-  right: ["    ", "█▀▀█", "█__█", "▀▀▀▀"],
-}
-
-export const marks = "_^~,"
+export * from "@opencode-ai/tui/logo"

+ 9 - 5
packages/opencode/src/cli/cmd/tui/attention.ts → packages/opencode/src/cli/tui/attention.ts

@@ -9,10 +9,10 @@ import type {
   TuiAttentionSoundPack,
   TuiAttentionSoundPackInfo,
 } from "@opencode-ai/plugin/tui"
+import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config"
+import { Schema } from "effect"
 import stripAnsi from "strip-ansi"
-import type { TuiConfig } from "./config/tui"
-import { isAttentionSoundName } from "./config/tui-schema"
-import * as TuiAudio from "@tui/util/audio"
+import * as TuiAudio from "./audio"
 import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
 import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
 import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
@@ -100,7 +100,9 @@ function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undef
     sounds: Object.fromEntries(
       Object.entries(pack.sounds).filter(
         (item): item is [TuiAttentionSoundName, string] =>
-          isAttentionSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
+          Schema.is(AttentionSoundName)(item[0]) &&
+          typeof item[1] === "string" &&
+          item[1].trim().length > 0,
       ),
     ),
   }
@@ -198,7 +200,9 @@ export function createTuiAttention(input: {
         const requestedSound = typeof request.sound === "object" ? request.sound : undefined
         const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
         const soundName =
-          requestedSound?.name && isAttentionSoundName(requestedSound.name) ? requestedSound.name : "default"
+          requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name)
+            ? requestedSound.name
+            : "default"
         const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
 
         if (!notification && !sound) {

+ 0 - 0
packages/opencode/src/cli/cmd/tui/util/audio.ts → packages/opencode/src/cli/tui/audio.ts


+ 3 - 3
packages/opencode/src/cli/cmd/tui/util/clipboard.ts → packages/opencode/src/cli/tui/clipboard.ts

@@ -1,13 +1,13 @@
 import { platform, release } from "os"
-import { lazy } from "../../../../util/lazy.js"
+import { lazy } from "../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
 import fs from "fs/promises"
 import { Effect } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { AppProcess } from "@opencode-ai/core/process"
-import * as Filesystem from "../../../../util/filesystem"
-import * as Process from "../../../../util/process"
+import * as Filesystem from "../../util/filesystem"
+import * as Process from "../../util/process"
 
 const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
   Effect.runPromise(

+ 3 - 3
packages/opencode/src/cli/cmd/tui/context/editor-zed.ts → packages/opencode/src/cli/tui/editor-zed.ts

@@ -1,9 +1,9 @@
 import { Database } from "bun:sqlite"
+import { statSync } from "node:fs"
 import os from "node:os"
 import path from "node:path"
 import { Option, Schema } from "effect"
-import { Filesystem } from "@/util/filesystem"
-import type { EditorSelection } from "./editor"
+import type { EditorSelection } from "@opencode-ai/tui/context/editor"
 
 const ZedEditorRowSchema = Schema.Struct({
   item_kind: Schema.String,
@@ -201,7 +201,7 @@ export function isZedTerminal() {
 
 function isFile(item: string) {
   try {
-    return Filesystem.stat(item)?.isFile() === true
+    return statSync(item).isFile()
   } catch {
     return false
   }

+ 0 - 0
packages/opencode/src/cli/cmd/tui/util/editor.ts → packages/opencode/src/cli/tui/editor.ts


+ 36 - 0
packages/opencode/src/cli/tui/host.ts

@@ -0,0 +1,36 @@
+import type { TuiHost, TuiInput } from "@opencode-ai/tui"
+import { Log } from "@opencode-ai/core/util/log"
+import { FormatError, FormatUnknownError } from "@/cli/error"
+import { createTuiAttention } from "./attention"
+import { createLegacyTuiPlatform } from "./platform"
+import * as TuiAudio from "./audio"
+import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
+
+export function createLegacyTuiHost(renderer: TuiInput["renderer"]): TuiHost {
+  return {
+    platform: createLegacyTuiPlatform(renderer),
+    attention: createTuiAttention,
+    logger: Log.Default,
+    disposeAudio: TuiAudio.dispose,
+    formatError: FormatError,
+    formatUnknownError: FormatUnknownError,
+    lifecycle: {
+      prepare() {
+        const unguard = win32InstallCtrlCGuard()
+        win32DisableProcessedInput()
+        return unguard
+      },
+      flushInput: win32FlushInputBuffer,
+      onSighup(handler) {
+        process.on("SIGHUP", handler)
+        return () => process.off("SIGHUP", handler)
+      },
+      writeStdout: (text) => process.stdout.write(text),
+      writeStderr: (text) => process.stderr.write(text),
+      suspend(resume) {
+        process.once("SIGCONT", resume)
+        process.kill(0, "SIGTSTP")
+      },
+    },
+  }
+}

+ 120 - 0
packages/opencode/src/cli/tui/platform.ts

@@ -0,0 +1,120 @@
+import type { CliRenderer } from "@opentui/core"
+import type { TuiPlatform } from "@opencode-ai/tui/platform"
+import { Filesystem } from "@/util/filesystem"
+import { Clipboard } from "./clipboard"
+import { Editor } from "./editor"
+import { Flock } from "@opencode-ai/core/util/flock"
+import { Glob } from "@opencode-ai/core/util/glob"
+import { Global } from "@opencode-ai/core/global"
+import { readJson, writeJsonAtomic } from "@opencode-ai/tui/util/persistence"
+import path from "path"
+import os from "node:os"
+import { readdirSync, readFileSync, statSync } from "node:fs"
+import { resolveZedSelection } from "./editor-zed"
+
+export function createLegacyTuiPlatform(renderer: CliRenderer): TuiPlatform {
+  const statePath = path.join(Global.Path.state, "kv.json")
+  const stateLock = `tui-kv:${statePath}`
+  return {
+    files: {
+      readText: Filesystem.readText,
+      readBytes: Filesystem.readBytes,
+      mime: Filesystem.mimeType,
+    },
+    state: {
+      read: () => Flock.withLock(stateLock, () => readJson<Record<string, unknown>>(statePath)),
+      write: (value) => Flock.withLock(stateLock, () => writeJsonAtomic(statePath, value)),
+    },
+    themes: {
+      async discover() {
+        const directories = [
+          Global.Path.config,
+          ...(await Array.fromAsync(Filesystem.up({ targets: [".opencode"], start: process.cwd() }))),
+        ]
+        const result: Record<string, unknown> = {}
+        for (const dir of directories) {
+          for (const item of await Glob.scan("themes/*.json", {
+            cwd: dir,
+            absolute: true,
+            dot: true,
+            symlink: true,
+          })) {
+            result[path.basename(item, ".json")] = await Filesystem.readJson(item)
+          }
+        }
+        return result
+      },
+      subscribeRefresh(refresh) {
+        process.on("SIGUSR2", refresh)
+        return () => process.off("SIGUSR2", refresh)
+      },
+    },
+    clipboard: {
+      read: Clipboard.read,
+      write: Clipboard.copy,
+    },
+    editor: {
+      open: (input) => Editor.open({ ...input, renderer }),
+      connection: discoverEditorConnection,
+      selection: (directory) => resolveZedSelection(resolveZedDbPath(), directory),
+    },
+    export: {
+      write: Filesystem.write,
+    },
+  }
+}
+
+export function discoverEditorConnection(directory: string) {
+  const root = path.join(os.homedir(), ".claude", "ide")
+  const contains = (parent: string) => {
+    const resolved = path.resolve(parent)
+    const relative = path.relative(resolved, path.resolve(directory))
+    return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
+  }
+  try {
+    return readdirSync(root)
+      .filter((entry) => entry.endsWith(".lock"))
+      .flatMap((entry) => {
+        const file = path.join(root, entry)
+        const port = Number.parseInt(path.basename(file, ".lock"), 10)
+        if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
+        try {
+          const value = JSON.parse(readFileSync(file, "utf-8")) as Record<string, unknown>
+          if (value.transport !== undefined && value.transport !== "ws") return []
+          const folders = Array.isArray(value.workspaceFolders)
+            ? value.workspaceFolders.filter((item): item is string => typeof item === "string")
+            : []
+          const score = Math.max(0, ...folders.map(contains))
+          if (!score) return []
+          return [{
+            url: `ws://127.0.0.1:${port}`,
+            authToken: typeof value.authToken === "string" ? value.authToken : undefined,
+            source: `lock:${port}`,
+            score,
+            mtime: statSync(file).mtimeMs,
+          }]
+        } catch {
+          return []
+        }
+      })
+      .sort((left, right) => right.score - left.score || right.mtime - left.mtime)
+      .map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
+  } catch {
+    return undefined
+  }
+}
+
+function resolveZedDbPath() {
+  const candidates = [
+    process.env.OPENCODE_ZED_DB,
+    path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
+    path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
+  ].filter((item): item is string => Boolean(item))
+  return candidates.find((item) => {
+    try {
+      return statSync(item).isFile()
+    } catch {
+      return false
+    }
+  }) ?? ""
+}

+ 57 - 0
packages/opencode/src/cli/tui/runtime.ts

@@ -0,0 +1,57 @@
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Global } from "@opencode-ai/core/global"
+import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
+import type { TuiConfig } from "@opencode-ai/tui/config"
+import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
+import path from "path"
+import { isZedTerminal, resolveZedDbPath } from "./editor-zed"
+
+export function resolveTuiRuntime(config: TuiConfig.Resolved) {
+  return {
+    environment: createTuiEnvironment({
+      cwd: process.cwd(),
+      platform: process.platform,
+      initialRoute: parseInitialRoute(process.env.OPENCODE_ROUTE),
+      paths: {
+        home: Global.Path.home,
+        state: Global.Path.state,
+        worktree: path.join(Global.Path.data, "worktree"),
+      },
+      capabilities: {
+        mouse: !Flag.OPENCODE_DISABLE_MOUSE && (config.mouse ?? true),
+        copyOnSelect: !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT,
+        terminalTitle: !Flag.OPENCODE_DISABLE_TERMINAL_TITLE,
+        terminalSuspend: process.platform !== "win32",
+        workspaces: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
+        showTimeToFirstDraw: Flag.OPENCODE_SHOW_TTFD,
+      },
+      terminal: {
+        multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
+        displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
+      },
+      editor: {
+        command: process.env.VISUAL || process.env.EDITOR,
+        port: parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT),
+        zedTerminal: isZedTerminal(),
+        zedDatabase: resolveZedDbPath(),
+      },
+      skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
+    }),
+    build: createTuiBuildInfo({
+      version: InstallationVersion,
+      channel: InstallationChannel,
+    }),
+  }
+}
+
+function parsePort(value: string | undefined) {
+  if (!value) return
+  const port = Number.parseInt(value, 10)
+  if (!Number.isInteger(port) || port <= 0 || port > 65535) return
+  return port
+}
+
+function parseInitialRoute(value: string | undefined) {
+  if (!value) return
+  return JSON.parse(value) as unknown
+}

+ 0 - 0
packages/opencode/src/cli/cmd/tui/validate-session.ts → packages/opencode/src/cli/tui/validate-session.ts


+ 0 - 0
packages/opencode/src/cli/cmd/tui/win32.ts → packages/opencode/src/cli/tui/win32.ts


+ 0 - 0
packages/opencode/src/cli/cmd/tui/worker.ts → packages/opencode/src/cli/tui/worker.ts


+ 0 - 0
packages/opencode/src/cli/cmd/tui/config/cwd.ts → packages/opencode/src/config/tui-cwd.ts


+ 21 - 0
packages/opencode/src/config/tui-host-attention.ts

@@ -0,0 +1,21 @@
+import { TuiConfig } from "@opencode-ai/tui/config"
+import { isRecord } from "@opencode-ai/tui/util/record"
+import { Filesystem } from "@/util/filesystem"
+import { Schema } from "effect"
+
+export function resolveHostAttentionSoundPaths(
+  root: string,
+  sounds: unknown,
+  options?: { trim?: boolean },
+): TuiConfig.AttentionSoundPaths {
+  if (!isRecord(sounds)) return {}
+  return Object.fromEntries(
+    Object.entries(sounds).flatMap(([name, file]) => {
+      if (!Schema.is(TuiConfig.AttentionSoundName)(name)) return []
+      if (typeof file !== "string") return []
+      const value = options?.trim ? file.trim() : file
+      if (!value) return []
+      return [[name, Filesystem.resolveFilePath(root, value)]]
+    }),
+  )
+}

+ 4 - 4
packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts → packages/opencode/src/config/tui-migrate.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
 import { unique } from "remeda"
 import { Option, Schema } from "effect"
-import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema"
+import { TuiConfig } from "@opencode-ai/tui/config"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { Filesystem } from "@/util/filesystem"
@@ -15,9 +15,9 @@ const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
 
 const decodeTheme = Schema.decodeUnknownOption(Schema.String)
 const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
-const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed)
-const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration)
-const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle)
+const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed)
+const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration)
+const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle)
 
 interface MigrateInput {
   cwd: string

+ 37 - 53
packages/opencode/src/cli/cmd/tui/config/tui.ts → packages/opencode/src/config/tui.ts

@@ -1,57 +1,47 @@
 export * as TuiConfig from "./tui"
 
 import path from "path"
-import { createBindingLookup } from "@opentui/keymap/extras"
 import { mergeDeep, unique } from "remeda"
-import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
+import { Cause, Context, Effect, Fiber, Layer } from "effect"
 import { ConfigParse } from "@/config/parse"
 import * as ConfigPaths from "@/config/paths"
 import { migrateTuiConfig } from "./tui-migrate"
-import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
+import { resolveHostAttentionSoundPaths } from "./tui-host-attention"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { isRecord } from "@/util/record"
+import { isRecord } from "@opencode-ai/tui/util/record"
 import { Global } from "@opencode-ai/core/global"
 import { FSUtil } from "@opencode-ai/core/fs-util"
-import { CurrentWorkingDirectory } from "./cwd"
+import { CurrentWorkingDirectory } from "./tui-cwd"
 import { ConfigPlugin } from "@/config/plugin"
-import { TuiKeybind } from "./keybind"
+import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
 import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { Filesystem } from "@/util/filesystem"
 import * as Log from "@opencode-ai/core/util/log"
 import { ConfigVariable } from "@/config/variable"
 import { Npm } from "@opencode-ai/core/npm"
-import type { DeepMutable } from "@opencode-ai/core/schema"
-import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
 import { FormatError, FormatUnknownError } from "@/cli/error"
+import { TuiConfig } from "@opencode-ai/tui/config"
 
 const log = Log.create({ service: "tui.config" })
 
-export const Info = TuiInfo
-export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
+export const Info = TuiConfig.Info
+export type Info = TuiConfig.Info
 
 type Acc = {
   result: Info
   plugin_origins: ConfigPlugin.Origin[]
 }
 
-export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
-  attention: {
-    enabled: boolean
-    notifications: boolean
-    sound: boolean
-    volume: number
-    sound_pack: string
-    sounds: Partial<Record<TuiAttentionSoundName, string>>
-  }
-  keybinds: TuiKeybind.BindingLookupView
-  leader_timeout: number
-  // Internal resolved plugin list used by runtime loading.
+export type Resolved = TuiConfig.Resolved
+
+export type HostMetadata = {
   plugin_origins?: ConfigPlugin.Origin[]
 }
 
 export interface Interface {
   readonly get: () => Effect.Effect<Resolved>
+  readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
   readonly waitForDependencies: () => Effect.Effect<void>
 }
 
@@ -104,10 +94,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
     Effect.gen(function* () {
       const plugins = config.plugin
       if (!plugins) return config
-      for (let i = 0; i < plugins.length; i++) {
-        plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
+      return {
+        ...config,
+        plugin: yield* Effect.forEach(plugins, (plugin) =>
+          Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)),
+        ),
       }
-      return config
     })
 
   const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
@@ -126,7 +118,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
             ...parsed,
             attention: {
               ...parsed.attention,
-              sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
+              sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
             },
           }
         : parsed
@@ -183,9 +175,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
       const scope = pluginScope(file, ctx)
       const plugins = ConfigPlugin.deduplicatePluginOrigins([
         ...acc.plugin_origins,
-        ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+        ...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })),
       ])
-      acc.result.plugin = plugins.map((item) => item.spec)
+      acc.result = {
+        ...acc.result,
+        plugin: plugins.map((item) => item.spec),
+      }
       acc.plugin_origins = plugins
     })
 
@@ -230,34 +225,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
     }
   }
 
-  const keybinds = { ...acc.result.keybinds }
-  if (process.platform === "win32") {
-    // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
-    keybinds.terminal_suspend = "none"
-    const inputUndo = TuiKeybind.defaultValue("input_undo")
-    keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",")
-  }
-  const parsedKeybinds = TuiKeybind.parse(keybinds)
-  const result: Resolved = {
-    ...acc.result,
-    attention: {
-      enabled: acc.result.attention?.enabled ?? false,
-      notifications: acc.result.attention?.notifications ?? true,
-      sound: acc.result.attention?.sound ?? true,
-      volume: acc.result.attention?.volume ?? 0.4,
-      sound_pack: acc.result.attention?.sound_pack ?? "opencode.default",
-      sounds: acc.result.attention?.sounds ?? {},
+  const result = TuiConfig.resolve(
+    {
+      ...acc.result,
     },
-    keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
-      commandMap: TuiKeybind.CommandMap,
-      bindingDefaults: TuiKeybind.bindingDefaults(),
-    }),
-    leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault,
-    plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
-  }
+    {
+      terminalSuspend: process.platform !== "win32",
+    },
+  )
 
   return {
     config: result,
+    pluginOrigins: acc.plugin_origins,
     dirs: result.plugin?.length ? dirs : [],
   }
 })
@@ -287,11 +266,12 @@ export const layer = Layer.effect(
     )
 
     const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+    const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins))
 
     const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
       Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
     )
-    return Service.of({ get, waitForDependencies })
+    return Service.of({ get, pluginOrigins, waitForDependencies })
   }).pipe(Effect.withSpan("TuiConfig.layer")),
 )
 
@@ -306,3 +286,7 @@ export async function waitForDependencies() {
 export async function get() {
   return runPromise((svc) => svc.get())
 }
+
+export async function pluginOrigins() {
+  return runPromise((svc) => svc.pluginOrigins())
+}

+ 2 - 2
packages/opencode/src/index.ts

@@ -21,8 +21,8 @@ import { McpCommand } from "./cli/cmd/mcp"
 import { GithubCommand } from "./cli/cmd/github"
 import { ExportCommand } from "./cli/cmd/export"
 import { ImportCommand } from "./cli/cmd/import"
-import { AttachCommand } from "./cli/cmd/tui/attach"
-import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { AttachCommand } from "./cli/cmd/attach"
+import { TuiThreadCommand } from "./cli/cmd/tui"
 import { AcpCommand } from "./cli/cmd/acp"
 import { EOL } from "os"
 import { WebCommand } from "./cli/cmd/web"

+ 1 - 1
packages/opencode/src/mcp/index.ts

@@ -25,7 +25,7 @@ import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
-import { TuiEvent } from "@/cli/cmd/tui/event"
+import { TuiEvent } from "@/server/tui-event"
 import open from "open"
 import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
 import { EffectBridge } from "@/effect/bridge"

+ 12 - 0
packages/opencode/src/plugin/tui/internal.ts

@@ -0,0 +1,12 @@
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
+import type { RuntimeFlags } from "@/effect/runtime-flags"
+
+export type InternalTuiPlugin = BuiltinTuiPlugin
+
+export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
+  return createBuiltinPlugins({
+    experimentalEventSystem: flags.experimentalEventSystem,
+    experimentalSessionSwitcher: Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER,
+  })
+}

+ 65 - 22
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts → packages/opencode/src/plugin/tui/runtime.ts

@@ -13,11 +13,11 @@ import {
 } from "@opencode-ai/plugin/tui"
 import path from "path"
 import { fileURLToPath } from "url"
-import { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { TuiConfig } from "@/config/tui"
 import * as Log from "@opencode-ai/core/util/log"
-import { errorData, errorMessage } from "@/util/error"
-import { isRecord } from "@/util/record"
-import { resolveAttentionSoundPaths } from "../config/tui-schema"
+import { errorData, errorMessage } from "@opencode-ai/tui/util/error"
+import { isRecord } from "@opencode-ai/tui/util/record"
+import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention"
 import {
   readPackageThemes,
   readPluginId,
@@ -29,20 +29,20 @@ import {
 import { PluginLoader } from "@/plugin/loader"
 import { PluginMeta } from "@/plugin/meta"
 import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
-import { hasTheme, upsertTheme } from "../context/theme"
+import { hasTheme, upsertTheme } from "@opencode-ai/tui/context/theme"
 import { Global } from "@opencode-ai/core/global"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
 import { Flock } from "@opencode-ai/core/util/flock"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { internalTuiPlugins, type InternalTuiPlugin } from "./internal"
-import { setupSlots, Slot as View } from "./slots"
-import type { HostPluginApi, HostSlots } from "./slots"
+import type { HostPluginApi, HostSlots } from "@opencode-ai/tui/plugin/slots"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
-import { createCommandShim } from "./command-shim"
+import { createCommandShim } from "@opencode-ai/tui/plugin/command-shim"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { Effect } from "effect"
+import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
 
 ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
 
@@ -110,6 +110,7 @@ const ScopedKeymapMethods = new Set<PropertyKey>([
 type RuntimeState = {
   directory: string
   api: Api
+  view: PluginRuntime
   dispose?: () => void
   slots: HostSlots
   plugins: PluginEntry[]
@@ -176,7 +177,7 @@ function createScopedAttention(
         return scope.track(
           attention.soundboard.registerPack({
             ...pack,
-            sounds: resolveAttentionSoundPaths(root, pack.sounds, { trim: true }),
+            sounds: resolveHostAttentionSoundPaths(root, pack.sounds, { trim: true }),
           }),
         )
       },
@@ -524,17 +525,24 @@ function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
 async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
   plugin.enabled = false
   if (persist) writePluginEnabledState(state.api, plugin.id, false)
-  if (!plugin.scope) return true
+  if (!plugin.scope) {
+    state.view.update({ status: listPluginStatus(state) })
+    return true
+  }
   const scope = plugin.scope
   plugin.scope = undefined
   await scope.dispose()
+  state.view.update({ status: listPluginStatus(state) })
   return true
 }
 
 async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
   plugin.enabled = true
   if (persist) writePluginEnabledState(state.api, plugin.id, true)
-  if (plugin.scope) return true
+  if (plugin.scope) {
+    state.view.update({ status: listPluginStatus(state) })
+    return true
+  }
 
   const scope = createPluginScope(plugin.load, plugin.id, state.dispose_timeout_ms)
   const api = pluginApi(state, plugin, scope, plugin.id)
@@ -555,15 +563,18 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
 
   if (!ok) {
     await scope.dispose()
+    state.view.update({ status: listPluginStatus(state) })
     return false
   }
 
   if (!plugin.enabled) {
     await scope.dispose()
+    state.view.update({ status: listPluginStatus(state) })
     return true
   }
 
   plugin.scope = scope
+  state.view.update({ status: listPluginStatus(state) })
   return true
 }
 
@@ -1014,11 +1025,11 @@ async function installPluginBySpec(
 let dir = ""
 let loaded: Promise<void> | undefined
 let runtime: RuntimeState | undefined
-export const Slot = View
 
 export async function init(input: {
   api: HostPluginApi
-  config: TuiConfig.Resolved
+  config: TuiConfig.Resolved & TuiConfig.HostMetadata
+  runtime?: PluginRuntime
   dispose?: () => void
   disposeTimeoutMs?: number
 }) {
@@ -1031,7 +1042,7 @@ export async function init(input: {
   }
 
   dir = cwd
-  loaded = load(input)
+  loaded = load({ ...input, runtime: input.runtime ?? createPluginRuntime() })
   return loaded
 }
 
@@ -1060,24 +1071,38 @@ export async function dispose() {
   const task = loaded
   loaded = undefined
   dir = ""
-  if (task) await task
+  if (task) await task.catch((error) => fail("failed to finish loading tui plugins during disposal", { error }))
   const state = runtime
   runtime = undefined
   if (!state) return
   const queue = [...state.plugins].reverse()
   for (const plugin of queue) {
-    await deactivatePluginEntry(state, plugin, false)
+    await deactivatePluginEntry(state, plugin, false).catch((error) =>
+      fail("failed to dispose tui plugin", { id: plugin.id, error }),
+    )
+  }
+  try {
+    state.dispose?.()
+  } finally {
+    state.slots.dispose()
+    state.view.clear()
   }
-  state.dispose?.()
 }
 
-async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void; disposeTimeoutMs?: number }) {
+async function load(input: {
+  api: Api
+  config: TuiConfig.Resolved & TuiConfig.HostMetadata
+  runtime: PluginRuntime
+  dispose?: () => void
+  disposeTimeoutMs?: number
+}) {
   const { api, config } = input
   const cwd = process.cwd()
-  const slots = setupSlots(api)
+  const slots = input.runtime.setupSlots(api)
   const next: RuntimeState = {
     directory: cwd,
     api,
+    view: input.runtime,
     dispose: input.dispose,
     slots,
     plugins: [],
@@ -1086,15 +1111,25 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
     dispose_timeout_ms: input.disposeTimeoutMs ?? DISPOSE_TIMEOUT_MS,
   }
   runtime = next
+  next.view.update({
+    commands: {
+      activate: activatePlugin,
+      deactivate: deactivatePlugin,
+      add: addPlugin,
+      install: installPlugin,
+    },
+    status: listPluginStatus(next),
+  })
   try {
     const flags = await Effect.runPromise(
       Effect.gen(function* () {
         return yield* RuntimeFlags.Service
       }).pipe(Effect.provide(RuntimeFlags.defaultLayer)),
     )
-    const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
-    if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
-      log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
+    const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins())
+    const records = Flag.OPENCODE_PURE ? [] : pluginOrigins
+    if (Flag.OPENCODE_PURE && pluginOrigins.length) {
+      log.info("skipping external tui plugins in pure mode", { count: pluginOrigins.length })
     }
 
     for (const item of internalTuiPlugins(flags)) {
@@ -1123,9 +1158,17 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
       // and hook chains rely on stable plugin ordering.
       await activatePluginEntry(next, plugin, false)
     }
+    next.view.update({ status: listPluginStatus(next) })
   } catch (error) {
     fail("failed to load tui plugins", { directory: cwd, error })
   }
 }
 
+export function createLegacyTuiPluginHost(): TuiPluginHost {
+  return {
+    start: init,
+    dispose,
+  }
+}
+
 export * as TuiPluginRuntime from "./runtime"

+ 4 - 2
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -17,11 +17,12 @@ import { ProjectCopyApi } from "./groups/project-copy"
 import { ProviderApi } from "./groups/provider"
 import { PtyApi, PtyConnectApi } from "./groups/pty"
 import { QuestionApi } from "./groups/question"
+import { ReferenceApi } from "./groups/reference"
 import { SessionApi } from "./groups/session"
 import { SyncApi } from "./groups/sync"
 import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
-import { V2Api } from "@opencode-ai/server/api"
+import { Api } from "@opencode-ai/server/api"
 // GlobalEventSchema snapshots the registry after event-producing groups register their variants.
 import { GlobalApi } from "./groups/global"
 import { Authorization } from "./middleware/authorization"
@@ -60,6 +61,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
   .addHttpApi(QuestionApi)
   .addHttpApi(PermissionApi)
   .addHttpApi(ProviderApi)
+  .addHttpApi(ReferenceApi)
   .addHttpApi(SessionApi)
   .addHttpApi(SyncApi)
   .addHttpApi(TuiApi)
@@ -70,7 +72,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
   .addHttpApi(RootHttpApi)
   .addHttpApi(EventApi)
   .addHttpApi(InstanceHttpApi)
-  .addHttpApi(V2Api)
+  .addHttpApi(Api)
   .addHttpApi(PtyConnectApi)
   .annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])
 

+ 60 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/reference.ts

@@ -0,0 +1,60 @@
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "../middleware/authorization"
+import { InstanceContextMiddleware } from "../middleware/instance-context"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
+import { described } from "./metadata"
+
+export const ReferenceDescriptor = Schema.Union([
+  Schema.Struct({
+    name: Schema.String,
+    kind: Schema.Literal("local"),
+    path: Schema.String,
+  }),
+  Schema.Struct({
+    name: Schema.String,
+    kind: Schema.Literal("git"),
+    repository: Schema.String,
+    path: Schema.String,
+    branch: Schema.optional(Schema.String),
+  }),
+  Schema.Struct({
+    name: Schema.String,
+    kind: Schema.Literal("invalid"),
+    repository: Schema.optional(Schema.String),
+    message: Schema.String,
+  }),
+]).annotate({ identifier: "ReferenceDescriptor" })
+
+export const ReferenceApi = HttpApi.make("reference")
+  .add(
+    HttpApiGroup.make("reference")
+      .add(
+        HttpApiEndpoint.get("list", "/reference", {
+          query: WorkspaceRoutingQuery,
+          success: described(Schema.Array(ReferenceDescriptor), "Resolved configured references"),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "reference.list",
+            summary: "List configured references",
+            description: "List configured references resolved in the current workspace.",
+          }),
+        ),
+      )
+      .annotateMerge(
+        OpenApi.annotations({
+          title: "reference",
+          description: "Configured reference routes.",
+        }),
+      )
+      .middleware(InstanceContextMiddleware)
+      .middleware(WorkspaceRoutingMiddleware)
+      .middleware(Authorization),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts

@@ -1,4 +1,4 @@
-import { TuiEvent } from "@/cli/cmd/tui/event"
+import { TuiEvent } from "@/server/tui-event"
 import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"

+ 27 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/reference.ts

@@ -0,0 +1,27 @@
+import { Reference } from "@/reference/reference"
+import { Effect } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../api"
+
+export const referenceHandlers = HttpApiBuilder.group(InstanceHttpApi, "reference", (handlers) =>
+  Effect.gen(function* () {
+    const reference = yield* Reference.Service
+
+    return handlers.handle("list", () =>
+      reference.list().pipe(
+        Effect.map((references) =>
+          references.map((item) => {
+            if (item.kind !== "git") return item
+            return {
+              name: item.name,
+              kind: item.kind,
+              repository: item.repository,
+              path: item.path,
+              ...(item.branch !== undefined ? { branch: item.branch } : {}),
+            }
+          }),
+        ),
+      ),
+    )
+  }),
+)

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts

@@ -1,5 +1,5 @@
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { TuiEvent } from "@/cli/cmd/tui/event"
+import { TuiEvent } from "@/server/tui-event"
 import { Session } from "@/session/session"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

@@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
 import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
 import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
 import { isPublicUIPath } from "@/server/shared/public-ui"
-export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
+export { Authorization as ServerAuthorization, authorizationLayer as serverAuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
 
 const AUTH_TOKEN_QUERY = "auth_token"
 const UNAUTHORIZED = 401

+ 12 - 8
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -35,6 +35,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev"
 import { Provider } from "@/provider/provider"
 import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { Question } from "@/question"
+import { Reference } from "@/reference/reference"
 import { Session } from "@/session/session"
 import { SessionCompaction } from "@/session/compaction"
 import { LLM } from "@/session/llm"
@@ -60,13 +61,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
 import { serveUIEffect } from "@/server/shared/ui"
 import { ServerAuth } from "@/server/auth"
 import { InstanceHttpApi, RootHttpApi } from "./api"
-import { V2Api } from "@opencode-ai/server/api"
+import { Api } from "@opencode-ai/server/api"
 import { PublicApi } from "./public"
 import {
   authorizationLayer,
   authorizationRouterMiddleware,
   ptyConnectAuthorizationLayer,
-  v2AuthorizationLayer,
+  serverAuthorizationLayer,
 } from "./middleware/authorization"
 import { EventApi } from "./groups/event"
 import { PtyConnectApi } from "./groups/pty"
@@ -85,10 +86,11 @@ import { projectCopyHandlers } from "./handlers/project-copy"
 import { providerHandlers } from "./handlers/provider"
 import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
 import { questionHandlers } from "./handlers/question"
+import { referenceHandlers } from "./handlers/reference"
 import { sessionHandlers } from "./handlers/session"
 import { syncHandlers } from "./handlers/sync"
 import { tuiHandlers } from "./handlers/tui"
-import { v2Handlers } from "@opencode-ai/server/handlers"
+import { handlers } from "@opencode-ai/server/handlers"
 import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
 import { workspaceHandlers } from "./handlers/workspace"
 import { instanceContextLayer } from "./middleware/instance-context"
@@ -121,7 +123,7 @@ const cors = (corsOptions?: CorsOptions) =>
 const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
-const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
+const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
 const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
   Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
@@ -147,6 +149,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
     projectCopyHandlers,
     ptyHandlers,
     questionHandlers,
+    referenceHandlers,
     permissionHandlers,
     providerHandlers,
     sessionHandlers,
@@ -159,9 +162,9 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
 const instanceRoutes = instanceApiRoutes.pipe(
   Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
 )
-const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
-  Layer.provide(v2Handlers),
-  Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
+const serverRoutes = HttpApiBuilder.layer(Api).pipe(
+  Layer.provide(handlers),
+  Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
 )
 
 // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@@ -201,7 +204,7 @@ export function createRoutes(
     eventApiRoutes,
     ptyConnectApiRoutes,
     instanceRoutes,
-    v2Routes,
+    serverRoutes,
     docRoute,
     uiRoute,
   ).pipe(
@@ -234,6 +237,7 @@ export function createRoutes(
       Provider.defaultLayer,
       PtyTicket.defaultLayer,
       Question.defaultLayer,
+      Reference.defaultLayer,
       Ripgrep.defaultLayer,
       RuntimeFlags.defaultLayer,
       Session.defaultLayer,

+ 0 - 0
packages/opencode/src/cli/cmd/tui/event.ts → packages/opencode/src/server/tui-event.ts


+ 2 - 0
packages/opencode/src/session/session.ts

@@ -12,6 +12,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionExecution } from "@opencode-ai/core/session/execution"
 
 import { NotFoundError } from "@/storage/storage"
 import { eq } from "drizzle-orm"
@@ -967,6 +968,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(BackgroundJob.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(EventV2Bridge.defaultLayer),
+  Layer.provide(SessionExecution.noopLayer),
   Layer.provide(SessionV2.defaultLayer),
   Layer.provide(RuntimeFlags.defaultLayer),
 )

+ 1 - 1
packages/opencode/src/temporary.ts

@@ -1,5 +1,5 @@
 import yargs from "yargs"
-import { TuiThreadCommand } from "./cli/cmd/tui/thread"
+import { TuiThreadCommand } from "./cli/cmd/tui"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { hideBin } from "yargs/helpers"
 import { Log } from "./node"

+ 1 - 88
packages/opencode/src/util/error.ts

@@ -1,88 +1 @@
-import { isRecord } from "./record"
-
-export function errorFormat(error: unknown): string {
-  if (error instanceof Error) {
-    return error.stack ?? `${error.name}: ${error.message}`
-  }
-
-  if (typeof error === "object" && error !== null) {
-    try {
-      const json = JSON.stringify(error, null, 2)
-      // Plain objects whose own properties are all non-enumerable (or empty)
-      // serialize to "{}", which prints as a useless bare `{}` on stderr.
-      // Fall back to a custom toString first, then to ctor name + own prop names.
-      if (json === "{}") {
-        const str = String(error)
-        if (str && str !== "[object Object]") return str
-        const ctor = error.constructor?.name
-        const prefix = ctor && ctor !== "Object" ? ctor : "Error"
-        const names = Object.getOwnPropertyNames(error)
-        return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
-      }
-      return json
-    } catch {
-      return "Unexpected error (unserializable)"
-    }
-  }
-
-  return String(error)
-}
-
-export function errorMessage(error: unknown): string {
-  if (error instanceof Error) {
-    if (error.message) return error.message
-    if (error.name) return error.name
-  }
-
-  if (isRecord(error) && typeof error.message === "string" && error.message) {
-    return error.message
-  }
-
-  if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) {
-    return error.data.message
-  }
-
-  const text = String(error)
-  if (text && text !== "[object Object]") return text
-
-  const formatted = errorFormat(error)
-  if (formatted) return formatted
-  return "unknown error"
-}
-
-export function errorData(error: unknown) {
-  if (error instanceof Error) {
-    return {
-      type: error.name,
-      message: errorMessage(error),
-      stack: error.stack,
-      cause: error.cause === undefined ? undefined : errorFormat(error.cause),
-      formatted: errorFormat(error),
-    }
-  }
-
-  if (!isRecord(error)) {
-    return {
-      type: typeof error,
-      message: errorMessage(error),
-      formatted: errorFormat(error),
-    }
-  }
-
-  const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
-    const value = error[key]
-    if (value === undefined) return acc
-    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
-      acc[key] = value
-      return acc
-    }
-    // oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
-    acc[key] = value instanceof Error ? value.message : String(value)
-    return acc
-  }, {})
-
-  if (typeof data.message !== "string") data.message = errorMessage(error)
-  if (typeof data.type !== "string") data.type = error.constructor?.name
-  data.formatted = errorFormat(error)
-  return data
-}
+export * from "@opencode-ai/tui/util/error"

+ 2 - 86
packages/opencode/src/util/locale.ts

@@ -1,86 +1,2 @@
-export function titlecase(str: string) {
-  return str.replace(/\b\w/g, (c) => c.toUpperCase())
-}
-
-export function time(input: number): string {
-  const date = new Date(input)
-  return date.toLocaleTimeString(undefined, { timeStyle: "short" })
-}
-
-export function datetime(input: number): string {
-  const date = new Date(input)
-  const localTime = time(input)
-  const localDate = date.toLocaleDateString()
-  return `${localTime} · ${localDate}`
-}
-
-export function todayTimeOrDateTime(input: number): string {
-  const date = new Date(input)
-  const now = new Date()
-  const isToday =
-    date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
-
-  if (isToday) {
-    return time(input)
-  } else {
-    return datetime(input)
-  }
-}
-
-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 duration(input: number) {
-  if (input < 1000) {
-    return `${input}ms`
-  }
-  if (input < 60000) {
-    return `${(input / 1000).toFixed(1)}s`
-  }
-  if (input < 3600000) {
-    const minutes = Math.floor(input / 60000)
-    const seconds = Math.floor((input % 60000) / 1000)
-    return `${minutes}m ${seconds}s`
-  }
-  if (input < 86400000) {
-    const hours = Math.floor(input / 3600000)
-    const minutes = Math.floor((input % 3600000) / 60000)
-    return `${hours}h ${minutes}m`
-  }
-  const hours = Math.floor(input / 3600000)
-  const days = Math.floor((input % 3600000) / 86400000)
-  return `${days}d ${hours}h`
-}
-
-export function truncate(str: string, len: number): string {
-  if (str.length <= len) return str
-  return str.slice(0, len - 1) + "…"
-}
-
-export function truncateLeft(str: string, len: number): string {
-  if (str.length <= len) return str
-  return "…" + str.slice(-(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())
-}
-
-export * as Locale from "./locale"
+export * from "@opencode-ai/tui/util/locale"
+export { Locale } from "@opencode-ai/tui/util/locale"

+ 1 - 3
packages/opencode/src/util/record.ts

@@ -1,3 +1 @@
-export function isRecord(value: unknown): value is Record<string, unknown> {
-  return !!value && typeof value === "object" && !Array.isArray(value)
-}
+export * from "@opencode-ai/tui/util/record"

+ 2 - 2
packages/opencode/test/cli/cmd/tui/attention.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { AudioPlayOptions, AudioSound } from "@opentui/core"
-import { createTuiAttention } from "@/cli/cmd/tui/attention"
-import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
+import { createTuiAttention } from "@/cli/tui/attention"
+import type { TuiConfig } from "@opencode-ai/tui/config"
 
 type FocusEvent = "focus" | "blur"
 

+ 0 - 44
packages/opencode/test/cli/cmd/tui/prompt-history.test.ts

@@ -1,44 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { isDuplicateEntry, type PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
-
-const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts })
-
-describe("prompt history dedupe", () => {
-  test("returns false when there is no previous entry", () => {
-    expect(isDuplicateEntry(undefined, entry("hello"))).toBe(false)
-  })
-
-  test("dedupes identical consecutive entries", () => {
-    const a = entry("hello world this is over twenty chars")
-    const b = entry("hello world this is over twenty chars")
-    expect(isDuplicateEntry(a, b)).toBe(true)
-  })
-
-  test("does not dedupe when input text differs", () => {
-    expect(isDuplicateEntry(entry("foo"), entry("bar"))).toBe(false)
-  })
-
-  test("does not dedupe when parts differ", () => {
-    const a = entry("describe this", [
-      {
-        type: "file",
-        mime: "image/png",
-        filename: "a.png",
-        url: "data:image/png;base64,AAA",
-      },
-    ])
-    const b = entry("describe this", [
-      {
-        type: "file",
-        mime: "image/png",
-        filename: "b.png",
-        url: "data:image/png;base64,BBB",
-      },
-    ])
-    expect(isDuplicateEntry(a, b)).toBe(false)
-  })
-
-  test("does not dedupe when mode differs", () => {
-    expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
-  })
-})

+ 0 - 77
packages/opencode/test/cli/cmd/tui/prompt-part.test.ts

@@ -1,77 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
-import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
-
-describe("prompt part", () => {
-  test("strip removes persisted ids from reused file parts", () => {
-    const part = {
-      id: "prt_old",
-      sessionID: "ses_old",
-      messageID: "msg_old",
-      type: "file" as const,
-      mime: "image/png",
-      filename: "tiny.png",
-      url: "data:image/png;base64,abc",
-    }
-
-    expect(strip(part)).toEqual({
-      type: "file",
-      mime: "image/png",
-      filename: "tiny.png",
-      url: "data:image/png;base64,abc",
-    })
-  })
-
-  test("assign overwrites stale runtime ids", () => {
-    const part = {
-      id: "prt_old",
-      sessionID: "ses_old",
-      messageID: "msg_old",
-      type: "file" as const,
-      mime: "image/png",
-      filename: "tiny.png",
-      url: "data:image/png;base64,abc",
-    } as PromptInfo["parts"][number]
-
-    const next = assign(part)
-
-    expect(next.id).not.toBe("prt_old")
-    expect(next.id.startsWith("prt_")).toBe(true)
-    expect(next).toMatchObject({
-      type: "file",
-      mime: "image/png",
-      filename: "tiny.png",
-      url: "data:image/png;base64,abc",
-    })
-  })
-
-  test("expandTrackedPastedText preserves wide characters around pasted text", () => {
-    const marker = "[Pasted ~3 lines]"
-    const prefix = "你好你好\n"
-
-    expect(
-      expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
-        {
-          start: Bun.stringWidth("你好你好") + 1,
-          end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
-          text: "public:\n\tvoid ExecuteTask();\nprivate:",
-        },
-      ]),
-    ).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
-  })
-
-  test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
-    const marker = "[Pasted ~3 lines]"
-    const prefix = `keep ${marker} then `
-
-    expect(
-      expandTrackedPastedText(prefix + marker + " tail", [
-        {
-          start: Bun.stringWidth(prefix),
-          end: Bun.stringWidth(prefix + marker),
-          text: "alpha\nbeta\ngamma",
-        },
-      ]),
-    ).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
-  })
-})

+ 1 - 1
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -5,7 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
 import { createSignal } from "solid-js"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import type { QuestionRequest } from "@opencode-ai/sdk/v2"
-import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
+import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
 import {
   RUN_COMMAND_PANEL_ROWS,
   RUN_SUBAGENT_PANEL_ROWS,

+ 0 - 32
packages/opencode/test/cli/run/prompt.shared.test.ts

@@ -1,11 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import {
   createPromptHistory,
-  displayCharAt,
-  displaySlice,
   isExitCommand,
   isNewCommand,
-  mentionTriggerIndex,
   movePromptHistory,
   pushPromptHistory,
 } from "@/cli/cmd/run/prompt.shared"
@@ -90,35 +87,6 @@ describe("run prompt shared", () => {
     expect(draft.cursor).toBe(Bun.stringWidth("草稿"))
   })
 
-  test("uses display-width offsets for mention helpers", () => {
-    expect(mentionTriggerIndex("@")).toBe(0)
-    expect(mentionTriggerIndex("test @")).toBe(5)
-    expect(mentionTriggerIndex("中文 @")).toBe(5)
-    expect(mentionTriggerIndex("こんにちは @")).toBe(11)
-    expect(mentionTriggerIndex("한국어 @")).toBe(7)
-    expect(mentionTriggerIndex("🙂 @")).toBe(3)
-    expect(mentionTriggerIndex("中文 @src file", Bun.stringWidth("中文 @src"))).toBe(5)
-    expect(displayCharAt("中文 @src", Bun.stringWidth("中文 @"))).toBe("s")
-    expect(displaySlice("中文 @src", 5, Bun.stringWidth("中文 @src"))).toBe("@src")
-    expect(displaySlice("中文 @src", 6, Bun.stringWidth("中文 @src"))).toBe("src")
-    expect(mentionTriggerIndex("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe(3)
-    expect(displayCharAt("👨‍👩‍👧‍👦 @src", Bun.stringWidth("👨‍👩‍👧‍👦 @"))).toBe("s")
-    expect(displaySlice("👨‍👩‍👧‍👦 @src", 3, Bun.stringWidth("👨‍👩‍👧‍👦 @src"))).toBe("@src")
-    expect(mentionTriggerIndex("@file1\n@file2", 13)).toBe(7)
-    expect(displayCharAt("@file1\n@file2", 6)).toBe("\n")
-    expect(displaySlice("@file1\n@file2", 8, 13)).toBe("file2")
-    expect(mentionTriggerIndex("@file1\nfoo @file2", 17)).toBe(11)
-    expect(mentionTriggerIndex("中文 @one\n@two", 14)).toBe(10)
-    expect(displaySlice("中文 @one\n@two", 11, 14)).toBe("two")
-    expect(mentionTriggerIndex("中文@")).toBeUndefined()
-    expect(mentionTriggerIndex("こんにちは@")).toBeUndefined()
-    expect(mentionTriggerIndex("한국어@")).toBeUndefined()
-    expect(mentionTriggerIndex("🙂@")).toBeUndefined()
-    expect(mentionTriggerIndex("hello@")).toBeUndefined()
-    expect(mentionTriggerIndex("foo@bar.com")).toBeUndefined()
-    expect(mentionTriggerIndex("中文 @src file")).toBeUndefined()
-  })
-
   test("recognizes exit commands", () => {
     expect(isExitCommand("/exit")).toBe(true)
     expect(isExitCommand(" /Quit ")).toBe(true)

+ 2 - 1
packages/opencode/test/cli/run/runtime.boot.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
-import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
+import type { Resolved } from "@opencode-ai/tui/config"
+import { TuiConfig } from "@/config/tui"
 import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 

+ 94 - 25
packages/opencode/test/cli/tui/app-lifecycle.test.ts

@@ -4,12 +4,13 @@ import { mkdir } from "node:fs/promises"
 import path from "node:path"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
-import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
+import { tui, type TuiHandle } from "@opencode-ai/tui"
+import { createLegacyTuiHost } from "../../../src/cli/tui/host"
 import { Global } from "@opencode-ai/core/global"
 import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
-import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
-import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
+import * as TuiAudio from "../../../src/cli/tui/audio"
+import * as TuiKeymap from "@opencode-ai/tui/keymap"
+import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
 
 type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
 type TmpDir = Awaited<ReturnType<typeof tmpdir>>
@@ -39,7 +40,6 @@ afterEach(async () => {
   current?.restore?.()
   await Bun.sleep(20)
   await current?.tmp?.[Symbol.asyncDispose]()
-  await TuiPluginRuntime.dispose().catch(() => {})
 })
 
 test("returns a handle immediately and resolves ready after async mount setup", async () => {
@@ -61,6 +61,23 @@ test("production can await done only and still receives mount failures", async (
   expect(app.setup.renderer.isDestroyed).toBe(true)
 })
 
+test("plugin startup failure does not fail the app", async () => {
+  const error = spyOn(console, "error").mockImplementation(() => {})
+  try {
+    const app = await startTui({ rejectPlugins: new Error("plugins failed") })
+    app.theme.resolve("dark")
+
+    await expect(app.handle.ready).resolves.toBeUndefined()
+    await app.pluginHost.started
+    expect(app.setup.renderer.isDestroyed).toBe(false)
+    expect(app.pluginHost.starts).toBe(1)
+    await app.handle.exit()
+    await app.handle.done
+  } finally {
+    error.mockRestore()
+  }
+})
+
 test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
   const beforeSighup = process.listenerCount("SIGHUP")
   const app = await startTui()
@@ -73,7 +90,7 @@ test("exit destroys the renderer, resolves done, and runs cleanup once", async (
   await app.handle.done
 
   expect(app.setup.renderer.isDestroyed).toBe(true)
-  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
 })
 
 test("exit preserves reason formatting and exit messages", async () => {
@@ -124,7 +141,7 @@ test("direct renderer destruction still cleans up and resolves done", async () =
   app.setup.renderer.destroy()
   await app.handle.done
 
-  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
 })
 
 test("SIGHUP exits before ready and removes its listener", async () => {
@@ -135,7 +152,7 @@ test("SIGHUP exits before ready and removes its listener", async () => {
   await app.handle.done
 
   expect(app.setup.renderer.isDestroyed).toBe(true)
-  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
 })
 
 test("SIGHUP exits after ready and removes its listener", async () => {
@@ -161,7 +178,6 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
       unregister()
     }
   })
-  const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
   const disposeAudio = spyOn(TuiAudio, "dispose")
 
   try {
@@ -175,18 +191,37 @@ test("plugin, audio, and keymap cleanup run exactly once", async () => {
 
     expect(registerKeymap).toHaveBeenCalledTimes(1)
     expect(unregisterKeymapCalls).toBe(1)
-    expect(disposePlugins).toHaveBeenCalledTimes(1)
+    expect(app.pluginHost.disposes).toBe(1)
     expect(disposeAudio).toHaveBeenCalledTimes(1)
   } finally {
     registerKeymap.mockRestore()
-    disposePlugins.mockRestore()
     disposeAudio.mockRestore()
   }
 })
 
-async function startTui(options: { rejectTheme?: Error } = {}) {
+test("plugin disposal failure does not stop remaining cleanup", async () => {
+  const error = spyOn(console, "error").mockImplementation(() => {})
+  const disposeAudio = spyOn(TuiAudio, "dispose")
+  try {
+    const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
+    app.theme.resolve("dark")
+    await app.handle.ready
+
+    await app.handle.exit()
+    await app.handle.done
+
+    expect(app.pluginHost.disposes).toBe(1)
+    expect(disposeAudio).toHaveBeenCalledTimes(1)
+    expect(app.setup.renderer.isDestroyed).toBe(true)
+  } finally {
+    error.mockRestore()
+    disposeAudio.mockRestore()
+  }
+})
+
+async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
   const tmp = await tmpdir()
-  const restore = await isolateGlobalPaths(tmp.path)
+  const isolated = await isolateGlobalPaths(tmp.path)
   const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
   const theme = deferred<"dark" | "light" | null>()
   const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
@@ -197,13 +232,48 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
 
   const calls = createFetch()
   const events = createEventSource()
+  const pluginStarted = deferred<void>()
+  const pluginHost = {
+    starts: 0,
+    disposes: 0,
+    started: pluginStarted.promise,
+    async start() {
+      pluginHost.starts++
+      pluginStarted.resolve()
+      if (options.rejectPlugins) throw options.rejectPlugins
+    },
+    async dispose() {
+      pluginHost.disposes++
+      if (options.rejectPluginDispose) throw options.rejectPluginDispose
+    },
+  }
+  const environment = createTuiEnvironment({
+    cwd: tmp.path,
+    platform: "linux",
+    paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
+    capabilities: {
+      mouse: true,
+      copyOnSelect: true,
+      terminalTitle: false,
+      terminalSuspend: false,
+      workspaces: false,
+      showTimeToFirstDraw: false,
+    },
+    terminal: {},
+    editor: { zedTerminal: false },
+    skipInitialLoading: false,
+  })
   const handle = tui({
+    environment,
+    build: createTuiBuildInfo({ version: "test", channel: "test" }),
     url: "http://test",
     renderer: setup.renderer,
+    host: createLegacyTuiHost(setup.renderer),
     config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
     directory,
     fetch: calls.fetch,
     events: events.source,
+    pluginHost,
     args: {},
   })
   active = {
@@ -212,27 +282,26 @@ async function startTui(options: { rejectTheme?: Error } = {}) {
     tmp,
     restore: () => {
       waitForThemeMode.mockRestore()
-      restore()
+      isolated.restore()
     },
   }
 
-  return { handle, setup, theme }
+  return { handle, setup, theme, pluginHost }
 }
 
 async function isolateGlobalPaths(root: string) {
-  const previous = {
-    config: Global.Path.config,
-    state: Global.Path.state,
-  }
+  const previous = Global.Path.config
   Global.Path.config = path.join(root, "config")
-  Global.Path.state = path.join(root, "state")
+  const state = path.join(root, "state")
   await mkdir(Global.Path.config, { recursive: true })
-  await mkdir(Global.Path.state, { recursive: true })
-  await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
+  await mkdir(state, { recursive: true })
+  await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
 
-  return () => {
-    Global.Path.config = previous.config
-    Global.Path.state = previous.state
+  return {
+    state,
+    restore() {
+      Global.Path.config = previous
+    },
   }
 }
 

+ 12 - 0
packages/opencode/test/cli/tui/attach.test.ts

@@ -0,0 +1,12 @@
+import { describe, expect, test } from "bun:test"
+
+describe("tui attach", () => {
+  test("loads the public TUI API and legacy hosts lazily", async () => {
+    const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
+
+    expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
+    expect(source).toContain('await import("../tui/host")')
+    expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
+    expect(source).not.toContain('import("./app")')
+  })
+})

+ 1 - 1
packages/opencode/test/cli/tui/editor-context-zed.test.ts

@@ -8,7 +8,7 @@ import {
   offsetToPosition,
   resolveZedDbPath,
   resolveZedSelection,
-} from "../../../src/cli/cmd/tui/context/editor-zed"
+} from "../../../src/cli/tui/editor-zed"
 import { tmpdir } from "../../fixture/fixture"
 
 const originalZedTerm = process.env.ZED_TERM

+ 28 - 4
packages/opencode/test/cli/tui/editor-context.test.tsx

@@ -3,9 +3,12 @@ import os from "node:os"
 import path from "node:path"
 import { afterEach, expect, spyOn, test } from "bun:test"
 import { createRoot } from "solid-js"
-import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor"
+import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor"
 import { tmpdir } from "../../fixture/fixture"
 import { FakeWebSocket } from "../../lib/websocket"
+import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform"
+import { discoverEditorConnection } from "../../../src/cli/tui/platform"
 
 const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
 const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
@@ -31,10 +34,19 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
       return null
     }
 
+    const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
     return (
-      <EditorContextProvider WebSocketImpl={WebSocketImpl}>
-        <Consumer />
-      </EditorContextProvider>
+      <TestTuiEnvironmentProvider
+        cwd={process.cwd()}
+        paths={{ home: os.homedir() }}
+        editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
+      >
+        <TuiPlatformProvider value={platform}>
+          <EditorContextProvider WebSocketImpl={WebSocketImpl}>
+            <Consumer />
+          </EditorContextProvider>
+        </TuiPlatformProvider>
+      </TestTuiEnvironmentProvider>
     )
   })
 
@@ -44,6 +56,18 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
   }
 }
 
+const platform: TuiPlatform = {
+  files: {
+    readText: (file) => Bun.file(file).text(),
+    readBytes: (file) => Bun.file(file).bytes(),
+    mime: () => Promise.resolve("application/octet-stream"),
+  },
+  editor: {
+    open: () => Promise.resolve(undefined),
+    connection: discoverEditorConnection,
+  },
+}
+
 function createWebSocketImpl(...sockets: FakeWebSocket[]) {
   let index = 0
 

+ 2 - 2
packages/opencode/test/cli/tui/plugin-add.test.ts

@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { TuiConfig } from "../../../src/config/tui"
 
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 test("adds tui plugin at runtime from spec", async () => {
   await using tmp = await tmpdir({

+ 2 - 2
packages/opencode/test/cli/tui/plugin-install.test.ts

@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { TuiConfig } from "../../../src/config/tui"
 
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 test("installs plugin without loading it", async () => {
   await using tmp = await tmpdir({

+ 1 - 1
packages/opencode/test/cli/tui/plugin-lifecycle.test.ts

@@ -6,7 +6,7 @@ import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { mockTuiRuntime } from "../../fixture/tui-runtime"
 
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
   await using tmp = await tmpdir({

+ 2 - 2
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -5,10 +5,10 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { TuiConfig } from "../../../src/config/tui"
 import { Npm } from "@opencode-ai/core/npm"
 
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 test("loads npm tui plugin from package ./tui export", async () => {
   await using tmp = await tmpdir({

+ 2 - 2
packages/opencode/test/cli/tui/plugin-loader-pure.test.ts

@@ -5,9 +5,9 @@ import { pathToFileURL } from "url"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { TuiConfig } from "../../../src/config/tui"
 
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 test("skips external tui plugins in pure mode", async () => {
   await using tmp = await tmpdir({

+ 3 - 3
packages/opencode/test/cli/tui/plugin-loader.test.ts

@@ -8,12 +8,12 @@ import { tmpdir } from "../../fixture/fixture"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
 import { Global } from "@opencode-ai/core/global"
-import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
+import { TuiConfig } from "../../../src/config/tui"
 import { Filesystem } from "@/util/filesystem"
 import { PluginLoader } from "../../../src/plugin/loader"
 
-const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
-const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
+const { allThemes, addTheme } = await import("@opencode-ai/tui/context/theme")
+const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
 
 type Row = Record<string, unknown>
 

Some files were not shown because too many files changed in this diff