Przeglądaj źródła

refactor(tui): extract standalone package (#31193)

Dax 2 miesięcy temu
rodzic
commit
106f8e94d6
84 zmienionych plików z 1147 dodań i 1918 usunięć
  1. 3 2
      bun.lock
  2. 1 1
      packages/cli/src/commands/handlers/default.ts
  3. 6 88
      packages/cli/src/tui.ts
  4. 0 2
      packages/opencode/package.json
  5. 35 48
      packages/opencode/src/cli/cmd/attach.ts
  6. 33 40
      packages/opencode/src/cli/cmd/tui.ts
  7. 0 181
      packages/opencode/src/cli/tui/clipboard.ts
  8. 0 43
      packages/opencode/src/cli/tui/editor.ts
  9. 0 36
      packages/opencode/src/cli/tui/host.ts
  10. 7 0
      packages/opencode/src/cli/tui/layer.ts
  11. 0 124
      packages/opencode/src/cli/tui/platform.ts
  12. 0 57
      packages/opencode/src/cli/tui/runtime.ts
  13. 1 1
      packages/opencode/test/cli/cmd/tui/attention.test.ts
  14. 0 330
      packages/opencode/test/cli/tui/app-lifecycle.test.ts
  15. 2 3
      packages/opencode/test/cli/tui/attach.test.ts
  16. 6 1
      packages/opencode/test/cli/tui/editor-context-zed.test.ts
  17. 8 20
      packages/opencode/test/cli/tui/editor-context.test.tsx
  18. 2 3
      packages/opencode/test/cli/tui/thread.test.ts
  19. 19 29
      packages/opencode/test/fixture/tui-environment.tsx
  20. 10 2
      packages/tui/package.json
  21. 195 321
      packages/tui/src/app.tsx
  22. 6 8
      packages/tui/src/attention.ts
  23. 9 0
      packages/tui/src/audio.d.ts
  24. 5 10
      packages/tui/src/audio.ts
  25. 120 0
      packages/tui/src/clipboard.ts
  26. 4 3
      packages/tui/src/component/dialog-move-session.tsx
  27. 4 4
      packages/tui/src/component/dialog-provider.tsx
  28. 2 3
      packages/tui/src/component/dialog-session-list.tsx
  29. 10 9
      packages/tui/src/component/error-component.tsx
  30. 4 4
      packages/tui/src/component/prompt/autocomplete.tsx
  31. 16 13
      packages/tui/src/component/prompt/index.tsx
  32. 31 2
      packages/tui/src/component/prompt/local-attachment.ts
  33. 3 3
      packages/tui/src/component/prompt/move.tsx
  34. 18 0
      packages/tui/src/context/clipboard.tsx
  35. 5 4
      packages/tui/src/context/directory.ts
  36. 22 14
      packages/tui/src/context/editor.ts
  37. 6 0
      packages/tui/src/context/epilogue.tsx
  38. 0 42
      packages/tui/src/context/exit.tsx
  39. 13 4
      packages/tui/src/context/kv.tsx
  40. 11 19
      packages/tui/src/context/local.tsx
  41. 5 4
      packages/tui/src/context/path-format.tsx
  42. 3 3
      packages/tui/src/context/route.tsx
  43. 62 0
      packages/tui/src/context/runtime.tsx
  44. 3 4
      packages/tui/src/context/sdk.tsx
  45. 12 19
      packages/tui/src/context/sync.tsx
  46. 41 5
      packages/tui/src/context/theme.tsx
  47. 3 4
      packages/tui/src/editor-zed.ts
  48. 84 0
      packages/tui/src/editor.ts
  49. 5 4
      packages/tui/src/feature-plugins/home/footer.tsx
  50. 4 8
      packages/tui/src/feature-plugins/home/tips-view.tsx
  51. 5 4
      packages/tui/src/feature-plugins/sidebar/footer.tsx
  52. 2 2
      packages/tui/src/feature-plugins/system/session-v2.tsx
  53. 1 22
      packages/tui/src/index.tsx
  54. 0 52
      packages/tui/src/platform.tsx
  55. 5 5
      packages/tui/src/prompt/frecency.tsx
  56. 3 3
      packages/tui/src/prompt/history.tsx
  57. 3 3
      packages/tui/src/prompt/stash.tsx
  58. 3 3
      packages/tui/src/routes/home/session-destination.tsx
  59. 3 3
      packages/tui/src/routes/session/dialog-message.tsx
  60. 37 27
      packages/tui/src/routes/session/index.tsx
  61. 3 4
      packages/tui/src/routes/session/sidebar.tsx
  62. 0 83
      packages/tui/src/runtime.tsx
  63. 0 0
      packages/tui/src/terminal-win32.ts
  64. 7 8
      packages/tui/src/ui/dialog.tsx
  65. 1 1
      packages/tui/src/util/presentation.ts
  66. 6 0
      packages/tui/src/util/renderer.ts
  67. 3 3
      packages/tui/src/util/selection.ts
  68. 59 0
      packages/tui/test/app-lifecycle.test.tsx
  69. 5 16
      packages/tui/test/cli/cmd/tui/sync-fixture.tsx
  70. 3 3
      packages/tui/test/cli/tui/dialog-prompt.test.tsx
  71. 3 3
      packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx
  72. 3 3
      packages/tui/test/cli/tui/diff-viewer.test.tsx
  73. 15 15
      packages/tui/test/cli/tui/sync-v2.test.tsx
  74. 3 3
      packages/tui/test/cli/tui/use-event.test.tsx
  75. 19 0
      packages/tui/test/clipboard.test.ts
  76. 23 0
      packages/tui/test/editor.test.ts
  77. 19 29
      packages/tui/test/fixture/tui-environment.tsx
  78. 2 4
      packages/tui/test/index.test.tsx
  79. 0 40
      packages/tui/test/platform.test.tsx
  80. 7 7
      packages/tui/test/prompt/local-attachment.test.ts
  81. 13 38
      packages/tui/test/runtime.test.tsx
  82. 16 0
      packages/tui/test/theme.test.ts
  83. 6 9
      packages/tui/test/util/presentation.test.ts
  84. 30 0
      packages/tui/test/util/renderer.test.ts

+ 3 - 2
bun.lock

@@ -536,7 +536,6 @@
         "@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",
         "@opentelemetry/context-async-hooks": "2.6.1",
@@ -558,7 +557,6 @@
         "ai-gateway-provider": "3.1.2",
         "bonjour-service": "1.3.0",
         "chokidar": "4.0.3",
-        "clipboardy": "4.0.0",
         "cross-spawn": "catalog:",
         "decimal.js": "10.5.0",
         "diff": "catalog:",
@@ -796,12 +794,15 @@
       "name": "@opencode-ai/tui",
       "version": "0.0.0",
       "dependencies": {
+        "@opencode-ai/core": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
+        "@opencode-ai/ui": "workspace:*",
         "@opentui/core": "catalog:",
         "@opentui/keymap": "catalog:",
         "@opentui/solid": "catalog:",
         "@solid-primitives/scheduled": "1.5.2",
+        "clipboardy": "4.0.0",
         "diff": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "catalog:",

+ 1 - 1
packages/cli/src/commands/handlers/default.ts

@@ -8,6 +8,6 @@ export default Runtime.handler(Commands, () =>
     const daemon = yield* Daemon.Service
     const transport = yield* daemon.transport()
     const { runTui } = yield* Effect.promise(() => import("../../tui"))
-    yield* Effect.promise(() => runTui(transport))
+    yield* runTui(transport)
   }),
 )

+ 6 - 88
packages/cli/src/tui.ts

@@ -1,102 +1,20 @@
-import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui"
+import { run } 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"
+import { Effect } from "effect"
+import { Global } from "@opencode-ai/core/global"
 
-declare const OPENCODE_VERSION: string | undefined
-declare const OPENCODE_CHANNEL: string | undefined
-
-export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
+export 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({
+  return 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"
-    },
-  },
+  }).pipe(Effect.provide(Global.defaultLayer))
 }
 
 const legacyDefaults: Record<string, unknown> = {

+ 0 - 2
packages/opencode/package.json

@@ -90,7 +90,6 @@
     "@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",
     "@opentelemetry/context-async-hooks": "2.6.1",
@@ -112,7 +111,6 @@
     "ai-gateway-provider": "3.1.2",
     "bonjour-service": "1.3.0",
     "chokidar": "4.0.3",
-    "clipboardy": "4.0.0",
     "cross-spawn": "catalog:",
     "decimal.js": "10.5.0",
     "diff": "catalog:",

+ 35 - 48
packages/opencode/src/cli/cmd/attach.ts

@@ -1,10 +1,8 @@
 import { cmd } from "./cmd"
 import { UI } from "@/cli/ui"
-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>",
@@ -46,54 +44,46 @@ export const AttachCommand = cmd({
       }),
   handler: async (args) => {
     const { TuiConfig } = await import("@/config/tui")
-    const unguard = win32InstallCtrlCGuard()
-    try {
-      win32DisableProcessedInput()
-
-      if (args.fork && !args.continue && !args.session) {
-        UI.error("--fork requires --continue or --session")
-        process.exitCode = 1
-        return
-      }
-
-      const directory = (() => {
-        if (!args.dir) return undefined
-        try {
-          process.chdir(args.dir)
-          return process.cwd()
-        } catch {
-          // If the directory doesn't exist locally (remote attach), pass it through.
-          return args.dir
-        }
-      })()
-      const headers = ServerAuth.headers({ password: args.password, username: args.username })
-      const config = await TuiConfig.get()
-      const runtime = resolveTuiRuntime(config)
+    if (args.fork && !args.continue && !args.session) {
+      UI.error("--fork requires --continue or --session")
+      process.exitCode = 1
+      return
+    }
 
+    const directory = (() => {
+      if (!args.dir) return undefined
       try {
-        await validateSession({
-          url: args.url,
-          sessionID: args.session,
-          directory,
-          headers,
-        })
-      } catch (error) {
-        UI.error(errorMessage(error))
-        process.exitCode = 1
-        return
+        process.chdir(args.dir)
+        return process.cwd()
+      } catch {
+        // If the directory doesn't exist locally (remote attach), pass it through.
+        return args.dir
       }
+    })()
+    const headers = ServerAuth.headers({ password: args.password, username: args.username })
+    const config = await TuiConfig.get()
+
+    try {
+      await validateSession({
+        url: args.url,
+        sessionID: args.session,
+        directory,
+        headers,
+      })
+    } catch (error) {
+      UI.error(errorMessage(error))
+      process.exitCode = 1
+      return
+    }
 
-      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,
+    const { Effect } = await import("effect")
+    const { run } = await import("../tui/layer")
+    const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
+    await Effect.runPromise(
+      run({
         url: args.url,
         config,
-        host: createLegacyTuiHost(renderer),
         pluginHost: createLegacyTuiPluginHost(),
-        renderer,
         args: {
           continue: args.continue,
           sessionID: args.session,
@@ -101,10 +91,7 @@ export const AttachCommand = cmd({
         },
         directory,
         headers,
-      })
-      await handle.done
-    } finally {
-      unguard?.()
-    }
+      }),
+    )
   },
 })

+ 33 - 40
packages/opencode/src/cli/cmd/tui.ts

@@ -11,7 +11,6 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network
 import { Filesystem } from "@/util/filesystem"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "@opencode-ai/tui/context/sdk"
-import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
 import { writeHeapSnapshot } from "v8"
 import {
   OPENCODE_PROCESS_ROLE,
@@ -20,7 +19,7 @@ import {
   sanitizedProcessEnv,
 } from "@opencode-ai/core/util/opencode-process"
 import { validateSession } from "../tui/validate-session"
-import { resolveTuiRuntime } from "../tui/runtime"
+import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -113,15 +112,9 @@ export const TuiThreadCommand = cmd({
         describe: "agent to use",
       }),
   handler: async (args) => {
-    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()
     try {
-      // Must be the very first thing — disables CTRL_C_EVENT before any Worker
-      // spawn or async work so the OS cannot kill the process group.
-      win32DisableProcessedInput()
-
+      const { TuiConfig } = await import("@/config/tui")
       if (args.fork && !args.continue && !args.session) {
         UI.error("--fork requires --continue or --session")
         process.exitCode = 1
@@ -189,7 +182,6 @@ 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 =
@@ -230,42 +222,43 @@ export const TuiThreadCommand = cmd({
       }, 1000).unref?.()
 
       try {
-        const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
-        const { createLegacyTuiHost } = await import("../tui/host")
+        const { Effect } = await import("effect")
+        const { run } = await import("../tui/layer")
         const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
-        const renderer = await createTuiRenderer(config, runtime)
-        const handle = tui({
-          ...runtime,
-          url: transport.url,
-          renderer,
-          async onSnapshot() {
-            const tui = writeHeapSnapshot("tui.heapsnapshot")
-            const server = await client.call("snapshot", undefined)
-            return [tui, server]
-          },
-          config,
-          host: createLegacyTuiHost(renderer),
-          pluginHost: createLegacyTuiPluginHost(),
-          directory: cwd,
-          fetch: transport.fetch,
-          events: transport.events,
-          args: {
-            continue: args.continue,
-            sessionID: args.session,
-            agent: args.agent,
-            model: args.model,
-            prompt,
-            fork: args.fork,
-          },
-        })
-        await handle.done
+        await Effect.runPromise(
+          run({
+            url: transport.url,
+            async onSnapshot() {
+              const tui = writeHeapSnapshot("tui.heapsnapshot")
+              const server = await client.call("snapshot", undefined)
+              return [tui, server]
+            },
+            config,
+            pluginHost: createLegacyTuiPluginHost(),
+            directory: cwd,
+            fetch: transport.fetch,
+            events: transport.events,
+            args: {
+              continue: args.continue,
+              sessionID: args.session,
+              agent: args.agent,
+              model: args.model,
+              prompt,
+              fork: args.fork,
+            },
+          }),
+        )
       } finally {
         await stop()
       }
+      process.exit(0)
     } finally {
-      unguard?.()
+      try {
+        unguard?.()
+      } catch (error) {
+        Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
+      }
     }
-    process.exit(0)
   },
 })
 // scratch

+ 0 - 181
packages/opencode/src/cli/tui/clipboard.ts

@@ -1,181 +0,0 @@
-import { platform, release } from "os"
-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"
-
-const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
-  Effect.runPromise(
-    AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe(
-      Effect.provide(AppProcess.defaultLayer),
-      Effect.catch(() => Effect.void),
-      Effect.asVoid,
-    ),
-  ).catch(() => undefined)
-
-// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
-const getWhich = lazy(async () => {
-  const { which } = await import("@opencode-ai/core/util/which")
-  return which
-})
-
-const getClipboardy = lazy(async () => {
-  const { default: clipboardy } = await import("clipboardy")
-  return clipboardy
-})
-
-/**
- * Writes text to clipboard via OSC 52 escape sequence.
- * This allows clipboard operations to work over SSH by having
- * the terminal emulator handle the clipboard locally.
- */
-function writeOsc52(text: string): void {
-  if (!process.stdout.isTTY) return
-  const base64 = Buffer.from(text).toString("base64")
-  const osc52 = `\x1b]52;c;${base64}\x07`
-  const passthrough = process.env["TMUX"] || process.env["STY"]
-  const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
-  process.stdout.write(sequence)
-}
-
-export interface Content {
-  data: string
-  mime: string
-}
-
-// Checks clipboard for images first, then falls back to text.
-//
-// On Windows prompt/ can call this from multiple paste signals because
-// terminals surface image paste differently:
-//   1. A forwarded Ctrl+V keypress
-//   2. An empty bracketed-paste hint for image-only clipboard in Windows
-//      Terminal <1.25
-//   3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+
-export async function read(): Promise<Content | undefined> {
-  const os = platform()
-
-  if (os === "darwin") {
-    const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
-    try {
-      await Process.run(
-        [
-          "osascript",
-          "-e",
-          'set imageData to the clipboard as "PNGf"',
-          "-e",
-          `set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
-          "-e",
-          "set eof fileRef to 0",
-          "-e",
-          "write imageData to fileRef",
-          "-e",
-          "close access fileRef",
-        ],
-        { nothrow: true },
-      )
-      const buffer = await Filesystem.readBytes(tmpfile)
-      return { data: buffer.toString("base64"), mime: "image/png" }
-    } catch {
-    } finally {
-      await fs.rm(tmpfile, { force: true }).catch(() => {})
-    }
-  }
-
-  // Windows/WSL: probe clipboard for images via PowerShell.
-  // Bracketed paste can't carry image data so we read it directly.
-  if (os === "win32" || release().includes("WSL")) {
-    const script =
-      "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
-    const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
-      nothrow: true,
-    })
-    if (base64.text) {
-      const imageBuffer = Buffer.from(base64.text.trim(), "base64")
-      if (imageBuffer.length > 0) {
-        return { data: imageBuffer.toString("base64"), mime: "image/png" }
-      }
-    }
-  }
-
-  if (os === "linux") {
-    const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
-    if (wayland.stdout.byteLength > 0) {
-      return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
-    }
-    const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
-      nothrow: true,
-    })
-    if (x11.stdout.byteLength > 0) {
-      return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
-    }
-  }
-
-  const clipboardy = await getClipboardy()
-  const text = await clipboardy.read().catch(() => {})
-  if (text) {
-    return { data: text, mime: "text/plain" }
-  }
-}
-
-const getCopyMethod = lazy(async () => {
-  const os = platform()
-  const which = await getWhich()
-
-  if (os === "darwin" && which("osascript")) {
-    console.log("clipboard: using osascript")
-    return async (text: string) => {
-      const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
-      await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
-    }
-  }
-
-  if (os === "linux") {
-    if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
-      console.log("clipboard: using wl-copy")
-      return (text: string) => writeWithStdin(["wl-copy"], text)
-    }
-    if (which("xclip")) {
-      console.log("clipboard: using xclip")
-      return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
-    }
-    if (which("xsel")) {
-      console.log("clipboard: using xsel")
-      return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
-    }
-  }
-
-  if (os === "win32") {
-    console.log("clipboard: using powershell")
-    return (text: string) =>
-      // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
-      writeWithStdin(
-        [
-          "powershell.exe",
-          "-NonInteractive",
-          "-NoProfile",
-          "-Command",
-          "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
-        ],
-        text,
-      )
-  }
-
-  console.log("clipboard: no native support")
-  return async (text: string) => {
-    const clipboardy = await getClipboardy()
-    await clipboardy.write(text).catch(() => {})
-  }
-})
-
-export async function copy(text: string): Promise<void> {
-  writeOsc52(text)
-  const method = await getCopyMethod()
-  await method(text)
-}
-
-export * as Clipboard from "./clipboard"

+ 0 - 43
packages/opencode/src/cli/tui/editor.ts

@@ -1,43 +0,0 @@
-import { defer } from "@/util/defer"
-import { existsSync } from "node:fs"
-import { rm } from "node:fs/promises"
-import { tmpdir } from "node:os"
-import { join } from "node:path"
-import { CliRenderer } from "@opentui/core"
-import { Filesystem } from "@/util/filesystem"
-import { Process } from "@/util/process"
-
-export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
-  const editor = process.env["VISUAL"] || process.env["EDITOR"]
-  if (!editor) return
-
-  const filepath = join(tmpdir(), `${Date.now()}.md`)
-  await using _ = defer(async () => rm(filepath, { force: true }))
-
-  // In attach mode the server's project directory may not exist locally.
-  // Fall back to the local process cwd so the editor can still spawn.
-  const cwd = opts.cwd && existsSync(opts.cwd) ? opts.cwd : process.cwd()
-
-  await Filesystem.write(filepath, opts.value)
-  opts.renderer.suspend()
-  opts.renderer.currentRenderBuffer.clear()
-  try {
-    const parts = editor.split(" ")
-    const proc = Process.spawn([...parts, filepath], {
-      cwd,
-      stdin: "inherit",
-      stdout: "inherit",
-      stderr: "inherit",
-      shell: process.platform === "win32",
-    })
-    await proc.exited
-    const content = await Filesystem.readText(filepath)
-    return content || undefined
-  } finally {
-    opts.renderer.currentRenderBuffer.clear()
-    opts.renderer.resume()
-    opts.renderer.requestRender()
-  }
-}
-
-export * as Editor from "./editor"

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

@@ -1,36 +0,0 @@
-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")
-      },
-    },
-  }
-}

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

@@ -0,0 +1,7 @@
+import { run as runTui, type TuiInput } from "@opencode-ai/tui"
+import { Global } from "@opencode-ai/core/global"
+import { Effect } from "effect"
+
+export function run(input: TuiInput) {
+  return runTui(input).pipe(Effect.provide(Global.defaultLayer))
+}

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

@@ -1,124 +0,0 @@
-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
-      }
-    }) ?? ""
-  )
-}

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

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

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

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

+ 0 - 330
packages/opencode/test/cli/tui/app-lifecycle.test.ts

@@ -1,330 +0,0 @@
-import { afterEach, expect, spyOn, test } from "bun:test"
-import { createTestRenderer } from "@opentui/core/testing"
-import { mkdir } from "node:fs/promises"
-import path from "node:path"
-import { tmpdir } from "../../fixture/fixture"
-import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-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/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>>
-
-const disabledInternalPlugins = {
-  "internal:home-footer": false,
-  "internal:home-tips": false,
-  "internal:sidebar-context": false,
-  "internal:sidebar-mcp": false,
-  "internal:sidebar-lsp": false,
-  "internal:sidebar-todo": false,
-  "internal:sidebar-files": false,
-  "internal:sidebar-footer": false,
-  "internal:plugin-manager": false,
-  "internal:session-v2-debug": false,
-  "which-key": false,
-}
-let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined
-
-afterEach(async () => {
-  const current = active
-  active = undefined
-  await current?.handle?.exit().catch(() => {})
-  await current?.handle?.done.catch(() => {})
-  await current?.handle?.ready.catch(() => {})
-  if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy()
-  current?.restore?.()
-  await Bun.sleep(20)
-  await current?.tmp?.[Symbol.asyncDispose]()
-})
-
-test("returns a handle immediately and resolves ready after async mount setup", async () => {
-  const app = await startTui()
-
-  expect(await promiseState(app.handle.ready)).toBe("pending")
-
-  app.theme.resolve("dark")
-  await app.handle.ready
-
-  expect(app.setup.renderer.isDestroyed).toBe(false)
-  expect(await promiseState(app.handle.done)).toBe("pending")
-})
-
-test("production can await done only and still receives mount failures", async () => {
-  const app = await startTui({ rejectTheme: new Error("theme failed") })
-
-  await expect(app.handle.done).rejects.toThrow("theme failed")
-  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()
-
-  app.theme.resolve("dark")
-  await app.handle.ready
-  expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup)
-
-  await Promise.all([app.handle.exit(), app.handle.exit()])
-  await app.handle.done
-
-  expect(app.setup.renderer.isDestroyed).toBe(true)
-  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
-})
-
-test("exit preserves reason formatting and exit messages", async () => {
-  const stdout: string[] = []
-  const stderr: string[] = []
-  const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
-    stdout.push(String(chunk))
-    return true
-  })
-  const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
-    stderr.push(String(chunk))
-    return true
-  })
-
-  try {
-    const app = await startTui()
-    app.theme.resolve("dark")
-    await app.handle.ready
-
-    app.handle.exit.message.set("goodbye")
-    await app.handle.exit(new Error("boom"))
-    await app.handle.done
-
-    expect(stderr.join("")).toContain("boom")
-    expect(stdout.join("")).toBe("goodbye\n")
-  } finally {
-    stdoutWrite.mockRestore()
-    stderrWrite.mockRestore()
-  }
-})
-
-test("exit before ready cancels mount and resolves done", async () => {
-  const app = await startTui()
-
-  await app.handle.exit()
-  await app.handle.done
-
-  expect(app.setup.renderer.isDestroyed).toBe(true)
-  await expect(app.handle.ready).resolves.toBeUndefined()
-})
-
-test("direct renderer destruction still cleans up and resolves done", async () => {
-  const beforeSighup = process.listenerCount("SIGHUP")
-  const app = await startTui()
-
-  app.theme.resolve("dark")
-  await app.handle.ready
-  app.setup.renderer.destroy()
-  await app.handle.done
-
-  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
-})
-
-test("SIGHUP exits before ready and removes its listener", async () => {
-  const beforeSighup = process.listenerCount("SIGHUP")
-  const app = await startTui()
-
-  process.emit("SIGHUP")
-  await app.handle.done
-
-  expect(app.setup.renderer.isDestroyed).toBe(true)
-  expect(process.listenerCount("SIGHUP")).toBeLessThanOrEqual(beforeSighup)
-})
-
-test("SIGHUP exits after ready and removes its listener", async () => {
-  const beforeSighup = process.listenerCount("SIGHUP")
-  const app = await startTui()
-
-  app.theme.resolve("dark")
-  await app.handle.ready
-  process.emit("SIGHUP")
-  await app.handle.done
-
-  expect(app.setup.renderer.isDestroyed).toBe(true)
-  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
-})
-
-test("plugin, audio, and keymap cleanup run exactly once", async () => {
-  const originalRegister = TuiKeymap.registerOpencodeKeymap
-  let unregisterKeymapCalls = 0
-  const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => {
-    const unregister = originalRegister(...args)
-    return () => {
-      unregisterKeymapCalls++
-      unregister()
-    }
-  })
-  const disposeAudio = spyOn(TuiAudio, "dispose")
-
-  try {
-    const app = await startTui()
-    app.theme.resolve("dark")
-    await app.handle.ready
-
-    app.setup.renderer.destroy()
-    await Promise.all([app.handle.exit(), app.handle.exit()])
-    await app.handle.done
-
-    expect(registerKeymap).toHaveBeenCalledTimes(1)
-    expect(unregisterKeymapCalls).toBe(1)
-    expect(app.pluginHost.disposes).toBe(1)
-    expect(disposeAudio).toHaveBeenCalledTimes(1)
-  } finally {
-    registerKeymap.mockRestore()
-    disposeAudio.mockRestore()
-  }
-})
-
-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 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(() => {
-    if (options.rejectTheme) return Promise.reject(options.rejectTheme)
-    return theme.promise
-  })
-  setup.renderer.once("destroy", () => theme.resolve(null))
-
-  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 = {
-    handle,
-    setup,
-    tmp,
-    restore: () => {
-      waitForThemeMode.mockRestore()
-      isolated.restore()
-    },
-  }
-
-  return { handle, setup, theme, pluginHost }
-}
-
-async function isolateGlobalPaths(root: string) {
-  const previous = Global.Path.config
-  Global.Path.config = path.join(root, "config")
-  const state = path.join(root, "state")
-  await mkdir(Global.Path.config, { recursive: true })
-  await mkdir(state, { recursive: true })
-  await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
-
-  return {
-    state,
-    restore() {
-      Global.Path.config = previous
-    },
-  }
-}
-
-async function promiseState(promise: Promise<unknown>) {
-  let state: "pending" | "resolved" | "rejected" = "pending"
-  promise.then(
-    () => {
-      state = "resolved"
-    },
-    () => {
-      state = "rejected"
-    },
-  )
-  await Promise.resolve()
-  return state
-}
-
-function deferred<T>() {
-  let resolve!: (value: T | PromiseLike<T>) => void
-  let reject!: (error: unknown) => void
-  const promise = new Promise<T>((done, fail) => {
-    resolve = done
-    reject = fail
-  })
-  return { promise, resolve, reject }
-}

+ 2 - 3
packages/opencode/test/cli/tui/attach.test.ts

@@ -1,11 +1,10 @@
 import { describe, expect, test } from "bun:test"
 
 describe("tui attach", () => {
-  test("loads the public TUI API and legacy hosts lazily", async () => {
+  test("loads the TUI integration 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).toContain('await import("../tui/layer")')
     expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
     expect(source).not.toContain('import("./app")')
   })

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

@@ -3,7 +3,12 @@ import { mkdir, symlink } from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
 import { afterEach, expect, spyOn, test } from "bun:test"
-import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/tui/editor-zed"
+import {
+  isZedTerminal,
+  offsetToPosition,
+  resolveZedDbPath,
+  resolveZedSelection,
+} from "@opencode-ai/tui/editor-zed"
 import { tmpdir } from "../../fixture/fixture"
 
 const originalZedTerm = process.env.ZED_TERM

+ 8 - 20
packages/opencode/test/cli/tui/editor-context.test.tsx

@@ -3,12 +3,11 @@ 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 "@opencode-ai/tui/context/editor"
+import { EditorContextProvider, useEditorContext, type EditorIntegration } 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"
+import { TestTuiContexts } from "../../fixture/tui-environment"
+import { discoverEditorConnection } from "@opencode-ai/tui/editor"
 
 const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT
 const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT
@@ -36,17 +35,14 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) {
 
     const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
     return (
-      <TestTuiEnvironmentProvider
+      <TestTuiContexts
         cwd={process.cwd()}
         paths={{ home: os.homedir() }}
-        editor={{ port: value ? Number.parseInt(value, 10) : undefined }}
       >
-        <TuiPlatformProvider value={platform}>
-          <EditorContextProvider WebSocketImpl={WebSocketImpl}>
+          <EditorContextProvider integration={editorService} WebSocketImpl={WebSocketImpl}>
             <Consumer />
           </EditorContextProvider>
-        </TuiPlatformProvider>
-      </TestTuiEnvironmentProvider>
+      </TestTuiContexts>
     )
   })
 
@@ -56,16 +52,8 @@ 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,
-  },
+const editorService: EditorIntegration = {
+  connection: discoverEditorConnection,
 }
 
 function createWebSocketImpl(...sockets: FakeWebSocket[]) {

+ 2 - 3
packages/opencode/test/cli/tui/thread.test.ts

@@ -5,11 +5,10 @@ import { tmpdir } from "../../fixture/fixture"
 import { resolveThreadDirectory } from "../../../src/cli/cmd/tui"
 
 describe("tui thread", () => {
-  test("loads the public TUI API and legacy hosts lazily", async () => {
+  test("loads the TUI integration lazily", async () => {
     const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
 
-    expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/)
-    expect(source).toContain('await import("../tui/host")')
+    expect(source).toContain('await import("../tui/layer")')
     expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
     expect(source).not.toContain('import("./app")')
   })

+ 19 - 29
packages/opencode/test/fixture/tui-environment.tsx

@@ -1,42 +1,32 @@
 /** @jsxImportSource @opentui/solid */
-import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "@opencode-ai/tui/runtime"
+import {
+  TuiPathsProvider,
+  TuiStartupProvider,
+  TuiTerminalEnvironmentProvider,
+  type TuiPaths,
+} from "@opencode-ai/tui/context/runtime"
 import type { ParentProps } from "solid-js"
 
-export function TestTuiEnvironmentProvider(
+export function TestTuiContexts(
   props: ParentProps<{
     cwd?: string
     directory?: string
-    paths?: Partial<TuiEnvironment["paths"]>
-    capabilities?: Partial<TuiEnvironment["capabilities"]>
-    editor?: Partial<TuiEnvironment["editor"]>
+    paths?: Partial<TuiPaths>
   }>,
 ) {
   return (
-    <TuiEnvironmentProvider
-      value={createTuiEnvironment({
+    <TuiPathsProvider
+      value={{
         cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/opencode",
-        platform: "linux",
-        paths: {
-          home: "/tmp/opencode/home",
-          state: "/tmp/opencode/state",
-          worktree: "/tmp/opencode",
-          ...props.paths,
-        },
-        capabilities: {
-          mouse: true,
-          copyOnSelect: true,
-          terminalTitle: false,
-          terminalSuspend: false,
-          workspaces: false,
-          showTimeToFirstDraw: false,
-          ...props.capabilities,
-        },
-        terminal: {},
-        editor: { zedTerminal: false, ...props.editor },
-        skipInitialLoading: false,
-      })}
+        home: "/tmp/opencode/home",
+        state: "/tmp/opencode/state",
+        worktree: "/tmp/opencode",
+        ...props.paths,
+      }}
     >
-      {props.children}
-    </TuiEnvironmentProvider>
+      <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
+        <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
+      </TuiTerminalEnvironmentProvider>
+    </TuiPathsProvider>
   )
 }

+ 10 - 2
packages/tui/package.json

@@ -14,19 +14,24 @@
     "./builtins": "./src/feature-plugins/builtins.ts",
     "./config": "./src/config/index.tsx",
     "./context/args": "./src/context/args.tsx",
-    "./context/exit": "./src/context/exit.tsx",
+    "./context/epilogue": "./src/context/epilogue.tsx",
     "./context/kv": "./src/context/kv.tsx",
     "./context/project": "./src/context/project.tsx",
+    "./context/runtime": "./src/context/runtime.tsx",
     "./context/sdk": "./src/context/sdk.tsx",
     "./context/sync": "./src/context/sync.tsx",
     "./context/theme": "./src/context/theme.tsx",
     "./context/editor": "./src/context/editor.ts",
+    "./context/clipboard": "./src/context/clipboard.tsx",
+    "./attention": "./src/attention.ts",
+    "./editor": "./src/editor.ts",
+    "./editor-zed": "./src/editor-zed.ts",
     "./context/aggregate-failures": "./src/context/aggregate-failures.ts",
     "./runtime": "./src/runtime.tsx",
+    "./terminal-win32": "./src/terminal-win32.ts",
     "./config/keybind": "./src/config/keybind.ts",
     "./keymap": "./src/keymap.tsx",
     "./prompt/display": "./src/prompt/display.ts",
-    "./platform": "./src/platform.tsx",
     "./plugin/runtime": "./src/plugin/runtime.tsx",
     "./plugin/slots": "./src/plugin/slots.tsx",
     "./plugin/command-shim": "./src/plugin/command-shim.ts",
@@ -42,12 +47,15 @@
     "./component/spinner": "./src/component/spinner.tsx"
   },
   "dependencies": {
+    "@opencode-ai/core": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
+    "@opencode-ai/ui": "workspace:*",
     "@opentui/core": "catalog:",
     "@opentui/keymap": "catalog:",
     "@opentui/solid": "catalog:",
     "@solid-primitives/scheduled": "1.5.2",
+    "clipboardy": "4.0.0",
     "diff": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "catalog:",

+ 195 - 321
packages/tui/src/app.tsx

@@ -1,7 +1,13 @@
 import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
+import { Deferred, Effect } from "effect"
+import { Global } from "@opencode-ai/core/global"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import { ClipboardProvider, useClipboard } from "./context/clipboard"
+import { EpilogueProvider } from "./context/epilogue"
 import * as Selection from "./util/selection"
-import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
+import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
 import { RouteProvider, useRoute } from "./context/route"
 import {
   Switch,
@@ -15,17 +21,8 @@ import {
   batch,
   Show,
   on,
-  type ParentProps,
 } from "solid-js"
-import {
-  TuiBuildInfoProvider,
-  TuiEnvironmentProvider,
-  useTuiBuildInfo,
-  useTuiEnvironment,
-  type TuiBuildInfo,
-  type TuiEnvironment,
-} from "./runtime"
-import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "./platform"
+import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
 import { DialogProvider, useDialog } from "./ui/dialog"
 import { DialogProvider as DialogProviderList } from "./component/dialog-provider"
 import { ErrorComponent } from "./component/error-component"
@@ -57,7 +54,6 @@ import { PromptStashProvider } from "./component/prompt/stash"
 import { DialogAlert } from "./ui/dialog-alert"
 import { DialogConfirm } from "./ui/dialog-confirm"
 import { ToastProvider, useToast } from "./ui/toast"
-import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
 import { isDefaultTitle } from "./util/session"
 import { KVProvider, useKV } from "./context/kv"
 import * as Model from "./util/model"
@@ -67,14 +63,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
 import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
 import { createTuiApiAdapters } from "./plugin/adapters"
 import { createTuiApi } from "./plugin/api"
-import {
-  createPluginRuntime,
-  PluginRuntimeProvider,
-  usePluginRuntime,
-  type PluginRuntime,
-  type TuiPluginHost,
-} from "./plugin/runtime"
-import type { TuiAttention } from "@opencode-ai/plugin/tui"
+import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
 import { CommandPaletteDialog } from "./component/command-palette"
 import {
   COMMAND_PALETTE_COMMAND,
@@ -87,6 +76,10 @@ import {
 
 import type { EventSource } from "./context/sdk"
 import { DialogVariant } from "./component/dialog-variant"
+import { createTuiAttention } from "./attention"
+import * as TuiAudio from "./audio"
+import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
+import { destroyRenderer } from "./util/renderer"
 
 const appGlobalBindingCommands = [
   "session.list",
@@ -136,81 +129,19 @@ const appBindingCommands = [
   "app.toggle.session_directory_filter",
 ] as const
 
-export type TuiRuntimeInput = {
-  environment: TuiEnvironment
-  build: TuiBuildInfo
-}
-
-export type TuiHost = Readonly<{
-  platform: TuiPlatform
-  attention(input: {
-    renderer: CliRenderer
-    config: TuiConfig.Resolved
-    kv: ReturnType<typeof useKV>
-  }): TuiAttention & { dispose(): void }
-  logger: { error(message: string, extra?: Record<string, unknown>): void }
-  lifecycle: Readonly<{
-    prepare?(): (() => void) | undefined
-    flushInput?(): void
-    onSighup?(handler: () => void): () => void
-    writeStdout?(text: string): void
-    writeStderr?(text: string): void
-    suspend?(resume: () => void): void
-  }>
-  disposeAudio?(): void
-  formatError(error: unknown): string | undefined
-  formatUnknownError(error: unknown): string
-}>
-
-export function tuiRendererConfig(_config: TuiConfig.Resolved, runtime: TuiRuntimeInput): CliRendererConfig {
-  return {
-    externalOutputMode: "passthrough",
-    targetFps: 60,
-    gatherStats: false,
-    exitOnCtrlC: false,
-    useKittyKeyboard: {},
-    autoFocus: false,
-    openConsoleOnError: false,
-    useMouse: runtime.environment.capabilities.mouse,
-    consoleOptions: {
-      keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
-    },
-  }
-}
-
-export function createTuiRenderer(config: TuiConfig.Resolved, runtime: TuiRuntimeInput) {
-  return createCliRenderer(tuiRendererConfig(config, runtime))
-}
-
-export type TuiHandle = {
-  ready: Promise<void>
-  done: Promise<void>
-  exit: Exit
-}
-
-export type TuiInput = TuiRuntimeInput & {
+export type TuiInput = {
   url: string
   args: Args
   config: TuiConfig.Resolved
-  renderer: CliRenderer
   onSnapshot?: () => Promise<string[]>
   directory?: string
   fetch?: typeof fetch
   headers?: RequestInit["headers"]
   events?: EventSource
   pluginHost: TuiPluginHost
-  host: TuiHost
-}
-
-type TuiLifecycle = {
-  exit: Exit
-  exited: Promise<void>
-  fail(error: unknown): Promise<never>
 }
 
-function errorMessage(error: unknown, host: TuiHost) {
-  const formatted = host.formatError(error)
-  if (formatted !== undefined) return formatted
+function errorMessage(error: unknown) {
   if (
     typeof error === "object" &&
     error !== null &&
@@ -222,7 +153,7 @@ function errorMessage(error: unknown, host: TuiHost) {
   ) {
     return error.data.message
   }
-  return host.formatUnknownError(error)
+  return error instanceof Error ? error.message : String(error)
 }
 
 function isVersionGreater(left: string, right: string) {
@@ -242,223 +173,164 @@ function isVersionGreater(left: string, right: string) {
   return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
 }
 
-export function tui(input: TuiInput): TuiHandle {
-  const unguard = input.host.lifecycle.prepare?.()
-
-  const renderer = input.renderer
-  const keymap = createDefaultOpenTuiKeymap(renderer)
-  const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
-  const pluginRuntime = createPluginRuntime()
-  const lifecycle = createTuiLifecycle({
-    renderer,
-    unguard,
-    host: input.host,
-    cleanup: async () => {
-      unregisterKeymap()
-      try {
-        await input.pluginHost.dispose()
-      } catch (error) {
-        console.error("Failed to dispose TUI plugins", error)
-      } finally {
-        input.host.disposeAudio?.()
-      }
-    },
-  })
-  const ready = mount({ ...input, keymap, pluginRuntime, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
-  const done = waitUntilDone(ready, lifecycle.exited)
-
-  return { ready, done, exit: lifecycle.exit }
-}
-
-export async function mount(
-  input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; pluginRuntime: PluginRuntime; exit: Exit },
-) {
-  const renderer = input.renderer
-  // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
-  void renderer.getPalette({ size: 16 }).catch(() => undefined)
-  const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
-  if (renderer.isDestroyed) return
-
-  await render(() => {
-    return (
-      <ErrorBoundary
-        fallback={(error, reset) => (
-          <ErrorComponent error={error} reset={reset} exit={input.exit} version={input.build.version} mode={mode} />
-        )}
-      >
-        <TuiEnvironmentProvider value={input.environment}>
-          <TuiPlatformProvider value={input.host.platform}>
-            <TuiBuildInfoProvider value={input.build}>
-              <OpencodeKeymapProvider keymap={input.keymap}>
-                <ArgsProvider {...input.args}>
-                  <ExitProvider exit={input.exit}>
-                    <KVProvider>
-                      <ToastProvider>
-                        <RouteProvider
-                          initialRoute={
-                            input.args.continue
-                              ? {
-                                  type: "session",
-                                  sessionID: "dummy",
-                                }
-                              : undefined
-                          }
-                        >
-                          <TuiConfigProvider config={input.config}>
-                            <PluginRuntimeProvider value={input.pluginRuntime}>
-                              <SDKProvider
-                                url={input.url}
-                                directory={input.directory}
-                                fetch={input.fetch}
-                                headers={input.headers}
-                                events={input.events}
-                              >
-                                <ProjectProvider>
-                                  <LegacySyncProvider logger={input.host.logger}>
-                                    <SyncProviderV2>
-                                      <ThemeProvider mode={mode}>
-                                        <LocalBridge>
-                                          <PromptStashProvider>
-                                            <DialogProvider>
-                                              <FrecencyProvider>
-                                                <PromptHistoryProvider>
-                                                  <PromptRefProvider>
-                                                    <EditorContextProvider>
-                                                      <App
-                                                        onSnapshot={input.onSnapshot}
-                                                        pluginHost={input.pluginHost}
-                                                        host={input.host}
-                                                      />
-                                                    </EditorContextProvider>
-                                                  </PromptRefProvider>
-                                                </PromptHistoryProvider>
-                                              </FrecencyProvider>
-                                            </DialogProvider>
-                                          </PromptStashProvider>
-                                        </LocalBridge>
-                                      </ThemeProvider>
-                                    </SyncProviderV2>
-                                  </LegacySyncProvider>
-                                </ProjectProvider>
-                              </SDKProvider>
-                            </PluginRuntimeProvider>
-                          </TuiConfigProvider>
-                        </RouteProvider>
-                      </ToastProvider>
-                    </KVProvider>
-                  </ExitProvider>
-                </ArgsProvider>
-              </OpencodeKeymapProvider>
-            </TuiBuildInfoProvider>
-          </TuiPlatformProvider>
-        </TuiEnvironmentProvider>
-      </ErrorBoundary>
-    )
-  }, renderer)
-}
-
-function LegacySyncProvider(props: ParentProps & { logger: TuiHost["logger"] }) {
-  const kv = useKV()
-  return (
-    <SyncProvider kv={kv} logger={props.logger}>
-      {props.children}
-    </SyncProvider>
-  )
-}
-
-function LocalBridge(props: ParentProps) {
-  const theme = useTheme()
-  const toast = useToast()
-  const route = useRoute()
-  return (
-    <LocalProvider theme={theme.theme} toast={toast} route={route}>
-      {props.children}
-    </LocalProvider>
+export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
+  const global = yield* Global.Service
+  const epilogue = { value: undefined as string | undefined }
+  const output = yield* Effect.scoped(
+    Effect.gen(function* () {
+      const renderer = yield* Effect.acquireRelease(
+        Effect.tryPromise(() =>
+          createCliRenderer({
+            externalOutputMode: "passthrough",
+            targetFps: 60,
+            gatherStats: false,
+            exitOnCtrlC: false,
+            useKittyKeyboard: {},
+            autoFocus: false,
+            openConsoleOnError: false,
+            useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
+            consoleOptions: {
+              keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
+            },
+          }),
+        ),
+        (renderer) => Effect.sync(() => destroyRenderer(renderer)),
+      )
+      win32DisableProcessedInput()
+      const keymap = createDefaultOpenTuiKeymap(renderer)
+      yield* Effect.acquireRelease(
+        Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),
+        (unregister) => Effect.sync(unregister),
+      )
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(async () => {
+          try {
+            await input.pluginHost.dispose()
+          } catch (error) {
+            console.error("Failed to dispose TUI plugins", error)
+          }
+        }),
+      )
+      yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
+      const shutdown = yield* Deferred.make<void>()
+      const onSighup = () => destroyRenderer(renderer)
+      yield* Effect.acquireRelease(
+        Effect.sync(() => process.on("SIGHUP", onSighup)),
+        () => Effect.sync(() => process.off("SIGHUP", onSighup)),
+      )
+      renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
+      const pluginRuntime = createPluginRuntime()
+
+      yield* Effect.tryPromise(async () => {
+        // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
+        void renderer.getPalette({ size: 16 }).catch(() => undefined)
+        const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
+        if (renderer.isDestroyed) return
+
+        await render(() => {
+          return (
+            <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
+              <TuiPathsProvider
+                value={{
+                  cwd: process.cwd(),
+                  home: global.home,
+                  state: global.state,
+                  worktree: global.data + "/worktree",
+                }}
+              >
+                <TuiTerminalEnvironmentProvider
+                  value={{
+                    platform: process.platform,
+                    multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
+                    displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
+                  }}
+                >
+                  <TuiStartupProvider
+                    value={{
+                      initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
+                      skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
+                    }}
+                  >
+                    <ClipboardProvider>
+                      <EpilogueProvider set={(value) => (epilogue.value = value)}>
+                        <OpencodeKeymapProvider keymap={keymap}>
+                          <ArgsProvider {...input.args}>
+                            <KVProvider>
+                              <ToastProvider>
+                                <RouteProvider
+                                  initialRoute={
+                                    input.args.continue
+                                      ? {
+                                          type: "session",
+                                          sessionID: "dummy",
+                                        }
+                                      : undefined
+                                  }
+                                >
+                                  <TuiConfigProvider config={input.config}>
+                                    <PluginRuntimeProvider value={pluginRuntime}>
+                                      <SDKProvider
+                                        url={input.url}
+                                        directory={input.directory}
+                                        fetch={input.fetch}
+                                        headers={input.headers}
+                                        events={input.events}
+                                      >
+                                        <ProjectProvider>
+                                          <SyncProvider>
+                                            <SyncProviderV2>
+                                              <ThemeProvider mode={mode}>
+                                                <LocalProvider>
+                                                  <PromptStashProvider>
+                                                    <DialogProvider>
+                                                      <FrecencyProvider>
+                                                        <PromptHistoryProvider>
+                                                          <PromptRefProvider>
+                                                            <EditorContextProvider>
+                                                              <App
+                                                                onSnapshot={input.onSnapshot}
+                                                                pluginHost={input.pluginHost}
+                                                              />
+                                                            </EditorContextProvider>
+                                                          </PromptRefProvider>
+                                                        </PromptHistoryProvider>
+                                                      </FrecencyProvider>
+                                                    </DialogProvider>
+                                                  </PromptStashProvider>
+                                                </LocalProvider>
+                                              </ThemeProvider>
+                                            </SyncProviderV2>
+                                          </SyncProvider>
+                                        </ProjectProvider>
+                                      </SDKProvider>
+                                    </PluginRuntimeProvider>
+                                  </TuiConfigProvider>
+                                </RouteProvider>
+                              </ToastProvider>
+                            </KVProvider>
+                          </ArgsProvider>
+                        </OpencodeKeymapProvider>
+                      </EpilogueProvider>
+                    </ClipboardProvider>
+                  </TuiStartupProvider>
+                </TuiTerminalEnvironmentProvider>
+              </TuiPathsProvider>
+            </ErrorBoundary>
+          )
+        }, renderer)
+      })
+      yield* Deferred.await(shutdown)
+      return epilogue.value
+    }),
   )
-}
-
-function createTuiLifecycle(input: {
-  renderer: CliRenderer
-  unguard?: () => void
-  cleanup: () => Promise<void>
-  host: TuiHost
-}): TuiLifecycle {
-  let resolveExited!: () => void
-  const exited = new Promise<void>((resolve) => {
-    resolveExited = resolve
+  yield* Effect.sync(() => {
+    win32FlushInputBuffer()
+    if (output) process.stdout.write(output + "\n")
   })
-  let exitCompleted = false
-  let exiting = false
-  let cleanupTask: Promise<void> | undefined
-
-  const completeExit = () => {
-    if (exitCompleted) return
-    exitCompleted = true
-    resolveExited()
-  }
-
-  const cleanup = () => {
-    cleanupTask ??= (async () => {
-      offSighup?.()
-      try {
-        await input.cleanup()
-      } finally {
-        input.unguard?.()
-      }
-    })()
-    return cleanupTask
-  }
-
-  const exit = createExit(async (reason, message) => {
-    exiting = true
-    await cleanup()
-    if (!input.renderer.isDestroyed) {
-      input.renderer.setTerminalTitle("")
-      input.renderer.destroy()
-    }
-    input.host.lifecycle.flushInput?.()
-    if (reason) {
-      const formatted = input.host.formatError(reason) ?? input.host.formatUnknownError(reason)
-      if (formatted) input.host.lifecycle.writeStderr?.(formatted + "\n")
-    }
-    const text = message()
-    if (text) input.host.lifecycle.writeStdout?.(text + "\n")
-    completeExit()
-  })
-  const onSighup = () => {
-    void exit()
-  }
-
-  input.renderer.once("destroy", () => {
-    if (exiting) return
-    void cleanup().finally(() => {
-      input.host.lifecycle.flushInput?.()
-      completeExit()
-    })
-  })
-  const offSighup = input.host.lifecycle.onSighup?.(onSighup)
-
-  return {
-    exit,
-    exited,
-    async fail(error) {
-      exiting = true
-      await cleanup().catch(() => {})
-      if (!input.renderer.isDestroyed) input.renderer.destroy()
-      completeExit()
-      throw error
-    },
-  }
-}
-
-async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
-  await ready
-  await exited
-}
+})
 
-function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; host: TuiHost }) {
-  const environment = useTuiEnvironment()
-  const build = useTuiBuildInfo()
+function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
+  const startup = useTuiStartup()
   const tuiConfig = useTuiConfig()
   const route = useRoute()
   const dimensions = useTerminalDimensions()
@@ -474,15 +346,14 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   const { theme, mode, setMode, locked, lock, unlock } = themeState
   const sync = useSync()
   const project = useProject()
-  const exit = useExit()
   const promptRef = usePromptRef()
   const pluginRuntime = usePluginRuntime()
-  const attention = props.host.attention({ renderer, config: tuiConfig, kv })
-  const platform = useTuiPlatform()
+  const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
+  const clipboard = useClipboard()
 
   const api = createTuiApi(
     createTuiApiAdapters({
-      version: build.version,
+      version: InstallationVersion,
       tuiConfig,
       dialog,
       keymap,
@@ -518,8 +389,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   const offSelectionKeys = keymap.intercept(
     "key",
     ({ event }) => {
-      if (environment.capabilities.copyOnSelect) return
-      Selection.handleSelectionKey(renderer, toast, event, platform.clipboard)
+      if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
+      Selection.handleSelectionKey(renderer, toast, event, clipboard)
     },
     { priority: 1 },
   )
@@ -532,8 +403,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
   renderer.console.onCopySelection = async (text: string) => {
     if (!text || text.length === 0) return
 
-    await platform.clipboard
-      ?.write?.(text)
+    await clipboard
+      .write?.(text)
       .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
       .catch(toast.error)
 
@@ -546,7 +417,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
 
   // Update terminal window title based on current route and session
   createEffect(() => {
-    if (!terminalTitleEnabled() || !environment.capabilities.terminalTitle) return
+    if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
 
     if (route.data.type === "home") {
       renderer.setTerminalTitle("OpenCode")
@@ -695,8 +566,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         run: async () => {
           const workspace = currentWorktreeWorkspace()
           if (!workspace?.directory) return
-          await platform.clipboard
-            ?.write?.(workspace.directory)
+          await clipboard
+            .write?.(workspace.directory)
             .then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
             .catch(toast.error)
           dialog.clear()
@@ -706,7 +577,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         name: "workspace.list",
         title: "Manage workspaces",
         category: "Workspace",
-        hidden: !environment.capabilities.workspaces,
+        hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
         slashName: "workspaces",
         run: () => {
           dialog.replace(() => <DialogWorkspaceList />)
@@ -915,7 +786,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         title: "Exit the app",
         slashName: "exit",
         slashAliases: ["quit", "q"],
-        run: () => exit(),
+        run: () => destroyRenderer(renderer),
         category: "System",
       },
       {
@@ -955,10 +826,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         title: "Suspend terminal",
         category: "System",
         hidden: true,
-        enabled: environment.capabilities.terminalSuspend,
+        enabled: process.platform !== "win32",
         run: () => {
           renderer.suspend()
-          props.host.lifecycle.suspend?.(() => renderer.resume())
+          process.once("SIGCONT", () => renderer.resume())
+          process.kill(0, "SIGTSTP")
         },
       },
       {
@@ -1094,7 +966,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
     if (workspace !== project.workspace.current()) return
     const error = evt.properties.error
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
-    const message = errorMessage(error, props.host)
+    const message = errorMessage(error)
 
     toast.show({
       variant: "error",
@@ -1148,7 +1020,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
     )
 
-    void exit()
+    destroyRenderer(renderer)
   })
 
   const plugin = createMemo(() => {
@@ -1166,18 +1038,20 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       flexDirection="column"
       backgroundColor={theme.background}
       onMouseDown={(evt) => {
-        if (environment.capabilities.copyOnSelect) return
+        if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
         if (evt.button !== MouseButton.RIGHT) return
 
-        if (!Selection.copy(renderer, toast, platform.clipboard)) return
+        if (!Selection.copy(renderer, toast, clipboard)) return
         evt.preventDefault()
         evt.stopPropagation()
       }}
       onMouseUp={
-        environment.capabilities.copyOnSelect ? () => Selection.copy(renderer, toast, platform.clipboard) : undefined
+        !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
+          ? () => Selection.copy(renderer, toast, clipboard)
+          : undefined
       }
     >
-      <Show when={environment.capabilities.showTimeToFirstDraw}>
+      <Show when={Flag.OPENCODE_SHOW_TTFD}>
         <TimeToFirstDraw />
       </Show>
       <Show when={ready()}>
@@ -1199,7 +1073,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         </box>
         <pluginRuntime.Slot name="app" />
       </Show>
-      <Show when={!environment.skipInitialLoading}>
+      <Show when={!startup.skipInitialLoading}>
         <StartupLoading ready={ready} />
       </Show>
     </box>

+ 6 - 8
packages/opencode/src/cli/tui/attention.ts → packages/tui/src/attention.ts

@@ -1,3 +1,4 @@
+/// <reference path="./audio.d.ts" />
 import type {
   TuiAttention,
   TuiAttentionNotifyInput,
@@ -9,7 +10,7 @@ import type {
   TuiAttentionSoundPack,
   TuiAttentionSoundPackInfo,
 } from "@opencode-ai/plugin/tui"
-import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config"
+import { AttentionSoundName, type TuiConfig } from "./config"
 import { Schema } from "effect"
 import stripAnsi from "strip-ansi"
 import * as TuiAudio from "./audio"
@@ -19,7 +20,6 @@ import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with {
 import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
 import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
 import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
-import * as Log from "@opencode-ai/core/util/log"
 
 type FocusState = "unknown" | "focused" | "blurred"
 
@@ -38,8 +38,6 @@ type TuiAttentionHost = TuiAttention & {
   dispose(): void
 }
 
-const log = Log.create({ service: "tui.attention" })
-
 const DEFAULT_TITLE = "opencode"
 const DEFAULT_PACK_ID = "opencode.default"
 const KV_SOUND_PACK = "attention_sound_pack"
@@ -154,7 +152,7 @@ export function createTuiAttention(input: {
     try {
       for (const file of soundCandidates(name)) {
         const current = await audio.loadSoundFile(file).catch((error) => {
-          log.debug("failed to load attention sound", { file, error })
+          console.debug("failed to load attention sound", { file, error })
           return null
         })
         if (disposed) return false
@@ -163,7 +161,7 @@ export function createTuiAttention(input: {
       }
       return false
     } catch (error) {
-      log.debug("failed to play attention sound", { error })
+      console.debug("failed to play attention sound", { error })
       return false
     }
   }
@@ -189,7 +187,7 @@ export function createTuiAttention(input: {
                   normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
                 )
               } catch (error) {
-                log.debug("failed to trigger attention notification", { error })
+                console.debug("failed to trigger attention notification", { error })
                 return false
               }
             })()
@@ -212,7 +210,7 @@ export function createTuiAttention(input: {
           sound,
         }
       } catch (error) {
-        log.debug("failed to handle attention notification", { error })
+        console.debug("failed to handle attention notification", { error })
         return {
           ok: false,
           notification: false,

+ 9 - 0
packages/tui/src/audio.d.ts

@@ -0,0 +1,9 @@
+declare module "*.mp3" {
+  const path: string
+  export default path
+}
+
+declare module "@opencode-ai/ui/audio/*.mp3" {
+  const path: string
+  export default path
+}

+ 5 - 10
packages/opencode/src/cli/tui/audio.ts → packages/tui/src/audio.ts

@@ -1,7 +1,5 @@
 import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
-import * as Log from "@opencode-ai/core/util/log"
-
-const log = Log.create({ service: "tui.audio" })
+import { readFile } from "node:fs/promises"
 
 let audio: Audio | null | undefined
 const sounds = new Map<string, Promise<AudioSound | null>>()
@@ -11,12 +9,12 @@ function getAudio() {
   try {
     const next = Audio.create({ autoStart: false })
     next.on("error", (error: Error, context: AudioErrorContext) => {
-      log.debug("tui audio error", { error, context })
+      console.debug("tui audio error", { error, context })
     })
     audio = next
     return next
   } catch (error) {
-    log.debug("failed to create tui audio", { error })
+    console.debug("failed to create tui audio", { error })
     audio = null
     return null
   }
@@ -27,11 +25,10 @@ export function loadSoundFile(file: string) {
   if (!current) return Promise.resolve(null)
   const cached = sounds.get(file)
   if (cached) return cached
-  const task = Bun.file(file)
-    .bytes()
+  const task = readFile(file)
     .then((bytes) => current.loadSound(bytes))
     .catch((error) => {
-      log.debug("failed to load tui sound", { file, error })
+      console.debug("failed to load tui sound", { file, error })
       return null
     })
   sounds.set(file, task)
@@ -54,5 +51,3 @@ export function dispose() {
   audio = undefined
   sounds.clear()
 }
-
-export * as TuiAudio from "./audio"

+ 120 - 0
packages/tui/src/clipboard.ts

@@ -0,0 +1,120 @@
+import { execFile, spawn } from "node:child_process"
+import { readFile, rm } from "node:fs/promises"
+import { platform, release, tmpdir } from "node:os"
+import path from "node:path"
+import { promisify } from "node:util"
+
+const exec = promisify(execFile)
+
+function command(command: string, args: string[] = [], input?: string) {
+  return new Promise<Buffer>((resolve, reject) => {
+    const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
+    const output: Buffer[] = []
+    child.on("error", reject)
+    child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
+    child.on("close", (code) => {
+      if (code === 0) return resolve(Buffer.concat(output))
+      reject(new Error(`${command} exited with code ${code}`))
+    })
+    if (input !== undefined) child.stdin?.end(input)
+  })
+}
+
+function writeOsc52(text: string) {
+  if (!process.stdout.isTTY) return
+  const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
+  process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
+}
+
+export async function read() {
+  if (platform() === "darwin") {
+    const file = path.join(tmpdir(), "opencode-clipboard.png")
+    try {
+      await exec("osascript", [
+        "-e",
+        'set imageData to the clipboard as "PNGf"',
+        "-e",
+        `set fileRef to open for access POSIX file "${file}" with write permission`,
+        "-e",
+        "set eof fileRef to 0",
+        "-e",
+        "write imageData to fileRef",
+        "-e",
+        "close access fileRef",
+      ])
+      return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
+    } catch {
+      // Fall through to text clipboard.
+    } finally {
+      await rm(file, { force: true }).catch(() => {})
+    }
+  }
+
+  if (platform() === "win32" || release().includes("WSL")) {
+    const script =
+      "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
+    const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
+      Buffer.alloc(0),
+    )
+    if (image.length) return { data: image.toString().trim(), mime: "image/png" }
+  }
+
+  if (platform() === "linux") {
+    const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
+    if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
+    const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
+      Buffer.alloc(0),
+    )
+    if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
+  }
+
+  const { default: clipboardy } = await import("clipboardy")
+  const text = await clipboardy.read().catch(() => undefined)
+  if (text) return { data: text, mime: "text/plain" }
+}
+
+export function copyCommand(os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean): string[] | undefined {
+  if (os === "darwin" && has("osascript")) return ["osascript"]
+  if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
+  if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
+  if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
+  if (os === "win32" && has("powershell.exe")) {
+    return [
+      "powershell.exe",
+      "-NonInteractive",
+      "-NoProfile",
+      "-Command",
+      "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
+    ]
+  }
+}
+
+let copyMethod: Promise<(text: string) => Promise<void>> | undefined
+
+function getCopyMethod() {
+  return (copyMethod ??= (async () => {
+    const { which } = await import("@opencode-ai/core/util/which")
+    const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
+    if (native?.[0] === "osascript") {
+      return async (text: string) => {
+        const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+        await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
+      }
+    }
+    if (native) {
+      return async (text: string) => {
+        await command(native[0], native.slice(1), text).catch(() => undefined)
+      }
+    }
+    return async (text: string) => {
+      const { default: clipboardy } = await import("clipboardy")
+      await clipboardy.write(text).catch(() => undefined)
+    }
+  })())
+}
+
+export async function write(text: string) {
+  writeOsc52(text)
+  const method = await getCopyMethod()
+  await method(text)
+}

+ 4 - 3
packages/tui/src/component/dialog-move-session.tsx

@@ -7,7 +7,8 @@ import { useDialog } from "../ui/dialog"
 import { useSDK } from "../context/sdk"
 import { useTheme } from "../context/theme"
 import { useSync } from "../context/sync"
-import { abbreviateHome, useTuiEnvironment } from "../runtime"
+import { abbreviateHome } from "../runtime"
+import { useTuiPaths } from "../context/runtime"
 import { Locale } from "../util/locale"
 import { errorMessage } from "../util/error"
 import { useToast } from "../ui/toast"
@@ -32,7 +33,7 @@ export function DialogMoveSession(props: {
   const sync = useSync()
   const projectContext = useProject()
   const toast = useToast()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
   const [toDelete, setToDelete] = createSignal<string>()
   const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -101,7 +102,7 @@ export function DialogMoveSession(props: {
     })
     const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
     return list.map((item) => {
-      const title = abbreviateHome(item.location, environment.paths.home)
+      const title = abbreviateHome(item.location, paths.home)
       const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
       const visible = Locale.truncateLeft(title, titleWidth)
       const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length

+ 4 - 4
packages/tui/src/component/dialog-provider.tsx

@@ -14,7 +14,7 @@ import { useToast } from "../ui/toast"
 import { isConsoleManagedProvider } from "../util/provider-origin"
 import { useConnected } from "./use-connected"
 import { useBindings } from "../keymap"
-import { useTuiPlatform } from "../platform"
+import { useClipboard } from "../context/clipboard"
 
 const PROVIDER_PRIORITY: Record<string, number> = {
   opencode: 0,
@@ -242,7 +242,7 @@ function AutoMethod(props: AutoMethodProps) {
   const dialog = useDialog()
   const sync = useSync()
   const toast = useToast()
-  const platform = useTuiPlatform()
+  const clipboard = useClipboard()
 
   useBindings(() => ({
     bindings: [
@@ -253,8 +253,8 @@ function AutoMethod(props: AutoMethodProps) {
         cmd: () => {
           const code =
             props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
-          platform.clipboard
-            ?.write?.(code)
+          clipboard
+            .write?.(code)
             .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
             .catch(toast.error)
         },

+ 2 - 3
packages/tui/src/component/dialog-session-list.tsx

@@ -8,7 +8,7 @@ import { useProject } from "../context/project"
 import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
 import { useLocal } from "../context/local"
-import { useTuiEnvironment } from "../runtime"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
@@ -27,7 +27,6 @@ export function DialogSessionList() {
   const { theme } = useTheme()
   const sdk = useSDK()
   const local = useLocal()
-  const environment = useTuiEnvironment()
   const toast = useToast()
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
@@ -174,7 +173,7 @@ export function DialogSessionList() {
       const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
 
       let footer: JSX.Element | string = ""
-      if (environment.capabilities.workspaces) {
+      if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
         if (x.workspaceID) {
           footer = workspace ? (
             <WorkspaceLabel

+ 10 - 9
packages/tui/src/component/error-component.tsx

@@ -1,22 +1,23 @@
 import { TextAttributes } from "@opentui/core"
-import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
+import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
 import { createSignal } from "solid-js"
 import { getScrollAcceleration } from "../util/scroll"
-import { useTuiPlatform } from "../platform"
+import { useClipboard } from "../context/clipboard"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import { destroyRenderer } from "../util/renderer"
 
 export function ErrorComponent(props: {
   error: Error
   reset: () => void
-  exit: () => Promise<void>
-  version: string
   mode?: "dark" | "light"
 }) {
   const term = useTerminalDimensions()
-  const platform = useTuiPlatform()
+  const renderer = useRenderer()
+  const clipboard = useClipboard()
 
   useKeyboard((evt) => {
     if (evt.ctrl && evt.name === "c") {
-      void props.exit()
+      destroyRenderer(renderer)
     }
   })
   const [copied, setCopied] = createSignal(false)
@@ -43,10 +44,10 @@ export function ErrorComponent(props: {
     )
   }
 
-  issueURL.searchParams.set("opencode-version", props.version)
+  issueURL.searchParams.set("opencode-version", InstallationVersion)
 
   const copyIssueURL = () => {
-    void platform.clipboard?.write?.(issueURL.toString()).then(() => {
+    void clipboard.write?.(issueURL.toString()).then(() => {
       setCopied(true)
     })
   }
@@ -69,7 +70,7 @@ export function ErrorComponent(props: {
         <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
           <text fg={colors.bg}>Reset TUI</text>
         </box>
-        <box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
+        <box onMouseUp={() => destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}>
           <text fg={colors.bg}>Exit</text>
         </box>
       </box>

+ 4 - 4
packages/tui/src/component/prompt/autocomplete.tsx

@@ -10,7 +10,7 @@ import { useProject } from "../../context/project"
 import { useSDK } from "../../context/sdk"
 import { useSync } from "../../context/sync"
 import { getScrollAcceleration } from "../../util/scroll"
-import { useTuiEnvironment } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 import { useTuiConfig } from "../../config"
 import { useTheme, selectedForeground } from "../../context/theme"
 import { SplitBorder } from "../../ui/border"
@@ -92,7 +92,7 @@ export function Autocomplete(props: {
   const dimensions = useTerminalDimensions()
   const frecency = useFrecency()
   const tuiConfig = useTuiConfig()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const [store, setStore] = createStore({
     index: 0,
     selected: 0,
@@ -236,7 +236,7 @@ export function Autocomplete(props: {
   }
 
   function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
-    const baseDir = (sync.path.directory || environment.cwd).replace(/\/+$/, "")
+    const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
     const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
     const urlObj = pathToFileURL(fullPath)
     const filename =
@@ -306,7 +306,7 @@ export function Autocomplete(props: {
   })
 
   function normalizeMentionPath(filePath: string) {
-    const baseDir = sync.path.directory || environment.cwd
+    const baseDir = sync.path.directory || paths.cwd
     const absolute = path.resolve(filePath)
     const relative = path.relative(baseDir, absolute)
 

+ 16 - 13
packages/tui/src/component/prompt/index.tsx

@@ -14,10 +14,11 @@ import "opentui-spinner/solid"
 import path from "path"
 import { fileURLToPath } from "url"
 import { useLocal } from "../../context/local"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import { tint, useTheme } from "../../context/theme"
 import { EmptyBorder, SplitBorder } from "../../ui/border"
-import { useTuiEnvironment } from "../../runtime"
-import { useTuiPlatform } from "../../platform"
+import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
+import { useClipboard } from "../../context/clipboard"
 import { Spinner } from "../spinner"
 import { useSDK } from "../../context/sdk"
 import { useRoute } from "../../context/route"
@@ -25,6 +26,8 @@ import { useProject } from "../../context/project"
 import { useSync } from "../../context/sync"
 import { useEvent } from "../../context/event"
 import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
+import { openEditor } from "../../editor"
+import { destroyRenderer } from "../../util/renderer"
 import { promptOffsetWidth } from "../../prompt/display"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { usePromptHistory, type PromptInfo } from "../../prompt/history"
@@ -34,7 +37,6 @@ import { usePromptStash } from "../../prompt/stash"
 import { DialogStash } from "../dialog-stash"
 import { type AutocompleteRef, Autocomplete } from "./autocomplete"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
-import { useExit } from "../../context/exit"
 import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
 import { Locale } from "../../util/locale"
 import { errorMessage } from "../../util/error"
@@ -143,8 +145,9 @@ export function Prompt(props: PromptProps) {
   const leader = useLeaderActive()
   const local = useLocal()
   const args = useArgs()
-  const environment = useTuiEnvironment()
-  const platform = useTuiPlatform()
+  const paths = useTuiPaths()
+  const terminalEnvironment = useTuiTerminalEnvironment()
+  const clipboard = useClipboard()
   const sdk = useSDK()
   const editor = useEditorContext()
   const route = useRoute()
@@ -366,7 +369,7 @@ export function Prompt(props: PromptProps) {
         run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
           ctx.event.preventDefault()
           ctx.event.stopPropagation()
-          const content = await platform.clipboard?.read?.()
+          const content = await clipboard.read?.()
           if (content?.mime.startsWith("image/")) {
             await pasteAttachment({
               filename: "clipboard",
@@ -430,12 +433,13 @@ export function Prompt(props: PromptProps) {
           const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
 
           const value = text
-          const content = await platform.editor?.open({
+          const content = await openEditor({
+            renderer,
             value,
             cwd:
               (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
               project.instance.directory() ||
-              environment.cwd,
+              paths.cwd,
           })
           if (!content) return
 
@@ -526,7 +530,7 @@ export function Prompt(props: PromptProps) {
         desc: "Change the workspace for the session",
         name: "workspace.set",
         category: "Session",
-        enabled: environment.capabilities.workspaces,
+        enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
         slashName: "warp",
         run: () => {
           workspace.open()
@@ -950,7 +954,7 @@ export function Prompt(props: PromptProps) {
     if (!agent) return false
     const trimmed = store.prompt.input.trim()
     if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
-      void exit()
+      destroyRenderer(renderer)
       return true
     }
     const selectedModel = local.model.current()
@@ -1124,7 +1128,6 @@ export function Prompt(props: PromptProps) {
     if (finishMoveProgress) move.finishSubmit()
     return true
   }
-  const exit = useExit()
 
   function pasteText(text: string, virtualText: string) {
     const currentOffset = input.cursorOffset
@@ -1163,10 +1166,10 @@ export function Prompt(props: PromptProps) {
   async function pasteInputText(text: string) {
     const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
     const pastedContent = normalizedText.trim()
-    const filepath = pastedFilepath(pastedContent, environment.platform)
+    const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
     const isUrl = /^(https?):\/\//.test(filepath)
     if (!isUrl) {
-      const attachment = await readLocalAttachment(platform.files, filepath)
+      const attachment = await readLocalAttachment(filepath)
       const filename = path.basename(filepath)
       if (attachment?.type === "text") {
         pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)

+ 31 - 2
packages/tui/src/component/prompt/local-attachment.ts

@@ -1,10 +1,39 @@
-import type { PlatformFiles } from "../../platform"
+import { readFile } from "node:fs/promises"
+import path from "node:path"
+
+export type LocalFiles = Readonly<{
+  readText(path: string): Promise<string>
+  readBytes(path: string): Promise<Uint8Array>
+  mime(path: string): Promise<string>
+}>
 
 export type LocalAttachment =
   | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
   | Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
 
-export async function readLocalAttachment(files: PlatformFiles, path: string): Promise<LocalAttachment | undefined> {
+export function readLocalAttachment(file: string) {
+  return readLocalAttachmentWith(
+    {
+      readText: (value) => readFile(value, "utf8"),
+      readBytes: (value) => readFile(value),
+      mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
+    },
+    file,
+  )
+}
+
+const mimeTypes: Record<string, string> = {
+  ".avif": "image/avif",
+  ".gif": "image/gif",
+  ".jpeg": "image/jpeg",
+  ".jpg": "image/jpeg",
+  ".pdf": "application/pdf",
+  ".png": "image/png",
+  ".svg": "image/svg+xml",
+  ".webp": "image/webp",
+}
+
+export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
   const mime = await files.mime(path).catch(() => undefined)
   if (!mime) return
   if (mime === "image/svg+xml") {

+ 3 - 3
packages/tui/src/component/prompt/move.tsx

@@ -1,6 +1,6 @@
 import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import path from "path"
-import { useTuiEnvironment } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 import { errorMessage } from "../../util/error"
 import { useDialog } from "../../ui/dialog"
 import { useSDK } from "../../context/sdk"
@@ -20,7 +20,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
   const sync = useSync()
   const toast = useToast()
   const homeDestination = useHomeSessionDestination()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const [creating, setCreating] = createSignal(false)
   const [creatingDots, setCreatingDots] = createSignal(3)
   const [progress, setProgress] = createSignal<string>()
@@ -35,7 +35,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
         {
           projectID,
           strategy: "git_worktree",
-          directory: path.join(environment.paths.worktree, projectID.slice(0, 6)),
+          directory: path.join(paths.worktree, projectID.slice(0, 6)),
           context,
         },
         { throwOnError: true },

+ 18 - 0
packages/tui/src/context/clipboard.tsx

@@ -0,0 +1,18 @@
+import { createContext, type JSX, useContext } from "solid-js"
+import { read, write } from "../clipboard"
+
+export type ClipboardContent = Readonly<{ data: string; mime: string }>
+export type ClipboardService = Readonly<{
+  read?(): Promise<ClipboardContent | undefined>
+  write?(text: string): Promise<void>
+}>
+const clipboard = { read, write }
+const ClipboardContext = createContext<ClipboardService>(clipboard)
+
+export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
+  return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
+}
+
+export function useClipboard() {
+  return useContext(ClipboardContext)
+}

+ 5 - 4
packages/tui/src/context/directory.ts

@@ -1,15 +1,16 @@
 import { createMemo } from "solid-js"
 import { useProject } from "./project"
 import { useSync } from "./sync"
-import { abbreviateHome, useTuiEnvironment } from "../runtime"
+import { abbreviateHome } from "../runtime"
+import { useTuiPaths } from "./runtime"
 
 export function useDirectory() {
   const project = useProject()
   const sync = useSync()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   return createMemo(() => {
-    const directory = project.instance.path().directory || environment.cwd
-    const result = abbreviateHome(directory, environment.paths.home)
+    const directory = project.instance.path().directory || paths.cwd
+    const result = abbreviateHome(directory, paths.home)
     if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
     return result
   })

+ 22 - 14
packages/tui/src/context/editor.ts

@@ -2,9 +2,9 @@ import { onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Option, Schema, SchemaGetter } from "effect"
 import { isRecord } from "../util/record"
-import { useTuiEnvironment } from "../runtime"
-import { useOptionalTuiPlatform } from "../platform"
+import { useTuiPaths } from "./runtime"
 import { createSimpleContext } from "./helper"
+import { editorIntegration } from "../editor"
 
 const MCP_PROTOCOL_VERSION = "2025-11-25"
 
@@ -104,11 +104,20 @@ type EditorConnection = {
   source: string
 }
 
+export type EditorIntegration = Readonly<{
+  connection?(directory: string): EditorConnection | undefined
+  selection?(directory: string): Promise<unknown>
+}>
+
 export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
   name: "EditorContext",
-  init: (props: { WebSocketImpl?: typeof WebSocket }) => {
-    const environment = useTuiEnvironment()
-    const platform = useOptionalTuiPlatform()
+  init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => {
+    const paths = useTuiPaths()
+    const editor = props.integration ?? editorIntegration
+    const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
+    const parsedPort = value ? Number.parseInt(value, 10) : undefined
+    const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined
+    const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
     const mentionListeners = new Set<(mention: EditorMention) => void>()
     const WebSocketImpl = props.WebSocketImpl ?? WebSocket
     const [store, setStore] = createStore<{
@@ -130,7 +139,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     let requestID = 0
     let zedSelection: Promise<void> | undefined
     let lastZedSelectionKey: string | undefined
-    let directory = environment.cwd
+    let directory = paths.cwd
     let preserveSelectionOnReconnect = false
     const pending = new Map<number, string>()
 
@@ -163,20 +172,20 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     const connect = () => {
       if (closed) return
 
-      const connection = resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection)
+      const connection = resolveEditorConnection(directory, port, editor.connection)
       if (!connection) {
-        if (!environment.editor.zedTerminal) {
+        if (!zedTerminal) {
           setStore("status", "disabled")
           scheduleReconnect()
           return
         }
-
-        if (!platform?.editor?.selection) {
+        if (!editor.selection) {
           setStore("status", "disabled")
           scheduleReconnect()
           return
         }
-        zedSelection ??= platform.editor
+
+        zedSelection ??= editor
           .selection(directory)
           .then((result) => {
             if (closed || socket) return
@@ -278,7 +287,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     }
 
     const reconnectWithDirectory = (nextDirectory?: string) => {
-      const resolved = nextDirectory || environment.cwd
+      const resolved = nextDirectory || paths.cwd
       const sameDirectory = directory === resolved
       clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
       if (sameDirectory) return
@@ -311,8 +320,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
     return {
       enabled() {
         return Boolean(
-          resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) ||
-            (environment.editor.zedTerminal && platform?.editor?.selection),
+          resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection),
         )
       },
       connected() {

+ 6 - 0
packages/tui/src/context/epilogue.tsx

@@ -0,0 +1,6 @@
+import { createSimpleContext } from "./helper"
+
+export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
+  name: "Epilogue",
+  init: (props: { set(value?: string): void }) => props.set,
+})

+ 0 - 42
packages/tui/src/context/exit.tsx

@@ -1,42 +0,0 @@
-import { createSimpleContext } from "./helper"
-
-export type Exit = ((reason?: unknown) => Promise<void>) & {
-  message: {
-    set: (value?: string) => () => void
-    clear: () => void
-    get: () => string | undefined
-  }
-}
-
-export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
-  let message: string | undefined
-  let task: Promise<void> | undefined
-  const store = {
-    set: (value?: string) => {
-      const prev = message
-      message = value
-      return () => {
-        message = prev
-      }
-    },
-    clear: () => {
-      message = undefined
-    },
-    get: () => message,
-  }
-
-  return Object.assign(
-    (reason?: unknown) => {
-      task ??= run(reason, store.get)
-      return task
-    },
-    {
-      message: store,
-    },
-  ) satisfies Exit
-}
-
-export const { use: useExit, provider: ExitProvider } = createSimpleContext({
-  name: "Exit",
-  init: (input: { exit: Exit }) => input.exit,
-})

+ 13 - 4
packages/tui/src/context/kv.tsx

@@ -1,18 +1,25 @@
 import { createSignal, type Setter } from "solid-js"
 import { createStore, unwrap } from "solid-js/store"
 import { createSimpleContext } from "./helper"
-import { useOptionalTuiPlatform } from "../platform"
+import { Flock } from "@opencode-ai/core/util/flock"
+import { Global } from "@opencode-ai/core/global"
+import { readJson, writeJsonAtomic } from "../util/persistence"
+import { useTuiPaths } from "./runtime"
+import path from "path"
 
 export const { use: useKV, provider: KVProvider } = createSimpleContext({
   name: "KV",
   init: () => {
-    const platform = useOptionalTuiPlatform()
+    const paths = useTuiPaths()
+    void Global.Path.state
+    const file = path.join(paths.state, "kv.json")
+    const lock = `tui-kv:${file}`
     const [ready, setReady] = createSignal(false)
     const [store, setStore] = createStore<Record<string, any>>()
     // Queue same-process writes so rapid updates persist in order.
     let write = Promise.resolve()
 
-    ;(platform?.state?.read() ?? Promise.resolve({}))
+    ;Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
       .then((x) => {
         setStore(x)
       })
@@ -48,7 +55,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
         setStore(key, value)
         const snapshot = structuredClone(unwrap(store))
         write = write
-          .then(() => platform?.state?.write(snapshot))
+          .then(() =>
+            Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)),
+          )
           .catch((error) => {
             console.error("Failed to write KV state", { error })
           })

+ 11 - 19
packages/tui/src/context/local.tsx

@@ -4,11 +4,14 @@ import { batch, createEffect, createMemo } from "solid-js"
 import { useSync } from "./sync"
 import { useEvent } from "./event"
 import path from "path"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiPaths } from "./runtime"
 import { useArgs } from "./args"
 import { useSDK } from "./sdk"
 import { RGBA } from "@opentui/core"
 import { readJson, writeJsonAtomic } from "../util/persistence"
+import { useTheme } from "./theme"
+import { useToast } from "../ui/toast"
+import { useRoute } from "./route"
 
 export type LocalTheme = {
   secondary: RGBA
@@ -20,17 +23,6 @@ export type LocalTheme = {
   info: RGBA
 }
 
-export type LocalDependencies = {
-  theme: LocalTheme
-  toast: {
-    show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void
-  }
-  route: {
-    readonly data: { type: string; sessionID?: string }
-    navigate(route: { type: "session"; sessionID: string }): void
-  }
-}
-
 export function parseModel(model: string) {
   const [providerID, ...rest] = model.split("/")
   return {
@@ -57,11 +49,13 @@ export function recentModels(
 
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
-  init: (props: LocalDependencies) => {
+  init: () => {
     const sync = useSync()
     const sdk = useSDK()
-    const toast = props.toast
-    const environment = useTuiEnvironment()
+    const toast = useToast()
+    const theme = useTheme().theme
+    const route = useRoute()
+    const paths = useTuiPaths()
 
     function isModelValid(model: { providerID: string; modelID: string }) {
       const provider = sync.data.provider.find((x) => x.id === model.providerID)
@@ -82,7 +76,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       const [agentStore, setAgentStore] = createStore({
         current: undefined as string | undefined,
       })
-      const theme = props.theme
       const colors = createMemo(() => [
         theme.secondary,
         theme.accent,
@@ -164,7 +157,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         variant: {},
       })
 
-      const filePath = path.join(environment.paths.state, "model.json")
+      const filePath = path.join(paths.state, "model.json")
       const state = {
         pending: false,
       }
@@ -421,7 +414,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         pinned: [],
       })
 
-      const filePath = path.join(environment.paths.state, "session.json")
+      const filePath = path.join(paths.state, "session.json")
       const state = {
         pending: false,
       }
@@ -453,7 +446,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (state.pending) save()
         })
 
-      const route = props.route
       const event = useEvent()
 
       const slots = createMemo(() => {

+ 5 - 4
packages/tui/src/context/path-format.tsx

@@ -1,6 +1,7 @@
 import path from "path"
 import { createContext, useContext, type ParentProps } from "solid-js"
-import { abbreviateHome, useTuiEnvironment } from "../runtime"
+import { abbreviateHome } from "../runtime"
+import { useTuiPaths } from "./runtime"
 
 const context = createContext<{
   path: () => string
@@ -8,12 +9,12 @@ const context = createContext<{
 }>()
 
 export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   return (
     <context.Provider
       value={{
-        path: () => props.path || environment.cwd,
-        format: (input) => formatPath(input, props.path || environment.cwd, environment.paths.home),
+        path: () => props.path || paths.cwd,
+        format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
       }}
     >
       {props.children}

+ 3 - 3
packages/tui/src/context/route.tsx

@@ -1,7 +1,7 @@
 import { createStore, reconcile } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import type { PromptInfo } from "../prompt/history"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiStartup } from "./runtime"
 
 export type HomeRoute = {
   type: "home"
@@ -25,9 +25,9 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
 export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
   name: "Route",
   init: (props: { initialRoute?: Route }) => {
-    const environment = useTuiEnvironment()
+    const startup = useTuiStartup()
     const [store, setStore] = createStore<Route>(
-      props.initialRoute ?? initialRoute(environment.initialRoute) ?? { type: "home" },
+      props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
     )
 
     return {

+ 62 - 0
packages/tui/src/context/runtime.tsx

@@ -0,0 +1,62 @@
+import { createComponent, createContext, type JSX, useContext } from "solid-js"
+
+export type TuiPaths = Readonly<{
+  cwd: string
+  home: string
+  state: string
+  worktree: string
+}>
+
+export type TuiTerminalEnvironment = Readonly<{
+  platform: string
+  multiplexer?: "tmux" | "screen"
+  displayServer?: "wayland" | "x11"
+}>
+
+export type TuiStartup = Readonly<{
+  initialRoute?: unknown
+  skipInitialLoading: boolean
+}>
+
+const PathsContext = createContext<TuiPaths>()
+const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
+const StartupContext = createContext<TuiStartup>()
+
+function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
+  return createComponent(context.Provider, {
+    value: Object.freeze({ ...value }),
+    get children() {
+      return children()
+    },
+  })
+}
+
+export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
+  return provider(PathsContext, props.value, () => props.children)
+}
+
+export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
+  return provider(TerminalEnvironmentContext, props.value, () => props.children)
+}
+
+export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
+  return provider(StartupContext, props.value, () => props.children)
+}
+
+function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
+  const value = useContext(context)
+  if (!value) throw new Error(`${name} is missing`)
+  return value
+}
+
+export function useTuiPaths() {
+  return required(PathsContext, "TuiPathsProvider")
+}
+
+export function useTuiTerminalEnvironment() {
+  return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
+}
+
+export function useTuiStartup() {
+  return required(StartupContext, "TuiStartupProvider")
+}

+ 3 - 4
packages/tui/src/context/sdk.tsx

@@ -1,6 +1,6 @@
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-import { useTuiEnvironment } from "../runtime"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import { createSimpleContext } from "./helper"
 import { batch, onCleanup, onMount } from "solid-js"
 
@@ -17,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
     headers?: RequestInit["headers"]
     events?: EventSource
   }) => {
-    const environment = useTuiEnvironment()
     const abort = new AbortController()
     let sse: AbortController | undefined
 
@@ -94,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
             sseMaxRetryAttempts: 0,
           })
 
-          if (environment.capabilities.workspaces) {
+          if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
             // Start syncing workspaces, it's important to do this after
             // we've started listening to events
             await sdk.sync.start().catch(() => {})
@@ -122,7 +121,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
         const unsub = await props.events.subscribe(handleEvent)
         onCleanup(unsub)
 
-        if (environment.capabilities.workspaces) {
+        if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
           // Start syncing workspaces, it's important to do this after
           // we've started listening to events
           await sdk.sync.start().catch(() => {})

+ 12 - 19
packages/tui/src/context/sync.tsx

@@ -24,13 +24,15 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { useProject } from "./project"
 import { useEvent } from "./event"
 import { useSDK } from "./sdk"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiStartup } from "./runtime"
 import { createSimpleContext } from "./helper"
-import { useExit } from "./exit"
+import { useRenderer } from "@opentui/solid"
 import { useArgs } from "./args"
 import { batch, onMount } from "solid-js"
 import path from "path"
 import { aggregateFailures } from "./aggregate-failures"
+import { useKV } from "./kv"
+import { destroyRenderer } from "../util/renderer"
 
 const emptyConsoleState: ConsoleState = {
   consoleManagedProviders: [],
@@ -50,19 +52,11 @@ function search<T>(items: T[], target: string, key: (item: T) => string) {
   return { found: false, index: left }
 }
 
-export type SyncDependencies = {
-  kv: { get(key: string, defaultValue: boolean): boolean }
-  logger: { error(message: string, extra?: Record<string, unknown>): void }
-}
-
-export const {
-  context: SyncContext,
-  use: useSync,
-  provider: SyncProvider,
-} = createSimpleContext({
+export const { context: SyncContext, use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
-  init: (dependencies: SyncDependencies) => {
-    const environment = useTuiEnvironment()
+  init: () => {
+    const startup = useTuiStartup()
+    const kv = useKV()
     const [store, setStore] = createStore<{
       status: "loading" | "partial" | "complete"
       provider: Provider[]
@@ -136,7 +130,6 @@ export const {
     const event = useEvent()
     const project = useProject()
     const sdk = useSDK()
-    const kv = dependencies.kv
 
     const fullSyncedSessions = new Set<string>()
     const syncingSessions = new Map<string, Promise<void>>()
@@ -427,7 +420,7 @@ export const {
       }
     })
 
-    const exit = useExit()
+    const renderer = useRenderer()
     const args = useArgs()
 
     async function bootstrap(input: { fatal?: boolean } = {}) {
@@ -520,13 +513,13 @@ export const {
           })
         })
         .catch(async (e) => {
-          dependencies.logger.error("tui bootstrap failed", {
+          console.error("tui bootstrap failed", {
             error: e instanceof Error ? e.message : String(e),
             name: e instanceof Error ? e.name : undefined,
             stack: e instanceof Error ? e.stack : undefined,
           })
           if (fatal) {
-            await exit(e)
+            destroyRenderer(renderer)
           } else {
             throw e
           }
@@ -544,7 +537,7 @@ export const {
         return store.status
       },
       get ready() {
-        if (environment.skipInitialLoading) return true
+        if (startup.skipInitialLoading) return true
         return store.status !== "loading"
       },
       get path() {

+ 41 - 5
packages/tui/src/context/theme.tsx

@@ -24,7 +24,41 @@ import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { useKV } from "./kv"
 import { useTuiConfig } from "../config"
-import { useOptionalTuiPlatform } from "../platform"
+import { Global } from "@opencode-ai/core/global"
+import { Glob } from "@opencode-ai/core/util/glob"
+import { readFile } from "node:fs/promises"
+import path from "node:path"
+
+export type ThemeSource = Readonly<{
+  discover(): Promise<Record<string, unknown>>
+  subscribeRefresh?(refresh: () => void): () => void
+}>
+
+const themeSource: ThemeSource = {
+  async discover() {
+    const directories = [Global.Path.config]
+    for (let current = process.cwd(); ; current = path.dirname(current)) {
+      directories.push(path.join(current, ".opencode"))
+      if (path.dirname(current) === current) break
+    }
+    return discoverThemes(directories)
+  },
+  subscribeRefresh(refresh) {
+    process.on("SIGUSR2", refresh)
+    return () => process.off("SIGUSR2", refresh)
+  },
+}
+
+export async function discoverThemes(directories: string[]) {
+  const result: Record<string, unknown> = {}
+  for (const directory of directories) {
+      const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
+      for (const file of files) {
+        result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
+      }
+  }
+  return result
+}
 
 export {
   DEFAULT_THEMES,
@@ -67,11 +101,11 @@ subscribeThemes((themes) => setStore("themes", themes))
 
 export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
   name: "Theme",
-  init: (props: { mode: "dark" | "light" }) => {
+  init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
     const renderer = useRenderer()
     const config = useTuiConfig()
     const kv = useKV()
-    const platform = useOptionalTuiPlatform()
+    const themes = props.source ?? themeSource
     const pick = (value: unknown) => {
       if (value === "dark" || value === "light") return value
       return
@@ -96,7 +130,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
     })
 
     function syncCustomThemes() {
-      return (platform?.themes?.discover() ?? Promise.resolve({}))
+      return themes
+        .discover()
         .then((themes) => {
           setCustomThemes(
             Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
@@ -207,7 +242,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         }, delay),
       )
     }
-    const unsubscribeRefresh = platform?.themes?.subscribeRefresh?.(refresh)
+    let unsubscribeRefresh: (() => void) | undefined
+    unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
 
     onCleanup(() => {
       renderer.off(CliRenderEvents.THEME_MODE, handle)

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

@@ -1,9 +1,10 @@
 import { Database } from "bun:sqlite"
 import { statSync } from "node:fs"
+import { readFile as readFileAsync } from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
 import { Option, Schema } from "effect"
-import type { EditorSelection } from "@opencode-ai/tui/context/editor"
+import type { EditorSelection } from "./context/editor"
 
 const ZedEditorRowSchema = Schema.Struct({
   item_kind: Schema.String,
@@ -63,9 +64,7 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
   const text =
     contents.type === "contents" && contents.contents != null
       ? contents.contents
-      : await Bun.file(row.buffer_path)
-          .text()
-          .catch(() => undefined)
+      : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
   if (text == null) return { type: "unavailable" }
 
   const ranges = byteRanges.map((range) => {

+ 84 - 0
packages/tui/src/editor.ts

@@ -0,0 +1,84 @@
+import type { CliRenderer } from "@opentui/core"
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
+import { readFile, rm, writeFile } from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { spawn } from "node:child_process"
+import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
+
+export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) {
+  const editor = process.env.VISUAL || process.env.EDITOR
+  if (!editor) return
+  const file = path.join(os.tmpdir(), `${Date.now()}.md`)
+  await writeFile(file, input.value)
+  input.renderer.suspend()
+  input.renderer.currentRenderBuffer.clear()
+  try {
+    await new Promise<void>((resolve, reject) => {
+      const parts = editor.split(" ")
+      const child = spawn(parts[0]!, [...parts.slice(1), file], {
+        cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
+        stdio: "inherit",
+        shell: process.platform === "win32",
+      })
+      child.on("error", reject)
+      child.on("exit", (code, signal) => {
+        if (code === 0) return resolve()
+        reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
+      })
+    })
+    return (await readFile(file, "utf8")) || undefined
+  } finally {
+    await rm(file, { force: true }).catch(() => {})
+    input.renderer.currentRenderBuffer.clear()
+    input.renderer.resume()
+    input.renderer.requestRender()
+  }
+}
+
+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, "utf8")) 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
+  }
+}
+
+export const editorIntegration = {
+  connection: discoverEditorConnection,
+  selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
+}

+ 5 - 4
packages/tui/src/feature-plugins/home/footer.tsx

@@ -1,7 +1,8 @@
 import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { BuiltinTuiPlugin } from "../builtins"
 import { createMemo, Match, Show, Switch } from "solid-js"
-import { abbreviateHome, useTuiEnvironment } from "../../runtime"
+import { abbreviateHome } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 import { useHomeSessionDestination } from "../../routes/home/session-destination"
 
 const id = "internal:home-footer"
@@ -9,13 +10,13 @@ const id = "internal:home-footer"
 function Directory(props: { api: TuiPluginApi }) {
   const theme = () => props.api.theme.current
   const destination = useHomeSessionDestination()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const dir = createMemo(() => {
     const selected = destination?.destination()
     if (!selected || selected.type === "new") return
-    const out = abbreviateHome(selected.directory, environment.paths.home)
+    const out = abbreviateHome(selected.directory, paths.home)
     const branch =
-      selected.directory === (props.api.state.path.directory || environment.cwd)
+      selected.directory === (props.api.state.path.directory || paths.cwd)
         ? props.api.state.vcs?.branch
         : undefined
     if (branch) return out + ":" + branch

+ 4 - 8
packages/tui/src/feature-plugins/home/tips-view.tsx

@@ -2,7 +2,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
 import { createMemo, For, type Accessor } from "solid-js"
 import { DEFAULT_THEMES, useTheme } from "../../context/theme"
 import { useCommandShortcut } from "../../keymap"
-import { useTuiEnvironment } from "../../runtime"
 
 const themeCount = Object.keys(DEFAULT_THEMES).length
 
@@ -97,7 +96,6 @@ function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
 
 export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
   const theme = useTheme().theme
-  const environment = useTuiEnvironment()
   const tipOffset = Math.random()
   const shortcuts: Shortcuts = {
     agentCycle: useCommandShortcut("agent.cycle"),
@@ -136,12 +134,10 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
   }
   const tip = createMemo(() => {
     if (props.connected === false) return NO_MODELS_TIP
-    const tips = [...TIPS, environment.capabilities.terminalSuspend ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap(
-      (item) => {
-        const value = typeof item === "string" ? item : item(shortcuts)
-        return value ? [value] : []
-      },
-    )
+    const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
+      const value = typeof item === "string" ? item : item(shortcuts)
+      return value ? [value] : []
+    })
     return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
   }, NO_MODELS_TIP)
   // Solid can expose a memo's initial value while a pure computation is pending.

+ 5 - 4
packages/tui/src/feature-plugins/sidebar/footer.tsx

@@ -1,12 +1,13 @@
 import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { BuiltinTuiPlugin } from "../builtins"
 import { createMemo, Show } from "solid-js"
-import { abbreviateHome, useTuiEnvironment } from "../../runtime"
+import { abbreviateHome } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 
 const id = "internal:sidebar-footer"
 
 function View(props: { api: TuiPluginApi; sessionID: string }) {
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const theme = () => props.api.theme.current
   const has = createMemo(() =>
     props.api.state.provider.some(
@@ -17,8 +18,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
   const show = createMemo(() => !has() && !done())
   const path = createMemo(() => {
     const session = props.api.state.session.get(props.sessionID)
-    const dir = session?.directory || props.api.state.path.directory || environment.cwd
-    const out = abbreviateHome(dir, environment.paths.home)
+    const dir = session?.directory || props.api.state.path.directory || paths.cwd
+    const out = abbreviateHome(dir, paths.home)
     const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
     const text = branch ? out + ":" + branch : out
     const list = text.split("/")

+ 2 - 2
packages/tui/src/feature-plugins/system/session-v2.tsx

@@ -10,7 +10,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
 import { useBindings } from "../../keymap"
 import { Locale } from "../../util/locale"
-import { useTuiEnvironment } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
 import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display"
 import path from "path"
@@ -1122,7 +1122,7 @@ function input(input: Record<string, unknown>, omit?: string[]) {
 }
 
 function usePathNormalizer() {
-  const cwd = useTuiEnvironment().cwd
+  const cwd = useTuiPaths().cwd
   return (input?: string) => normalizePath(input, cwd)
 }
 

+ 1 - 22
packages/tui/src/index.tsx

@@ -1,22 +1 @@
-export {
-  createTuiBuildInfo,
-  createTuiEnvironment,
-  TuiBuildInfoProvider,
-  TuiEnvironmentProvider,
-  useTuiBuildInfo,
-  useTuiEnvironment,
-  type TuiBuildInfo,
-  type TuiEnvironment,
-} from "./runtime"
-export {
-  createTuiRenderer,
-  createTuiRenderer as createRenderer,
-  mount,
-  tui,
-  tui as run,
-  tuiRendererConfig,
-  type TuiHandle,
-  type TuiHost,
-  type TuiInput,
-  type TuiRuntimeInput,
-} from "./app"
+export { run, type TuiInput } from "./app"

+ 0 - 52
packages/tui/src/platform.tsx

@@ -1,52 +0,0 @@
-import { createContext, type JSX, useContext } from "solid-js"
-
-export type PlatformFiles = Readonly<{
-  readText(path: string): Promise<string>
-  readBytes(path: string): Promise<Uint8Array>
-  mime(path: string): Promise<string>
-}>
-
-export type PlatformClipboardContent = Readonly<{
-  data: string
-  mime: string
-}>
-
-export type TuiPlatform = Readonly<{
-  files: PlatformFiles
-  state?: Readonly<{
-    read(): Promise<Record<string, unknown>>
-    write(value: Record<string, unknown>): Promise<void>
-  }>
-  themes?: Readonly<{
-    discover(): Promise<Record<string, unknown>>
-    subscribeRefresh?(refresh: () => void): () => void
-  }>
-  clipboard?: Readonly<{
-    read?(): Promise<PlatformClipboardContent | undefined>
-    write?(text: string): Promise<void>
-  }>
-  editor?: Readonly<{
-    open(input: Readonly<{ value: string; cwd?: string }>): Promise<string | undefined>
-    connection?(directory: string): Readonly<{ url: string; authToken?: string; source: string }> | undefined
-    selection?(directory: string): Promise<unknown>
-  }>
-  export?: Readonly<{
-    write(path: string, content: string): Promise<void>
-  }>
-}>
-
-const PlatformContext = createContext<TuiPlatform>()
-
-export function TuiPlatformProvider(props: { value: TuiPlatform; children: JSX.Element }) {
-  return <PlatformContext.Provider value={props.value}>{props.children}</PlatformContext.Provider>
-}
-
-export function useTuiPlatform() {
-  const value = useContext(PlatformContext)
-  if (!value) throw new Error("TuiPlatformProvider is missing")
-  return value
-}
-
-export function useOptionalTuiPlatform() {
-  return useContext(PlatformContext)
-}

+ 5 - 5
packages/tui/src/prompt/frecency.tsx

@@ -2,7 +2,7 @@ import path from "path"
 import { onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "../context/helper"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiPaths } from "../context/runtime"
 import { appendText, readText, writeText } from "../util/persistence"
 
 type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
@@ -38,8 +38,8 @@ function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
 export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
   name: "Frecency",
   init: () => {
-    const environment = useTuiEnvironment()
-    const frecencyPath = path.join(environment.paths.state, "frecency.jsonl")
+    const paths = useTuiPaths()
+    const frecencyPath = path.join(paths.state, "frecency.jsonl")
     onMount(async () => {
       const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
       setStore(
@@ -55,7 +55,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
     const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
 
     function updateFrecency(filePath: string) {
-      const absolutePath = path.resolve(environment.cwd, filePath)
+      const absolutePath = path.resolve(paths.cwd, filePath)
       const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
       setStore("data", absolutePath, newEntry)
       appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
@@ -72,7 +72,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont
     }
 
     return {
-      getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(environment.cwd, filePath)]),
+      getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]),
       updateFrecency,
       data: () => store.data,
     }

+ 3 - 3
packages/tui/src/prompt/history.tsx

@@ -3,7 +3,7 @@ import { onMount } from "solid-js"
 import { createStore, produce, unwrap } from "solid-js/store"
 import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "../context/helper"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiPaths } from "../context/runtime"
 import { appendText, readText, writeText } from "../util/persistence"
 
 export type PromptInfo = {
@@ -49,8 +49,8 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
 export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
   name: "PromptHistory",
   init: () => {
-    const environment = useTuiEnvironment()
-    const historyPath = path.join(environment.paths.state, "prompt-history.jsonl")
+    const paths = useTuiPaths()
+    const historyPath = path.join(paths.state, "prompt-history.jsonl")
     onMount(async () => {
       const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
       setStore("history", lines)

+ 3 - 3
packages/tui/src/prompt/stash.tsx

@@ -2,7 +2,7 @@ import path from "path"
 import { onMount } from "solid-js"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { createSimpleContext } from "../context/helper"
-import { useTuiEnvironment } from "../runtime"
+import { useTuiPaths } from "../context/runtime"
 import { appendText, readText, writeText } from "../util/persistence"
 import type { PromptInfo } from "./history"
 
@@ -32,8 +32,8 @@ export function parsePromptStash(text: string) {
 export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
   name: "PromptStash",
   init: () => {
-    const environment = useTuiEnvironment()
-    const stashPath = path.join(environment.paths.state, "prompt-stash.jsonl")
+    const paths = useTuiPaths()
+    const stashPath = path.join(paths.state, "prompt-stash.jsonl")
     onMount(async () => {
       const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
       setStore("entries", lines)

+ 3 - 3
packages/tui/src/routes/home/session-destination.tsx

@@ -8,7 +8,7 @@ import {
   type Setter,
 } from "solid-js"
 import { useSync } from "../../context/sync"
-import { useTuiEnvironment } from "../../runtime"
+import { useTuiPaths } from "../../context/runtime"
 
 export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
 
@@ -22,10 +22,10 @@ const HomeSessionDestinationContext = createContext<Context>()
 
 export function HomeSessionDestinationProvider(props: ParentProps) {
   const sync = useSync()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const [selected, setDestination] = createSignal<HomeSessionDestination>()
   const destination = createMemo<HomeSessionDestination>(
-    () => selected() ?? { type: "directory", directory: sync.path.directory || environment.cwd, subdirectory: false },
+    () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
   )
   return (
     <HomeSessionDestinationContext.Provider

+ 3 - 3
packages/tui/src/routes/session/dialog-message.tsx

@@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
 import { DialogSelect } from "../../ui/dialog-select"
 import { useSDK } from "../../context/sdk"
 import { useRoute } from "../../context/route"
-import { useTuiPlatform } from "../../platform"
+import { useClipboard } from "../../context/clipboard"
 import type { PromptInfo } from "../../component/prompt/history"
 import { stripPromptPartIDs as strip } from "../../prompt/part"
 
@@ -16,7 +16,7 @@ export function DialogMessage(props: {
   const sdk = useSDK()
   const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
   const route = useRoute()
-  const platform = useTuiPlatform()
+  const clipboard = useClipboard()
 
   return (
     <DialogSelect
@@ -69,7 +69,7 @@ export function DialogMessage(props: {
               return agg
             }, "")
 
-            await platform.clipboard?.write?.(text)
+            await clipboard.write?.(text)
             dialog.clear()
           },
         },

+ 37 - 27
packages/tui/src/routes/session/index.tsx

@@ -7,6 +7,7 @@ import {
   For,
   Match,
   on,
+  onCleanup,
   onMount,
   Show,
   Switch,
@@ -15,12 +16,13 @@ import {
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import path from "node:path"
+import { mkdir, writeFile } from "node:fs/promises"
 import { useRoute, useRouteData } from "../../context/route"
 import { useProject } from "../../context/project"
 import { useSync } from "../../context/sync"
 import { useEvent } from "../../context/event"
 import { SplitBorder } from "../../ui/border"
-import { useTuiEnvironment } from "../../runtime"
+import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
 import { Spinner } from "../../component/spinner"
 import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
 import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
@@ -41,6 +43,7 @@ import { webSearchProviderLabel } from "../../util/tool-display"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "../../context/sdk"
 import { useEditorContext } from "../../context/editor"
+import { openEditor } from "../../editor"
 import { useDialog } from "../../ui/dialog"
 import { DialogAlert } from "../../ui/dialog-alert"
 import { TodoItem } from "../../component/todo-item"
@@ -59,17 +62,17 @@ import { Toast, useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv.tsx"
 import stripAnsi from "strip-ansi"
 import { usePromptRef } from "../../context/prompt"
-import { useExit } from "../../context/exit"
+import { useEpilogue } from "../../context/epilogue"
 import { normalizePath } from "../../util/path"
 import { PermissionPrompt } from "./permission"
 import { QuestionPrompt } from "./question"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import * as Model from "../../util/model"
 import { formatTranscript } from "../../util/transcript"
+import { sessionEpilogue } from "../../util/presentation"
 import { setPreLayoutSiblingMargin } from "../../util/layout"
-import { sessionExitSummary } from "../../util/presentation"
 import { useTuiConfig } from "../../config"
-import { useTuiPlatform } from "../../platform"
+import { useClipboard } from "../../context/clipboard"
 import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
 import { getScrollAcceleration } from "../../util/scroll"
 import { collapseToolOutput } from "../../util/collapse-tool-output"
@@ -171,19 +174,30 @@ function use() {
 }
 
 export function Session() {
-  const platform = useTuiPlatform()
+  const setEpilogue = useEpilogue()
+  const clipboard = useClipboard()
+  const writeExport = async (file: string, content: string) => {
+    await mkdir(path.dirname(file), { recursive: true })
+    await writeFile(file, content)
+  }
   const pluginRuntime = usePluginRuntime()
   const route = useRouteData("session")
   const { navigate } = useRoute()
   const sync = useSync()
   const event = useEvent()
   const project = useProject()
-  const environment = useTuiEnvironment()
+  const paths = useTuiPaths()
   const tuiConfig = useTuiConfig()
   const kv = useKV()
   const { theme } = useTheme()
   const promptRef = usePromptRef()
   const session = createMemo(() => sync.session.get(route.sessionID))
+
+  createEffect(() => {
+    const title = Locale.truncate(session()?.title ?? "", 50)
+    setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
+  })
+  onCleanup(() => setEpilogue())
   const children = createMemo(() => {
     const parentID = session()?.parentID ?? session()?.id
     return sync.data.session
@@ -353,13 +367,6 @@ export function Session() {
     })
   })
 
-  const exit = useExit()
-
-  createEffect(() => {
-    const title = Locale.truncate(session()?.title ?? "", 50)
-    return exit.message.set(sessionExitSummary({ title, sessionID: session()?.id }))
-  })
-
   // Helper: Find next visible message boundary in direction
   const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
     const children = scroll.getChildren()
@@ -460,8 +467,7 @@ export function Session() {
       },
       run: async () => {
         const copy = (url: string) =>
-          platform.clipboard
-            ?.write?.(url)
+          clipboard.write?.(url)
             .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
             .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
         const url = session()?.share?.url
@@ -896,8 +902,7 @@ export function Session() {
           return
         }
 
-        platform.clipboard
-          ?.write?.(text)
+        clipboard.write?.(text)
           .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
           .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
         dialog.clear()
@@ -925,7 +930,7 @@ export function Session() {
               providers: sync.data.provider,
             },
           )
-          await platform.clipboard?.write?.(transcript)
+          await clipboard.write?.(transcript)
           toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
         } catch {
           toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -972,30 +977,32 @@ export function Session() {
 
           if (options.openWithoutSaving) {
             // Just open in editor without saving
-            await platform.editor?.open({
+            await openEditor({
+              renderer,
               value: transcript,
               cwd:
                 (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
                 project.instance.directory() ||
-                environment.cwd,
+                paths.cwd,
             })
           } else {
-            const exportDir = environment.cwd
+            const exportDir = paths.cwd
             const filename = options.filename.trim()
             const filepath = path.join(exportDir, filename)
 
-            await platform.export?.write(filepath, transcript)
+            await writeExport(filepath, transcript)
 
             // Open with EDITOR if available
-            const result = await platform.editor?.open({
+            const result = await openEditor({
+              renderer,
               value: transcript,
               cwd:
                 (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
                 project.instance.directory() ||
-                environment.cwd,
+                paths.cwd,
             })
             if (result !== undefined) {
-              await platform.export?.write(filepath, result)
+              await writeExport(filepath, result)
             }
 
             toast.show({ message: `Session exported to ${filename}`, variant: "success" })
@@ -2510,9 +2517,12 @@ function Skill(props: ToolProps) {
 
 function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
   const { theme } = useTheme()
-  const environment = useTuiEnvironment()
+  const terminalEnvironment = useTuiTerminalEnvironment()
   const errors = createMemo(() => {
-    const normalized = normalizePath(typeof props.filePath === "string" ? props.filePath : "", environment.platform)
+    const normalized = normalizePath(
+      typeof props.filePath === "string" ? props.filePath : "",
+      terminalEnvironment.platform,
+    )
     return parseDiagnostics(props.diagnostics, normalized)
   })
 

+ 3 - 4
packages/tui/src/routes/session/sidebar.tsx

@@ -3,7 +3,7 @@ import { useSync } from "../../context/sync"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
 import { useTuiConfig } from "../../config"
-import { useTuiBuildInfo } from "../../runtime"
+import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { usePluginRuntime } from "../../plugin/runtime"
 
 import { getScrollAcceleration } from "../../util/scroll"
@@ -15,7 +15,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const sync = useSync()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
-  const build = useTuiBuildInfo()
   const session = createMemo(() => sync.session.get(props.sessionID))
   const workspace = () => {
     const workspaceID = session()?.workspaceID
@@ -58,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 <text fg={theme.text}>
                   <b>{session()!.title}</b>
                 </text>
-                <Show when={build.channel !== "latest"}>
+                <Show when={InstallationChannel !== "latest"}>
                   <text fg={theme.textMuted}>{props.sessionID}</text>
                 </Show>
                 <Show when={session()!.workspaceID}>
@@ -94,7 +93,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
               <span style={{ fg: theme.text }}>
                 <b>Code</b>
               </span>{" "}
-              <span>{build.version}</span>
+              <span>{InstallationVersion}</span>
             </text>
           </pluginRuntime.Slot>
         </box>

+ 0 - 83
packages/tui/src/runtime.tsx

@@ -1,88 +1,5 @@
-import { createComponent, createContext, type JSX, useContext } from "solid-js"
 import path from "path"
 
-export type TuiEnvironment = Readonly<{
-  cwd: string
-  platform: string
-  initialRoute?: unknown
-  paths: Readonly<{
-    home: string
-    state: string
-    worktree: string
-  }>
-  capabilities: Readonly<{
-    mouse: boolean
-    copyOnSelect: boolean
-    terminalTitle: boolean
-    terminalSuspend: boolean
-    workspaces: boolean
-    showTimeToFirstDraw: boolean
-  }>
-  terminal: Readonly<{
-    multiplexer?: "tmux" | "screen"
-    displayServer?: "wayland" | "x11"
-  }>
-  editor: Readonly<{
-    command?: string
-    port?: number
-    zedTerminal: boolean
-    zedDatabase?: string
-  }>
-  skipInitialLoading: boolean
-}>
-
-export type TuiBuildInfo = Readonly<{
-  version: string
-  channel: string
-}>
-
-const EnvironmentContext = createContext<TuiEnvironment>()
-const BuildInfoContext = createContext<TuiBuildInfo>()
-
-export function TuiEnvironmentProvider(props: { value: TuiEnvironment; children: JSX.Element }) {
-  return createComponent(EnvironmentContext.Provider, {
-    value: props.value,
-    get children() {
-      return props.children
-    },
-  })
-}
-
-export function TuiBuildInfoProvider(props: { value: TuiBuildInfo; children: JSX.Element }) {
-  return createComponent(BuildInfoContext.Provider, {
-    value: props.value,
-    get children() {
-      return props.children
-    },
-  })
-}
-
-export function useTuiEnvironment() {
-  const value = useContext(EnvironmentContext)
-  if (!value) throw new Error("TuiEnvironmentProvider is missing")
-  return value
-}
-
-export function useTuiBuildInfo() {
-  const value = useContext(BuildInfoContext)
-  if (!value) throw new Error("TuiBuildInfoProvider is missing")
-  return value
-}
-
-export function createTuiEnvironment(input: TuiEnvironment): TuiEnvironment {
-  return Object.freeze({
-    ...input,
-    paths: Object.freeze({ ...input.paths }),
-    capabilities: Object.freeze({ ...input.capabilities }),
-    terminal: Object.freeze({ ...input.terminal }),
-    editor: Object.freeze({ ...input.editor }),
-  })
-}
-
-export function createTuiBuildInfo(input: TuiBuildInfo): TuiBuildInfo {
-  return Object.freeze({ ...input })
-}
-
 export function abbreviateHome(input: string, home: string) {
   if (!home) return input
   const relative = path.relative(home, input)

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


+ 7 - 8
packages/tui/src/ui/dialog.tsx

@@ -4,9 +4,9 @@ import { useTheme } from "../context/theme"
 import { MouseButton, Renderable, RGBA } from "@opentui/core"
 import { createStore } from "solid-js/store"
 import { useToast } from "./toast"
-import { useTuiEnvironment } from "../runtime"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import { useBindings, useOpencodeModeStack } from "../keymap"
-import { useOptionalTuiPlatform } from "../platform"
+import { useClipboard } from "../context/clipboard"
 
 export function Dialog(
   props: ParentProps<{
@@ -180,13 +180,12 @@ export function DialogProvider(props: ParentProps) {
   const value = init()
   const renderer = useRenderer()
   const toast = useToast()
-  const environment = useTuiEnvironment()
-  const platform = useOptionalTuiPlatform()
+  const clipboard = useClipboard()
 
   function copySelection() {
     const text = renderer.getSelection()?.getSelectedText()
-    if (!text || !platform?.clipboard?.write) return false
-    void platform.clipboard.write(text).then(
+    if (!text || !clipboard.write) return false
+    void clipboard.write(text).then(
       () => toast.show({ message: "Copied to clipboard", variant: "info" }),
       (error) => toast.error(error),
     )
@@ -201,14 +200,14 @@ export function DialogProvider(props: ParentProps) {
         position="absolute"
         zIndex={3000}
         onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
-          if (environment.capabilities.copyOnSelect) return
+          if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
           if (evt.button !== MouseButton.RIGHT) return
 
           if (!copySelection()) return
           evt.preventDefault()
           evt.stopPropagation()
         }}
-        onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined}
+        onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
       >
         <Show when={value.stack.length}>
           <Dialog onClose={() => value.clear()} size={value.size}>

+ 1 - 1
packages/tui/src/util/presentation.ts

@@ -26,7 +26,7 @@ function wordmark(pad = "") {
   })
 }
 
-export function sessionExitSummary(input: { title: string; sessionID?: string }) {
+export function sessionEpilogue(input: { title: string; sessionID?: string }) {
   const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}`
   return [
     ...wordmark("  "),

+ 6 - 0
packages/tui/src/util/renderer.ts

@@ -0,0 +1,6 @@
+import type { CliRenderer } from "@opentui/core"
+
+export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
+  renderer.setTerminalTitle("")
+  if (!renderer.isDestroyed) renderer.destroy()
+}

+ 3 - 3
packages/tui/src/util/selection.ts

@@ -1,4 +1,4 @@
-import type { TuiPlatform } from "../platform"
+import type { ClipboardService } from "../context/clipboard"
 
 type Toast = {
   show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
@@ -23,7 +23,7 @@ type SelectionKeyEvent = {
   stopPropagation: () => void
 }
 
-export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean {
+export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
   const selection = renderer.getSelection()
   if (!selection) return false
 
@@ -47,7 +47,7 @@ export function handleSelectionKey(
   renderer: Renderer,
   toast: Toast,
   event: SelectionKeyEvent,
-  clipboard: TuiPlatform["clipboard"],
+  clipboard: ClipboardService,
 ) {
   const selection = renderer.getSelection()
   if (!selection) return

+ 59 - 0
packages/tui/test/app-lifecycle.test.tsx

@@ -0,0 +1,59 @@
+import { expect, mock, test } from "bun:test"
+import { createTestRenderer } from "@opentui/core/testing"
+import { Effect } from "effect"
+import { Global } from "@opencode-ai/core/global"
+import { createTuiResolvedConfig } from "./fixture/tui-runtime"
+import { createEventSource, createFetch, directory } from "./fixture/tui-sdk"
+
+test("SIGHUP clears title and disposes scoped resources once", async () => {
+  const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
+  const core = await import("@opentui/core")
+  mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
+  const titles: string[] = []
+  const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
+  setup.renderer.setTerminalTitle = (title) => {
+    titles.push(title)
+    setTitle(title)
+  }
+  const listeners = new Set(process.listeners("SIGHUP"))
+  const events = createEventSource()
+  const calls = createFetch()
+  let started!: () => void
+  const ready = new Promise<void>((resolve) => {
+    started = resolve
+  })
+  let disposes = 0
+
+  try {
+    const { run } = await import("../src/app")
+    const task = Effect.runPromise(
+      run({
+        url: "http://test",
+        directory,
+        config: createTuiResolvedConfig({ plugin_enabled: {} }),
+        fetch: calls.fetch,
+        events: events.source,
+        args: {},
+        pluginHost: {
+          async start() {
+            started()
+          },
+          async dispose() {
+            disposes++
+          },
+        },
+      }).pipe(Effect.provide(Global.defaultLayer)),
+    )
+    await ready
+    process.emit("SIGHUP")
+    await task
+
+    expect(setup.renderer.isDestroyed).toBe(true)
+    expect(titles.at(-1)).toBe("")
+    expect(disposes).toBe(1)
+    expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
+  } finally {
+    if (!setup.renderer.isDestroyed) setup.renderer.destroy()
+    mock.restore()
+  }
+})

+ 5 - 16
packages/tui/test/cli/cmd/tui/sync-fixture.tsx

@@ -2,13 +2,12 @@
 import { testRender } from "@opentui/solid"
 import { onMount } from "solid-js"
 import { ArgsProvider } from "../../../../src/context/args"
-import { createExit, ExitProvider } from "../../../../src/context/exit"
 import { KVProvider, useKV } from "../../../../src/context/kv"
 import { ProjectProvider, useProject } from "../../../../src/context/project"
 import { SDKProvider } from "../../../../src/context/sdk"
 import { SyncProvider, useSync } from "../../../../src/context/sync"
 import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
-import { TestTuiEnvironmentProvider } from "../../../fixture/tui-environment"
+import { TestTuiContexts } from "../../../fixture/tui-environment"
 export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
 
 export async function wait(fn: () => boolean, timeout = 2000) {
@@ -44,32 +43,22 @@ export async function mount(override?: FetchHandler, state?: string) {
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider paths={state ? { state } : undefined}>
+    <TestTuiContexts paths={state ? { state } : undefined}>
       <ArgsProvider>
-        <ExitProvider exit={createExit(async () => {})}>
           <KVProvider>
             <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
               <ProjectProvider>
-                <SyncFixtureProvider>
+                <SyncProvider>
                   <Probe />
-                </SyncFixtureProvider>
+                </SyncProvider>
               </ProjectProvider>
             </SDKProvider>
           </KVProvider>
-        </ExitProvider>
       </ArgsProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   await ready
   await wait(() => sync.status === "complete")
   return { app, emit: events.emit, kv, project, sync, session: calls.session }
 }
-
-function SyncFixtureProvider(props: { children: import("solid-js").JSX.Element }) {
-  return (
-    <SyncProvider kv={useKV()} logger={{ error() {} }}>
-      {props.children}
-    </SyncProvider>
-  )
-}

+ 3 - 3
packages/tui/test/cli/tui/dialog-prompt.test.tsx

@@ -9,7 +9,7 @@ import { onCleanup } from "solid-js"
 import { tmpdir } from "../../fixture/fixture"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 import type { TuiKeybind } from "../../../src/config/keybind"
-import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
   const start = Date.now()
@@ -57,7 +57,7 @@ async function mountPrompt(input: {
     onCleanup(off)
 
     return (
-      <TestTuiEnvironmentProvider
+      <TestTuiContexts
         directory={input.root}
         paths={{
           home: input.root,
@@ -78,7 +78,7 @@ async function mountPrompt(input: {
             </KVProvider>
           </TuiConfigProvider>
         </OpencodeKeymapProvider>
-      </TestTuiEnvironmentProvider>
+      </TestTuiContexts>
     )
   }
 

+ 3 - 3
packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -8,7 +8,7 @@ import { KVProvider } from "../../../src/context/kv"
 import { ThemeProvider } from "../../../src/context/theme"
 import { TuiConfigProvider } from "../../../src/config"
 import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
-import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TestTuiContexts } from "../../fixture/tui-environment"
 import {
   allExpandedFileTreeDirectories,
   buildFileTree,
@@ -180,13 +180,13 @@ async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>)
 
 function withTheme(component: () => JSX.Element) {
   return (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <TuiConfigProvider config={createTuiResolvedConfig()}>
         <KVProvider>
           <ThemeProvider mode="dark">{component()}</ThemeProvider>
         </KVProvider>
       </TuiConfigProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   )
 }
 

+ 3 - 3
packages/tui/test/cli/tui/diff-viewer.test.tsx

@@ -13,7 +13,7 @@ import { OpencodeKeymapProvider } from "../../../src/keymap"
 import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
 import { createTuiPluginApi } from "../../fixture/tui-plugin"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
-import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TestTuiContexts } from "../../fixture/tui-environment"
 
 test("closing the diff viewer returns to the route it opened from", async () => {
   const viewer = await renderDiffViewer([])
@@ -152,7 +152,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
     commands.get("diff.open")?.run?.({} as never)
 
     return (
-      <TestTuiEnvironmentProvider>
+      <TestTuiContexts>
         <OpencodeKeymapProvider keymap={keymap}>
           <TuiConfigProvider config={config}>
             <KVProvider>
@@ -162,7 +162,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
             </KVProvider>
           </TuiConfigProvider>
         </OpencodeKeymapProvider>
-      </TestTuiEnvironmentProvider>
+      </TestTuiContexts>
     )
   }
 

+ 15 - 15
packages/tui/test/cli/tui/sync-v2.test.tsx

@@ -7,7 +7,7 @@ import { ProjectProvider } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2"
 import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
-import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TestTuiContexts } from "../../fixture/tui-environment"
 
 async function wait(fn: () => boolean, timeout = 2000) {
   const start = Date.now()
@@ -43,7 +43,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -51,7 +51,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -172,7 +172,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -180,7 +180,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -236,7 +236,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -244,7 +244,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -284,7 +284,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -292,7 +292,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -339,7 +339,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -347,7 +347,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -389,7 +389,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -397,7 +397,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows",
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {
@@ -458,7 +458,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
   }
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <SyncProviderV2>
@@ -466,7 +466,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn
           </SyncProviderV2>
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   try {

+ 3 - 3
packages/tui/test/cli/tui/use-event.test.tsx

@@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../src/context/project"
 import { SDKProvider } from "../../../src/context/sdk"
 import { useEvent } from "../../../src/context/event"
 import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
-import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment"
+import { TestTuiContexts } from "../../fixture/tui-environment"
 
 const projectID = "proj_test"
 
@@ -60,7 +60,7 @@ async function mount() {
   })
 
   const app = await testRender(() => (
-    <TestTuiEnvironmentProvider>
+    <TestTuiContexts>
       <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
         <ProjectProvider>
           <Probe
@@ -74,7 +74,7 @@ async function mount() {
           />
         </ProjectProvider>
       </SDKProvider>
-    </TestTuiEnvironmentProvider>
+    </TestTuiContexts>
   ))
 
   await ready

+ 19 - 0
packages/tui/test/clipboard.test.ts

@@ -0,0 +1,19 @@
+import { expect, test } from "bun:test"
+import { copyCommand } from "../src/clipboard"
+
+test("prefers Wayland clipboard when available", () => {
+  expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
+})
+
+test("uses osascript on macOS", () => {
+  expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
+})
+
+test("falls back through X11 clipboard commands", () => {
+  expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
+  expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
+})
+
+test("returns undefined when native clipboard is unavailable", () => {
+  expect(copyCommand("linux", false, () => false)).toBeUndefined()
+})

+ 23 - 0
packages/tui/test/editor.test.ts

@@ -0,0 +1,23 @@
+import { afterEach, expect, test } from "bun:test"
+import { openEditor } from "../src/editor"
+
+const editor = process.env.EDITOR
+const visual = process.env.VISUAL
+
+afterEach(() => {
+  process.env.EDITOR = editor
+  process.env.VISUAL = visual
+})
+
+test("rejects when the external editor cannot start", async () => {
+  delete process.env.VISUAL
+  process.env.EDITOR = "opencode-editor-that-does-not-exist"
+  const renderer = {
+    suspend() {},
+    resume() {},
+    requestRender() {},
+    currentRenderBuffer: { clear() {} },
+  }
+
+  await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow()
+})

+ 19 - 29
packages/tui/test/fixture/tui-environment.tsx

@@ -1,42 +1,32 @@
 /** @jsxImportSource @opentui/solid */
-import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "../../src/runtime"
+import {
+  TuiPathsProvider,
+  TuiStartupProvider,
+  TuiTerminalEnvironmentProvider,
+  type TuiPaths,
+} from "../../src/context/runtime"
 import type { ParentProps } from "solid-js"
 
-export function TestTuiEnvironmentProvider(
+export function TestTuiContexts(
   props: ParentProps<{
     cwd?: string
     directory?: string
-    paths?: Partial<TuiEnvironment["paths"]>
-    capabilities?: Partial<TuiEnvironment["capabilities"]>
-    editor?: Partial<TuiEnvironment["editor"]>
+    paths?: Partial<TuiPaths>
   }>,
 ) {
   return (
-    <TuiEnvironmentProvider
-      value={createTuiEnvironment({
+    <TuiPathsProvider
+      value={{
         cwd: props.cwd ?? props.directory ?? "/tmp/opencode/packages/tui",
-        platform: "linux",
-        paths: {
-          home: "/tmp/opencode/home",
-          state: "/tmp/opencode/state",
-          worktree: "/tmp/opencode",
-          ...props.paths,
-        },
-        capabilities: {
-          mouse: true,
-          copyOnSelect: true,
-          terminalTitle: false,
-          terminalSuspend: false,
-          workspaces: false,
-          showTimeToFirstDraw: false,
-          ...props.capabilities,
-        },
-        terminal: {},
-        editor: { zedTerminal: false, ...props.editor },
-        skipInitialLoading: false,
-      })}
+        home: "/tmp/opencode/home",
+        state: "/tmp/opencode/state",
+        worktree: "/tmp/opencode",
+        ...props.paths,
+      }}
     >
-      {props.children}
-    </TuiEnvironmentProvider>
+      <TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
+        <TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
+      </TuiTerminalEnvironmentProvider>
+    </TuiPathsProvider>
   )
 }

+ 2 - 4
packages/tui/test/index.test.tsx

@@ -1,8 +1,6 @@
 import { expect, test } from "bun:test"
-import { createRenderer, createTuiRenderer, mount, run, tui } from "../src"
+import { run } from "../src"
 
 test("exports the canonical application lifecycle", () => {
-  expect(run).toBe(tui)
-  expect(createRenderer).toBe(createTuiRenderer)
-  expect(typeof mount).toBe("function")
+  expect(typeof run).toBe("function")
 })

+ 0 - 40
packages/tui/test/platform.test.tsx

@@ -1,40 +0,0 @@
-import { expect, test } from "bun:test"
-import { testRender } from "@opentui/solid"
-import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "../src/platform"
-
-test("provides host platform operations", async () => {
-  const platform: TuiPlatform = {
-    files: {
-      readText: async (path) => `text:${path}`,
-      readBytes: async () => new Uint8Array([1, 2, 3]),
-      mime: async () => "text/plain",
-    },
-  }
-
-  function Consumer() {
-    const value = useTuiPlatform()
-    return <text>{value.clipboard ? "clipboard" : "files-only"}</text>
-  }
-
-  const app = await testRender(
-    () => (
-      <TuiPlatformProvider value={platform}>
-        <Consumer />
-      </TuiPlatformProvider>
-    ),
-    { width: 20, height: 3 },
-  )
-
-  try {
-    await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("files-only")
-    expect(await platform.files.readText("file.txt")).toBe("text:file.txt")
-    expect(await platform.files.readBytes("file.bin")).toEqual(new Uint8Array([1, 2, 3]))
-  } finally {
-    app.renderer.destroy()
-  }
-})
-
-test("requires a platform provider", () => {
-  expect(() => useTuiPlatform()).toThrow("TuiPlatformProvider is missing")
-})

+ 7 - 7
packages/tui/test/prompt/local-attachment.test.ts

@@ -1,8 +1,8 @@
 import { describe, expect, test } from "bun:test"
-import { readLocalAttachment } from "../../src/component/prompt/local-attachment"
-import type { PlatformFiles } from "../../src/platform"
+import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
+import type { LocalFiles } from "../../src/component/prompt/local-attachment"
 
-function files(input: { mime: string; text?: string; bytes?: Uint8Array }): PlatformFiles {
+function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
   return {
     mime: async () => input.mime,
     readText: async () => input.text ?? "",
@@ -12,7 +12,7 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Plat
 
 describe("prompt local attachments", () => {
   test("reads SVG attachments as text", async () => {
-    expect(await readLocalAttachment(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
+    expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
       type: "text",
       mime: "image/svg+xml",
       content: "<svg />",
@@ -21,7 +21,7 @@ describe("prompt local attachments", () => {
 
   test("reads image and PDF attachments as bytes", async () => {
     const content = new Uint8Array([1, 2, 3])
-    expect(await readLocalAttachment(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({
+    expect(await readLocalAttachmentWith(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({
       type: "binary",
       mime: "application/pdf",
       content,
@@ -29,9 +29,9 @@ describe("prompt local attachments", () => {
   })
 
   test("ignores unsupported and unreadable local files", async () => {
-    expect(await readLocalAttachment(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined()
+    expect(await readLocalAttachmentWith(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined()
     expect(
-      await readLocalAttachment(
+      await readLocalAttachmentWith(
         {
           ...files({ mime: "image/png" }),
           readBytes: async () => Promise.reject(new Error("missing")),

+ 13 - 38
packages/tui/test/runtime.test.tsx

@@ -1,14 +1,10 @@
 import { expect, test } from "bun:test"
 import { testRender } from "@opentui/solid"
+import { abbreviateHome } from "../src/runtime"
 import {
-  abbreviateHome,
-  createTuiBuildInfo,
-  createTuiEnvironment,
-  TuiBuildInfoProvider,
-  TuiEnvironmentProvider,
-  useTuiBuildInfo,
-  useTuiEnvironment,
-} from "../src/runtime"
+  TuiPathsProvider,
+  useTuiPaths,
+} from "../src/context/runtime"
 
 test("abbreviates paths within home boundaries", () => {
   expect(abbreviateHome("/home/test", "/home/test")).toBe("~")
@@ -17,48 +13,27 @@ test("abbreviates paths within home boundaries", () => {
   expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project")
 })
 
-test("provides immutable runtime inputs", async () => {
-  const environment = createTuiEnvironment({
-    cwd: "/work",
-    platform: "linux",
-    paths: { home: "/home/test", state: "/state", worktree: "/data/worktree" },
-    capabilities: {
-      mouse: true,
-      copyOnSelect: true,
-      terminalTitle: true,
-      terminalSuspend: true,
-      workspaces: false,
-      showTimeToFirstDraw: false,
-    },
-    terminal: { multiplexer: "tmux", displayServer: "wayland" },
-    editor: { command: "vim", port: 4242, zedTerminal: false },
-    skipInitialLoading: false,
-  })
-  const build = createTuiBuildInfo({ version: "1.2.3", channel: "beta" })
+test("provides focused immutable runtime inputs", async () => {
+  let paths: ReturnType<typeof useTuiPaths>
 
   function Runtime() {
-    const runtime = useTuiEnvironment()
-    const info = useTuiBuildInfo()
-    return <text>{`${runtime.cwd} ${runtime.editor.command} ${info.version}`}</text>
+    paths = useTuiPaths()
+    return <text>{paths.cwd}</text>
   }
 
   const app = await testRender(
     () => (
-      <TuiEnvironmentProvider value={environment}>
-        <TuiBuildInfoProvider value={build}>
-          <Runtime />
-        </TuiBuildInfoProvider>
-      </TuiEnvironmentProvider>
+      <TuiPathsProvider value={{ cwd: "/work", home: "/home/test", state: "/state", worktree: "/worktree" }}>
+        <Runtime />
+      </TuiPathsProvider>
     ),
     { width: 40, height: 3 },
   )
 
   try {
     await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("/work vim 1.2.3")
-    expect(Object.isFrozen(environment)).toBe(true)
-    expect(Object.isFrozen(environment.paths)).toBe(true)
-    expect(Object.isFrozen(build)).toBe(true)
+    expect(app.captureCharFrame()).toContain("/work")
+    expect(Object.isFrozen(paths!)).toBe(true)
   } finally {
     app.renderer.destroy()
   }

+ 16 - 0
packages/tui/test/theme.test.ts

@@ -1,6 +1,10 @@
 import { expect, test } from "bun:test"
+import { mkdir, writeFile } from "node:fs/promises"
+import path from "node:path"
 import type { TerminalColors } from "@opentui/core"
 import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme"
+import { discoverThemes } from "../src/context/theme"
+import { tmpdir } from "./fixture/fixture"
 
 test("addTheme writes into module theme store", () => {
   const name = `plugin-theme-${Date.now()}`
@@ -63,3 +67,15 @@ test("terminalMode derives mode from refreshed background", () => {
 test("terminalMode does not derive mode from ANSI slot zero", () => {
   expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
 })
+
+test("custom theme precedence follows directory order", async () => {
+  await using tmp = await tmpdir()
+  const global = path.join(tmp.path, "global")
+  const project = path.join(tmp.path, "project")
+  await mkdir(path.join(global, "themes"), { recursive: true })
+  await mkdir(path.join(project, "themes"), { recursive: true })
+  await writeFile(path.join(global, "themes", "custom.json"), JSON.stringify({ source: "global" }))
+  await writeFile(path.join(project, "themes", "custom.json"), JSON.stringify({ source: "project" }))
+
+  await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } })
+})

+ 6 - 9
packages/tui/test/util/presentation.test.ts

@@ -1,11 +1,8 @@
-import { describe, expect, test } from "bun:test"
-import { sessionExitSummary } from "../../src/util/presentation"
+import { expect, test } from "bun:test"
+import { sessionEpilogue } from "../../src/util/presentation"
 
-describe("util.presentation", () => {
-  test("formats the ANSI session exit summary", () => {
-    const summary = sessionExitSummary({ title: "A session", sessionID: "ses_123" })
-    expect(summary.split("\n")).toHaveLength(8)
-    expect(summary).toContain("\x1b[90mSession   \x1b[0m\x1b[1mA session\x1b[0m")
-    expect(summary).toContain("\x1b[1mopencode -s ses_123\x1b[0m")
-  })
+test("formats session continuation summary", () => {
+  const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" })
+  expect(epilogue).toContain("A session")
+  expect(epilogue).toContain("opencode -s ses_123")
 })

+ 30 - 0
packages/tui/test/util/renderer.test.ts

@@ -0,0 +1,30 @@
+import { expect, test } from "bun:test"
+import { destroyRenderer } from "../../src/util/renderer"
+
+test("clears the terminal title before destroying the renderer", () => {
+  const calls: string[] = []
+  destroyRenderer({
+    isDestroyed: false,
+    setTerminalTitle(title) {
+      calls.push(`title:${title}`)
+    },
+    destroy() {
+      calls.push("destroy")
+    },
+  })
+  expect(calls).toEqual(["title:", "destroy"])
+})
+
+test("still clears the title after renderer destruction", () => {
+  const calls: string[] = []
+  destroyRenderer({
+    isDestroyed: true,
+    setTerminalTitle(title) {
+      calls.push(`title:${title}`)
+    },
+    destroy() {
+      calls.push("destroy")
+    },
+  })
+  expect(calls).toEqual(["title:"])
+})