Forráskód Böngészése

refactor(tui): centralize application exit (#31524)

Dax 2 hónapja
szülő
commit
37522185d3
32 módosított fájl, 176 hozzáadás és 1217 törlés
  1. 1 23
      .github/workflows/test.yml
  2. 1 2
      packages/app/package.json
  3. 1 7
      packages/app/playwright.config.ts
  4. 1 2
      packages/core/package.json
  5. 0 1
      packages/core/src/flag/flag.ts
  6. 1 2
      packages/effect-drizzle-sqlite/package.json
  7. 1 2
      packages/http-recorder/package.json
  8. 1 1
      packages/llm/package.json
  9. 0 136
      packages/opencode/BUN_SHELL_MIGRATION_PLAN.md
  10. 1 2
      packages/opencode/package.json
  11. 1 1
      packages/opencode/src/cli/cmd/tui.ts
  12. 0 2
      packages/opencode/src/plugin/tui/internal.ts
  13. 10 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts
  14. 1 1
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  15. 11 6
      packages/opencode/test/server/httpapi-error-middleware.test.ts
  16. 2 2
      packages/tui/package.json
  17. 111 92
      packages/tui/src/app.tsx
  18. 5 5
      packages/tui/src/component/error-component.tsx
  19. 3 2
      packages/tui/src/component/prompt/index.tsx
  20. 0 51
      packages/tui/src/context/aggregate-failures.ts
  21. 8 0
      packages/tui/src/context/exit.tsx
  22. 11 22
      packages/tui/src/context/sync.tsx
  23. 0 3
      packages/tui/src/feature-plugins/builtins.ts
  24. 0 356
      packages/tui/src/feature-plugins/session/dialog.tsx
  25. 0 32
      packages/tui/src/feature-plugins/session/index.tsx
  26. 0 288
      packages/tui/src/feature-plugins/session/preview-pane.tsx
  27. 0 54
      packages/tui/src/feature-plugins/session/util.tsx
  28. 0 4
      packages/tui/src/ui/dialog-select.tsx
  29. 4 2
      packages/tui/src/util/renderer.ts
  30. 0 95
      packages/tui/test/context/aggregate-failures.test.ts
  31. 1 2
      packages/ui/package.json
  32. 0 19
      turbo.json

+ 1 - 23
.github/workflows/test.yml

@@ -65,7 +65,7 @@ jobs:
 
       - name: Run unit tests
         timeout-minutes: 20
-        run: bun turbo test:ci --log-order=stream --log-prefix=task
+        run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
         env:
           OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
 
@@ -74,26 +74,6 @@ jobs:
         working-directory: packages/opencode
         run: bun run test:httpapi
 
-      - name: Publish unit reports
-        if: always()
-        uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
-        with:
-          report_paths: packages/*/.artifacts/unit/junit.xml
-          check_name: "unit results (${{ matrix.settings.name }})"
-          detailed_summary: true
-          include_time_in_summary: true
-          fail_on_failure: false
-
-      - name: Upload unit artifacts
-        if: always()
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
-        with:
-          name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
-          include-hidden-files: true
-          if-no-files-found: ignore
-          retention-days: 7
-          path: packages/*/.artifacts/unit/junit.xml
-
   e2e:
     name: e2e (${{ matrix.settings.name }})
     strategy:
@@ -151,7 +131,6 @@ jobs:
         run: bun --cwd packages/app test:e2e:local
         env:
           CI: true
-          PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
         timeout-minutes: 30
 
       - name: Upload Playwright artifacts
@@ -162,6 +141,5 @@ jobs:
           if-no-files-found: ignore
           retention-days: 7
           path: |
-            packages/app/e2e/junit-*.xml
             packages/app/e2e/test-results
             packages/app/e2e/playwright-report

+ 1 - 2
packages/app/package.json

@@ -18,8 +18,7 @@
     "build": "vite build",
     "serve": "vite preview",
     "test": "bun run test:unit",
-    "test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
-    "test:unit": "bun test --preload ./happydom.ts ./src",
+    "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
     "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
     "test:e2e": "playwright test",
     "test:e2e:local": "playwright test",

+ 1 - 7
packages/app/playwright.config.ts

@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
 const reuse = !process.env.CI
 const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
-const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
-
-if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
-  reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
-}
-
 export default defineConfig({
   testDir: "./e2e",
   outputDir: "./e2e/test-results",
@@ -24,7 +18,7 @@ export default defineConfig({
   forbidOnly: !!process.env.CI,
   retries: process.env.CI ? 2 : 0,
   workers,
-  reporter,
+  reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
   webServer: {
     command,
     url: baseURL,

+ 1 - 2
packages/core/package.json

@@ -9,8 +9,7 @@
     "db": "bun drizzle-kit",
     "migration": "bun run script/migration.ts",
     "fix-node-pty": "bun run script/fix-node-pty.ts",
-    "test": "bun test",
-    "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
+    "test": "bun test --only-failures",
     "typecheck": "tsgo --noEmit"
   },
   "bin": {

+ 0 - 1
packages/core/src/flag/flag.ts

@@ -46,7 +46,6 @@ export const Flag = {
 
   OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
   OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
-  OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"),
 
   // Evaluated at access time (not module load) because tests, the CLI, and
   // external tooling set these env vars at runtime.

+ 1 - 2
packages/effect-drizzle-sqlite/package.json

@@ -6,8 +6,7 @@
   "license": "MIT",
   "private": true,
   "scripts": {
-    "test": "bun test --timeout 30000",
-    "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
+    "test": "bun test --timeout 30000 --only-failures",
     "typecheck": "tsgo --noEmit"
   },
   "exports": {

+ 1 - 2
packages/http-recorder/package.json

@@ -27,8 +27,7 @@
     "access": "public"
   },
   "scripts": {
-    "test": "bun test --timeout 30000",
-    "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
+    "test": "bun test --timeout 30000 --only-failures",
     "typecheck": "tsgo --noEmit",
     "build": "bun ./script/build.ts",
     "verify:package": "bun ./script/verify-package.ts"

+ 1 - 1
packages/llm/package.json

@@ -7,7 +7,7 @@
   "private": true,
   "scripts": {
     "setup:recording-env": "bun run script/setup-recording-env.ts",
-    "test": "bun test --timeout 30000",
+    "test": "bun test --timeout 30000 --only-failures",
     "typecheck": "tsgo --noEmit"
   },
   "exports": {

+ 0 - 136
packages/opencode/BUN_SHELL_MIGRATION_PLAN.md

@@ -1,136 +0,0 @@
-# Bun shell migration plan
-
-Practical phased replacement of Bun `$` calls.
-
-## Goal
-
-Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
-
-Keep behavior stable while improving safety, testability, and observability.
-
-Current baseline from audit:
-
-- 143 runtime command invocations across 17 files
-- 84 are git commands
-- Largest hotspots:
-  - `src/cli/cmd/github.ts` (33)
-  - `src/worktree/index.ts` (22)
-  - `src/lsp/server.ts` (21)
-  - `src/installation/index.ts` (20)
-  - `src/snapshot/index.ts` (18)
-
-## Decisions
-
-- Extend `src/util/process.ts` (do not create a separate exec module).
-- Proceed with phased migration for both git and non-git paths.
-- Keep plugin `$` compatibility in 1.x and remove in 2.0.
-
-## Non-goals
-
-- Do not remove plugin `$` compatibility in this effort.
-- Do not redesign command semantics beyond what is needed to preserve behavior.
-
-## Constraints
-
-- Keep migration phased, not big-bang.
-- Minimize behavioral drift.
-- Keep these explicit shell-only exceptions:
-  - `src/session/prompt.ts` raw command execution
-  - worktree start scripts in `src/worktree/index.ts`
-
-## Process API proposal (`src/util/process.ts`)
-
-Add higher-level wrappers on top of current spawn support.
-
-Core methods:
-
-- `Process.run(cmd, opts)`
-- `Process.text(cmd, opts)`
-- `Process.lines(cmd, opts)`
-- `Process.status(cmd, opts)`
-- `Process.shell(command, opts)` for intentional shell execution
-
-Git helpers:
-
-- `Process.git(args, opts)`
-- `Process.gitText(args, opts)`
-
-Shared options:
-
-- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
-- `allowFailure` / non-throw mode
-- optional redaction + trace metadata
-
-Standard result shape:
-
-- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
-- helpers like `text()` and `arrayBuffer()` where useful
-
-## Phased rollout
-
-### Phase 0: Foundation
-
-- Implement Process wrappers in `src/util/process.ts`.
-- Refactor `src/util/git.ts` to use Process only.
-- Add tests for exit handling, timeout, abort, and output capture.
-
-### Phase 1: High-impact hotspots
-
-Migrate these first:
-
-- `src/cli/cmd/github.ts`
-- `src/worktree/index.ts`
-- `src/lsp/server.ts`
-- `src/installation/index.ts`
-- `src/snapshot/index.ts`
-
-Within each file, migrate git paths first where applicable.
-
-### Phase 2: Remaining git-heavy files
-
-Migrate git-centric call sites to `Process.git*` helpers:
-
-- `../core/src/filesystem.ts`
-- `src/project/vcs.ts`
-- `../core/src/filesystem/watcher.ts`
-- `src/storage/storage.ts`
-- `src/cli/cmd/pr.ts`
-
-### Phase 3: Remaining non-git files
-
-Migrate residual non-git usages:
-
-- `src/cli/cmd/tui/util/clipboard.ts`
-- `src/util/archive.ts`
-- `../core/src/filesystem/ripgrep.ts`
-- `src/tool/bash.ts`
-- `src/cli/cmd/uninstall.ts`
-
-### Phase 4: Stabilize
-
-- Remove dead wrappers and one-off patterns.
-- Keep plugin `$` compatibility isolated and documented as temporary.
-- Create linked 2.0 task for plugin `$` removal.
-
-## Validation strategy
-
-- Unit tests for new `Process` methods and options.
-- Integration tests on hotspot modules.
-- Smoke tests for install, snapshot, worktree, and GitHub flows.
-- Regression checks for output parsing behavior.
-
-## Risk mitigation
-
-- File-by-file PRs with small diffs.
-- Preserve behavior first, simplify second.
-- Keep shell-only exceptions explicit and documented.
-- Add consistent error shaping and logging at Process layer.
-
-## Definition of done
-
-- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
-  - approved shell-only exceptions
-  - temporary plugin compatibility path (1.x)
-- Git paths use `Process.git*` consistently.
-- CI and targeted smoke tests pass.
-- 2.0 issue exists for plugin `$` removal.

+ 1 - 2
packages/opencode/package.json

@@ -7,8 +7,7 @@
   "private": true,
   "scripts": {
     "typecheck": "tsgo --noEmit",
-    "test": "bun test --timeout 30000",
-    "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
+    "test": "bun test --timeout 30000 --only-failures",
     "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
     "bench:test": "bun run script/bench-test-suite.ts",
     "profile:test": "bun run script/profile-test-files.ts",

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

@@ -213,12 +213,12 @@ export const TuiThreadCommand = cmd({
       } finally {
         await stop()
       }
-      process.exit(0)
     } finally {
       try {
         unguard?.()
       } catch {}
     }
+    process.exit(0)
   },
 })
 // scratch

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

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

+ 10 - 0
packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

@@ -1,4 +1,5 @@
 import { NamedError } from "@opencode-ai/core/util/error"
+import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
 import { Cause, Effect } from "effect"
 import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
 
@@ -15,6 +16,15 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
       if (!defect) return Effect.failCause(cause)
 
       const error = defect.defect
+      if (
+        ConfigErrorV1.JsonError.isInstance(error) ||
+        ConfigErrorV1.InvalidError.isInstance(error) ||
+        ConfigErrorV1.FrontmatterError.isInstance(error) ||
+        ConfigErrorV1.DirectoryTypoError.isInstance(error)
+      ) {
+        return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 400 }))
+      }
+
       const ref = `err_${crypto.randomUUID().slice(0, 8)}`
 
       return Effect.logError("failed", { ref, error, cause: Cause.pretty(cause) }).pipe(

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

@@ -264,7 +264,7 @@ export function createRoutes(
     ]),
     Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
     Layer.provide(InstanceLayer.layer),
-    Layer.provide(Observability.layer),
+    Layer.provideMerge(Observability.layer),
   )
 }
 

+ 11 - 6
packages/opencode/test/server/httpapi-error-middleware.test.ts

@@ -53,7 +53,7 @@ describe("HttpApi error middleware", () => {
     }),
   )
 
-  it.live("does not expose config defects from generic middleware", () =>
+  it.live("returns invalid config defects as structured client errors", () =>
     Effect.gen(function* () {
       const configError = new ConfigErrorV1.InvalidError({
         path: "/tmp/opencode.json",
@@ -70,11 +70,16 @@ describe("HttpApi error middleware", () => {
       const body = yield* response.json
       const serialized = JSON.stringify(body)
 
-      expect(response.status).toBe(500)
-      expectUnknownErrorBody(body)
-      expect(serialized).not.toContain("/tmp/opencode.json")
-      expect(serialized).not.toContain("provider")
-      expect(serialized).not.toContain("anthropic")
+      expect(response.status).toBe(400)
+      expect(body).toMatchObject({
+        name: "ConfigInvalidError",
+        data: {
+          path: "/tmp/opencode.json",
+          issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
+        },
+      })
+      expect(serialized).toContain("/tmp/opencode.json")
+      expect(serialized).toContain("anthropic")
     }),
   )
 

+ 2 - 2
packages/tui/package.json

@@ -6,7 +6,7 @@
   "type": "module",
   "license": "MIT",
   "scripts": {
-    "test": "bun test --timeout 30000",
+    "test": "bun test --timeout 30000 --only-failures",
     "typecheck": "tsgo --noEmit"
   },
   "exports": {
@@ -15,6 +15,7 @@
     "./config": "./src/config/index.tsx",
     "./context/args": "./src/context/args.tsx",
     "./context/epilogue": "./src/context/epilogue.tsx",
+    "./context/exit": "./src/context/exit.tsx",
     "./context/kv": "./src/context/kv.tsx",
     "./context/project": "./src/context/project.tsx",
     "./context/runtime": "./src/context/runtime.tsx",
@@ -26,7 +27,6 @@
     "./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",

+ 111 - 92
packages/tui/src/app.tsx

@@ -5,6 +5,7 @@ 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 { ExitProvider, useExit } from "./context/exit"
 import { EpilogueProvider } from "./context/epilogue"
 import * as Selection from "./util/selection"
 import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
@@ -80,6 +81,7 @@ import { createTuiAttention } from "./attention"
 import * as TuiAudio from "./audio"
 import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
 import { destroyRenderer } from "./util/renderer"
+import { cliErrorMessage, errorFormat } from "./util/error"
 
 const appGlobalBindingCommands = [
   "session.list",
@@ -175,8 +177,8 @@ function isVersionGreater(left: string, right: string) {
 
 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(
+  const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
+  yield* Effect.scoped(
     Effect.gen(function* () {
       const renderer = yield* Effect.acquireRelease(
         Effect.tryPromise(() =>
@@ -194,7 +196,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
             },
           }),
         ),
-        (renderer) => Effect.sync(() => destroyRenderer(renderer)),
+        (renderer) =>
+          Effect.sync(() => {
+            destroyRenderer(renderer)
+          }),
       )
       win32DisableProcessedInput()
       const keymap = createDefaultOpenTuiKeymap(renderer)
@@ -212,7 +217,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
         }),
       )
       yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
-      const shutdown = yield* Deferred.make<void>()
+      const shutdown = yield* Deferred.make<unknown>()
       const onSighup = () => destroyRenderer(renderer)
       yield* Effect.acquireRelease(
         Effect.sync(() => process.on("SIGHUP", onSighup)),
@@ -229,103 +234,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
 
         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
+            <ExitProvider
+              exit={(reason) => {
+                if (renderer.isDestroyed) return
+                exit.reason = reason
+                destroyRenderer(renderer)
+              }}
+            >
+              <EpilogueProvider set={(value) => (exit.epilogue = value)}>
+                <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
+                  <TuiPathsProvider
                     value={{
-                      initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
-                      skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
+                      cwd: process.cwd(),
+                      home: global.home,
+                      state: global.state,
+                      worktree: global.data + "/worktree",
                     }}
                   >
-                    <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>
+                    <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>
+                          <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>
+                        </ClipboardProvider>
+                      </TuiStartupProvider>
+                    </TuiTerminalEnvironmentProvider>
+                  </TuiPathsProvider>
+                </ErrorBoundary>
+              </EpilogueProvider>
+            </ExitProvider>
           )
         }, renderer)
       })
       yield* Deferred.await(shutdown)
-      return epilogue.value
     }),
   )
   yield* Effect.sync(() => {
     win32FlushInputBuffer()
-    if (output) process.stdout.write(output + "\n")
+    if (exit.reason !== undefined)
+      process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
+    if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
   })
 })
 
@@ -346,6 +364,7 @@ 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 = createTuiAttention({ renderer, config: tuiConfig, kv })
@@ -786,7 +805,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
         title: "Exit the app",
         slashName: "exit",
         slashAliases: ["quit", "q"],
-        run: () => destroyRenderer(renderer),
+        run: () => exit(),
         category: "System",
       },
       {
@@ -1020,7 +1039,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
       `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
     )
 
-    destroyRenderer(renderer)
+    void exit()
   })
 
   const plugin = createMemo(() => {

+ 5 - 5
packages/tui/src/component/error-component.tsx

@@ -1,19 +1,19 @@
 import { TextAttributes } from "@opentui/core"
-import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
+import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { createSignal } from "solid-js"
 import { getScrollAcceleration } from "../util/scroll"
 import { useClipboard } from "../context/clipboard"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
-import { destroyRenderer } from "../util/renderer"
+import { useExit } from "../context/exit"
 
 export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
   const term = useTerminalDimensions()
-  const renderer = useRenderer()
+  const exit = useExit()
   const clipboard = useClipboard()
 
   useKeyboard((evt) => {
     if (evt.ctrl && evt.name === "c") {
-      destroyRenderer(renderer)
+      void exit()
     }
   })
   const [copied, setCopied] = createSignal(false)
@@ -66,7 +66,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
         <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
           <text fg={colors.bg}>Reset TUI</text>
         </box>
-        <box onMouseUp={() => destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}>
+        <box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
           <text fg={colors.bg}>Exit</text>
         </box>
       </box>

+ 3 - 2
packages/tui/src/component/prompt/index.tsx

@@ -27,7 +27,7 @@ import { useSync } from "../../context/sync"
 import { useEvent } from "../../context/event"
 import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
 import { normalizePromptContent, openEditor } from "../../editor"
-import { destroyRenderer } from "../../util/renderer"
+import { useExit } from "../../context/exit"
 import { promptOffsetWidth } from "../../prompt/display"
 import { createStore, produce, unwrap } from "solid-js/store"
 import { usePromptHistory, type PromptInfo } from "../../prompt/history"
@@ -163,6 +163,7 @@ export function Prompt(props: PromptProps) {
   const agentShortcut = useCommandShortcut("agent.cycle")
   const paletteShortcut = useCommandShortcut("command.palette.show")
   const renderer = useRenderer()
+  const exit = useExit()
   const dimensions = useTerminalDimensions()
   const { theme, syntax } = useTheme()
   const kv = useKV()
@@ -955,7 +956,7 @@ export function Prompt(props: PromptProps) {
     if (!agent) return false
     const trimmed = store.prompt.input.trim()
     if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
-      destroyRenderer(renderer)
+      void exit()
       return true
     }
     const selectedModel = local.model.current()

+ 0 - 51
packages/tui/src/context/aggregate-failures.ts

@@ -1,51 +0,0 @@
-import { cliErrorMessage } from "../util/error"
-
-/**
- * Aggregate Promise.allSettled results into a single Error that names every
- * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
- * boundaries so a single 4xx doesn't drown its parallel siblings as
- * unhandled rejections — every failure surfaces in one labeled message.
- */
-export type LabeledSettled = {
-  name: string
-  result: PromiseSettledResult<unknown>
-}
-
-export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
-  const failed = labeled.filter(
-    (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
-  )
-  if (failed.length === 0) return null
-
-  const reasons = Array.from(
-    failed
-      .map((f) => ({ name: f.name, message: reasonMessage(f.result.reason) }))
-      .reduce((grouped, failure) => {
-        grouped.set(failure.message, [...(grouped.get(failure.message) ?? []), failure.name])
-        return grouped
-      }, new Map<string, string[]>())
-      .entries(),
-  )
-    .map(([message, names]) =>
-      names.length === 1 ? `${names[0]}: ${message}` : `${message}\nAffected startup requests: ${names.join(", ")}`,
-    )
-    .join("; ")
-  const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
-  const err = new Error(summary)
-  err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
-  return err
-}
-
-function reasonMessage(reason: unknown): string {
-  const formatted = cliErrorMessage(reason)
-  if (formatted) return formatted
-
-  if (reason instanceof Error) return reason.message
-  if (typeof reason === "string") return reason
-  if (reason && typeof reason === "object") {
-    const obj = reason as { message?: unknown; name?: unknown }
-    if (typeof obj.message === "string") return obj.message
-    if (typeof obj.name === "string") return obj.name
-  }
-  return String(reason)
-}

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

@@ -0,0 +1,8 @@
+import { createSimpleContext } from "./helper"
+
+export type Exit = (reason?: unknown) => void
+
+export const { use: useExit, provider: ExitProvider } = createSimpleContext({
+  name: "Exit",
+  init: (input: { exit: Exit }) => input.exit,
+})

+ 11 - 22
packages/tui/src/context/sync.tsx

@@ -26,13 +26,11 @@ import { useEvent } from "./event"
 import { useSDK } from "./sdk"
 import { useTuiStartup } from "./runtime"
 import { createSimpleContext } from "./helper"
-import { useRenderer } from "@opentui/solid"
+import { useExit } from "./exit"
 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: [],
@@ -424,7 +422,7 @@ export const {
       }
     })
 
-    const renderer = useRenderer()
+    const exit = useExit()
     const args = useArgs()
 
     async function bootstrap(input: { fatal?: boolean } = {}) {
@@ -442,23 +440,14 @@ export const {
         .catch(() => emptyConsoleState)
       const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
       const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
-      const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
-        { name: "config.providers", promise: providersPromise },
-        { name: "provider.list", promise: providerListPromise },
-        { name: "app.agents", promise: agentsPromise },
-        { name: "config.get", promise: configPromise },
-        { name: "project.sync", promise: projectPromise },
-        ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
-      ]
-
-      await Promise.allSettled(blockingRequests.map((r) => r.promise))
-        .then((settled) => {
-          // Surface every failed endpoint in one labeled message instead of
-          // letting the first rejection drown its siblings as unhandled
-          // rejections.
-          const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
-          if (failure) throw failure
-        })
+      await Promise.all([
+        providersPromise,
+        providerListPromise,
+        agentsPromise,
+        configPromise,
+        projectPromise,
+        ...(args.continue ? [sessionListPromise] : []),
+      ])
         .then(async () => {
           const providersResponse = providersPromise.then((x) => x.data!)
           const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -523,7 +512,7 @@ export const {
             stack: e instanceof Error ? e.stack : undefined,
           })
           if (fatal) {
-            destroyRenderer(renderer)
+            exit(e)
           } else {
             throw e
           }

+ 0 - 3
packages/tui/src/feature-plugins/builtins.ts

@@ -1,7 +1,6 @@
 import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
 import HomeFooter from "./home/footer"
 import HomeTips from "./home/tips"
-import SessionSwitcher from "./session"
 import SidebarContext from "./sidebar/context"
 import SidebarFiles from "./sidebar/files"
 import SidebarFooter from "./sidebar/footer"
@@ -22,7 +21,6 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
 
 export function createBuiltinPlugins(options: {
   experimentalEventSystem: boolean
-  experimentalSessionSwitcher: boolean
 }): BuiltinTuiPlugin[] {
   return [
     HomeFooter,
@@ -38,6 +36,5 @@ export function createBuiltinPlugins(options: {
     WhichKey,
     DiffViewer,
     ...(options.experimentalEventSystem ? [SessionV2Debug] : []),
-    ...(options.experimentalSessionSwitcher ? [SessionSwitcher] : []),
   ]
 }

+ 0 - 356
packages/tui/src/feature-plugins/session/dialog.tsx

@@ -1,356 +0,0 @@
-import { useDialog } from "../../ui/dialog"
-import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
-import { useRoute } from "../../context/route"
-import { useSync } from "../../context/sync"
-import { useProject } from "../../context/project"
-import { useTheme } from "../../context/theme"
-import { useSDK } from "../../context/sdk"
-import { useLocal } from "../../context/local"
-import { useToast } from "../../ui/toast"
-import { useCommandShortcut } from "../../keymap"
-import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
-import { useTerminalDimensions } from "@opentui/solid"
-import { Spinner } from "../../component/spinner"
-import { DialogSessionRename } from "../../component/dialog-session-rename"
-import { DialogSessionDeleteFailed } from "../../component/dialog-session-delete-failed"
-import {
-  openWorkspaceSelect,
-  type WorkspaceSelection,
-  warpWorkspaceSession,
-} from "../../component/dialog-workspace-create"
-import { createDebouncedSignal } from "../../util/signal"
-import { errorMessage } from "../../util/error"
-import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane"
-import { relativeTime } from "./util"
-
-export function SessionSwitcherDialog() {
-  const dialog = useDialog()
-  const route = useRoute()
-  const sync = useSync()
-  const project = useProject()
-  const { theme } = useTheme()
-  const sdk = useSDK()
-  const local = useLocal()
-  const toast = useToast()
-  const dimensions = useTerminalDimensions()
-  const [toDelete, setToDelete] = createSignal<string>()
-  const [search, setSearch] = createDebouncedSignal("", 150)
-  const deleteHint = useCommandShortcut("session.delete")
-  const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
-  const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
-  let select: DialogSelectRef<string> | undefined
-
-  const [searchResults, { refetch }] = createResource(
-    () => ({ query: search(), filter: sync.session.query() }),
-    async (input) => {
-      if (!input.query) return undefined
-      const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
-      return result.data ?? []
-    },
-  )
-
-  const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
-  const sessions = createMemo(() => searchResults() ?? sync.data.session)
-  const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal<string | undefined>(
-    undefined,
-    150,
-  )
-  const focusedSessionInfo = createMemo(() => {
-    const id = focusedSession()
-    if (!id) return undefined
-    return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id)
-  })
-
-  function recoverFailed(session: NonNullable<ReturnType<typeof sessions>[number]>) {
-    const workspace = project.workspace.get(session.workspaceID!)
-    const list = () => dialog.replace(() => <SessionSwitcherDialog />)
-    const warp = async (selection: WorkspaceSelection) => {
-      const workspaceID = await (async () => {
-        if (selection.type === "none") return null
-        if (selection.type === "existing") return selection.workspaceID
-        const result = await sdk.client.experimental.workspace
-          .create({ type: selection.workspaceType, branch: null })
-          .catch(() => undefined)
-        const created = result?.data
-        if (!created) {
-          toast.show({
-            message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
-            variant: "error",
-          })
-          return
-        }
-        await project.workspace.sync()
-        return created.id
-      })()
-      if (workspaceID === undefined) return
-      await warpWorkspaceSession({
-        dialog,
-        sdk,
-        sync,
-        project,
-        toast,
-        sourceWorkspaceID: session.workspaceID,
-        workspaceID,
-        sessionID: session.id,
-        copyChanges: false,
-        done: list,
-      })
-    }
-    dialog.replace(() => (
-      <DialogSessionDeleteFailed
-        session={session.title}
-        workspace={workspace?.name ?? session.workspaceID!}
-        onDone={list}
-        onDelete={async () => {
-          const current = currentSessionID()
-          const info = current ? sync.data.session.find((item) => item.id === current) : undefined
-          const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
-          if (result.error) {
-            toast.show({
-              variant: "error",
-              title: "Failed to delete workspace",
-              message: errorMessage(result.error),
-            })
-            return false
-          }
-          await project.workspace.sync()
-          await sync.session.refresh()
-          if (search()) await refetch()
-          if (info?.workspaceID === session.workspaceID) {
-            route.navigate({ type: "home" })
-          }
-          return true
-        }}
-        onRestore={() => {
-          void openWorkspaceSelect({
-            dialog,
-            sdk,
-            sync,
-            project,
-            toast,
-            onSelect: (selection) => {
-              void warp(selection)
-            },
-          })
-          return false
-        }}
-      />
-    ))
-  }
-
-  function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
-    return sessionsList
-      .filter((x) => x.parentID === undefined)
-      .toSorted((a, b) => b.time.updated - a.time.updated)
-      .map((x) => x.id)
-  }
-
-  const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
-
-  const quickSwitchHint = createMemo(() => {
-    const first = quickSwitch1()
-    const last = quickSwitch9()
-    if (!first || !last) return undefined
-    return quickSwitchRange(first, last)
-  })
-  const options = createMemo<DialogSelectOption<string>[]>(() => {
-    const today = new Date().toDateString()
-    const sessionMap = new Map(
-      sessions()
-        .filter((x) => x.parentID === undefined)
-        .map((x) => [x.id, x]),
-    )
-
-    const searchResult = searchResults()
-    const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
-
-    const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
-    const pinnedSet = new Set(pinned)
-    const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
-
-    function buildOption(id: string, category: string): DialogSelectOption<string> | undefined {
-      const x = sessionMap.get(id)
-      if (!x) return undefined
-      const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
-
-      const footer = relativeTime(x.time.updated)
-      const isWorktree = workspace?.type === "worktree"
-
-      const isDeleting = toDelete() === x.id
-      const status = sync.data.session_status?.[x.id]
-      const isWorking = status?.type === "busy" || status?.type === "retry"
-      const slot = slotByID.get(x.id)
-      const gutter =
-        slot !== undefined || isWorking
-          ? () => (
-              <box flexDirection="row" gap={1}>
-                <Show when={slot !== undefined}>
-                  <text fg={theme.accent}>{slot}</text>
-                </Show>
-                <Show when={isWorking}>
-                  <Spinner />
-                </Show>
-              </box>
-            )
-          : undefined
-      const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
-      return {
-        title: titleText,
-        bg: isDeleting ? theme.error : undefined,
-        value: x.id,
-        category,
-        categoryView:
-          category === "Pinned" ? (
-            <text>
-              <span style={{ fg: theme.accent }}>
-                <b>Pinned</b>
-              </span>
-              <Show when={quickSwitchHint()}>
-                {(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
-              </Show>
-            </text>
-          ) : undefined,
-        footer,
-        gutter,
-      }
-    }
-
-    const remaining = displayOrder
-      .filter((id) => !pinnedSet.has(id))
-      .map((id) => {
-        const x = sessionMap.get(id)
-        if (!x) return undefined
-        const label = new Date(x.time.updated).toDateString()
-        return buildOption(id, label === today ? "Today" : label)
-      })
-      .filter((x): x is DialogSelectOption<string> => x !== undefined)
-
-    return [
-      ...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => x !== undefined),
-      ...remaining,
-    ]
-  })
-
-  createEffect(
-    on([options, currentSessionID], ([items, current]) => {
-      const selected = untrack(() => select?.selected)
-      const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined
-      const currentID = current && items.some((item) => item.value === current) ? current : undefined
-      setFocusedSession(selectedID ?? currentID ?? items[0]?.value)
-    }),
-  )
-
-  const showPreview = createMemo(() => dimensions().width >= 100)
-  const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
-
-  createEffect(() => {
-    dialog.setSize(showPreview() ? "xlarge" : "large")
-  })
-
-  const list = (
-    <DialogSelect
-      ref={(value) => (select = value)}
-      title="Sessions"
-      options={options()}
-      skipFilter={true}
-      current={currentSessionID()}
-      onFilter={setSearch}
-      onMove={(option) => {
-        setToDelete(undefined)
-        scheduleFocused(option.value)
-      }}
-      onSelect={(option) => {
-        route.navigate({
-          type: "session",
-          sessionID: option.value,
-        })
-        dialog.clear()
-      }}
-      actions={[
-        {
-          command: "session.pin.toggle",
-          title: "pin/unpin",
-          onTrigger: (option: { value: string }) => {
-            local.session.togglePin(option.value)
-            queueMicrotask(() => select?.moveTo(option.value))
-          },
-        },
-        {
-          command: "session.delete",
-          title: "delete",
-          onTrigger: async (option) => {
-            if (toDelete() === option.value) {
-              const session = sessions().find((item) => item.id === option.value)
-              const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
-
-              try {
-                const result = await sdk.client.session.delete({
-                  sessionID: option.value,
-                })
-                if (result.error) {
-                  if (session?.workspaceID) {
-                    recoverFailed(session)
-                  } else {
-                    toast.show({
-                      variant: "error",
-                      title: "Failed to delete session",
-                      message: errorMessage(result.error),
-                    })
-                  }
-                  setToDelete(undefined)
-                  return
-                }
-              } catch (err) {
-                if (session?.workspaceID) {
-                  recoverFailed(session)
-                } else {
-                  toast.show({
-                    variant: "error",
-                    title: "Failed to delete session",
-                    message: errorMessage(err),
-                  })
-                }
-                setToDelete(undefined)
-                return
-              }
-              if (status && status !== "connected") {
-                await sync.session.refresh()
-              }
-              if (search()) await refetch()
-              setToDelete(undefined)
-              return
-            }
-            setToDelete(option.value)
-          },
-        },
-        {
-          command: "session.rename",
-          title: "rename",
-          onTrigger: async (option) => {
-            dialog.replace(() => <DialogSessionRename session={option.value} />)
-          },
-        },
-      ]}
-    />
-  )
-
-  return (
-    <box flexDirection="row" width="100%" height={height()}>
-      <box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
-        {list}
-      </box>
-      <Show when={showPreview()}>
-        <box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
-        <box flexGrow={1} flexShrink={1} flexDirection="column">
-          <SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
-        </box>
-      </Show>
-    </box>
-  )
-}
-
-function quickSwitchRange(first: string, last: string) {
-  const prefix = first.slice(0, -1)
-  if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
-  return `${first} through ${last}`
-}

+ 0 - 32
packages/tui/src/feature-plugins/session/index.tsx

@@ -1,32 +0,0 @@
-import type { TuiPlugin } from "@opencode-ai/plugin/tui"
-import type { BuiltinTuiPlugin } from "../builtins"
-import { SessionSwitcherDialog } from "./dialog"
-
-const id = "internal:session-switcher"
-
-const tui: TuiPlugin = async (api) => {
-  api.keymap.registerLayer({
-    priority: 1000,
-    commands: [
-      {
-        name: "session.list",
-        title: "Switch session",
-        category: "Session",
-        namespace: "palette",
-        suggested: () => api.state.session.count() > 0,
-        slashName: "sessions",
-        slashAliases: ["resume", "continue"],
-        run() {
-          api.ui.dialog.replace(() => <SessionSwitcherDialog />)
-        },
-      },
-    ],
-  })
-}
-
-const plugin: BuiltinTuiPlugin = {
-  id,
-  tui,
-}
-
-export default plugin

+ 0 - 288
packages/tui/src/feature-plugins/session/preview-pane.tsx

@@ -1,288 +0,0 @@
-import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
-import { TextAttributes } from "@opentui/core"
-import { useTerminalDimensions } from "@opentui/solid"
-import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
-import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2"
-import { useTheme } from "../../context/theme"
-import { useSDK } from "../../context/sdk"
-import { useSync } from "../../context/sync"
-import { Locale } from "../../util/locale"
-import { Spinner } from "../../component/spinner"
-import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
-
-type WithParts = { info: Message; parts: Part[] }
-
-type Sdk = ReturnType<typeof useSDK>
-type Sync = ReturnType<typeof useSync>
-
-const messageCache = new Map<string, Promise<WithParts[]>>()
-
-function cacheKey(sessionID: string, version: number) {
-  return `${sessionID}:${version}`
-}
-
-function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined {
-  const infos = sync.data.message[sessionID]
-  if (!infos || infos.length === 0) return undefined
-  return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] }))
-}
-
-function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<WithParts[]> {
-  const key = cacheKey(sessionID, version)
-  const cached = messageCache.get(key)
-  if (cached) return cached
-
-  const promise = sdk.client.session
-    .messages({ sessionID, limit: 50 })
-    .then((res) => {
-      if (res.error) throw res.error
-      return (res.data as WithParts[] | undefined) ?? []
-    })
-    .catch((error) => {
-      messageCache.delete(key)
-      throw error
-    })
-  messageCache.set(key, promise)
-  return promise
-}
-
-export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
-  for (const id of sessionIDs) {
-    const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
-    if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
-  }
-}
-
-export function createLeadingTrailingSignal<T>(initial: T, ms: number): [Accessor<T>, (v: T) => void, (v: T) => void] {
-  const [get, set] = createSignal(initial)
-  const setNow = (v: T) => set(() => v)
-  const schedule = leadingAndTrailing(debounce, setNow, ms)
-  return [get, setNow, schedule]
-}
-
-export function SessionPreviewPane(props: {
-  sessionID: Accessor<string | undefined>
-  session?: Accessor<SdkSession | undefined>
-}) {
-  const { theme } = useTheme()
-  const sdk = useSDK()
-  const sync = useSync()
-  const dimensions = useTerminalDimensions()
-
-  const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
-  const session = createMemo(() => {
-    const provided = props.session?.()
-    if (provided) return provided
-    const id = props.sessionID()
-    if (!id) return undefined
-    return sync.data.session.find((s) => s.id === id)
-  })
-
-  const status = createMemo(() => {
-    const id = props.sessionID()
-    if (!id) return undefined
-    return sync.data.session_status?.[id]?.type
-  })
-
-  onMount(() => {
-    const top = sync.data.session
-      .filter((s) => s.parentID === undefined)
-      .slice()
-      .sort((a, b) => b.time.updated - a.time.updated)
-      .slice(0, 5)
-      .map((s) => s.id)
-    prefetchPreviews(sdk, sync, top)
-  })
-
-  const syncedMessages = createMemo(() => {
-    const id = props.sessionID()
-    if (!id) return undefined
-    return hydrateFromSync(sync, id)
-  })
-
-  const [fetchedMessages] = createResource(
-    () => {
-      const id = props.sessionID()
-      if (!id || syncedMessages()) return undefined
-      return { sessionID: id, version: session()?.time.updated ?? 0 }
-    },
-    async (input) => loadMessages(sdk, input.sessionID, input.version),
-  )
-
-  const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
-
-  const exchange = createMemo(() => {
-    const items = messages()
-    if (!items || items.length === 0) return undefined
-    const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b))
-    const user = sorted.findLast((item) => messageRole(item) === "user")
-    const assistant = user
-      ? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id)
-      : sorted.findLast((item) => messageRole(item) === "assistant")
-    return { user, assistant }
-  })
-
-  const loading = createMemo(() => fetchedMessages.loading && !exchange())
-
-  const statusLabel = createMemo(() => {
-    const s = status()
-    if (s === "busy") return "working"
-    if (s === "retry") return "retrying"
-    return "idle"
-  })
-
-  return (
-    <box
-      flexDirection="column"
-      paddingLeft={2}
-      paddingRight={2}
-      paddingTop={1}
-      paddingBottom={1}
-      gap={1}
-      height={maxHeight()}
-      overflow="hidden"
-    >
-      <Show
-        when={session()}
-        fallback={
-          <text fg={theme.textMuted} wrapMode="word">
-            No session selected
-          </text>
-        }
-      >
-        {(s) => (
-          <>
-            <Header session={s()} statusLabel={statusLabel()} />
-            <Show when={loading()}>
-              <Spinner>loading preview...</Spinner>
-            </Show>
-            <Show
-              when={exchange()}
-              fallback={
-                <Show when={!loading()}>
-                  <text fg={theme.textMuted} wrapMode="word">
-                    {fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
-                  </text>
-                </Show>
-              }
-            >
-              {(ex) => <Exchange exchange={ex()} />}
-            </Show>
-          </>
-        )}
-      </Show>
-    </box>
-  )
-}
-
-function messageRole(item: WithParts) {
-  return (item.info as { role?: string }).role
-}
-
-function messageCreated(item: WithParts) {
-  return (item.info.time as { created?: number }).created ?? 0
-}
-
-function messageParentID(item: WithParts) {
-  return (item.info as { parentID?: string }).parentID
-}
-
-const ROW_WIDTH = 40
-
-function Header(props: { session: SdkSession; statusLabel: string }) {
-  const { theme } = useTheme()
-  const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
-  const statusRest = createMemo(() => {
-    const joined = ` · ${relativeTime(props.session.time.updated)}`
-    return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
-  })
-
-  return (
-    <box flexDirection="column" gap={0} flexShrink={0}>
-      <Row height={1}>
-        <text fg={theme.text} attributes={TextAttributes.BOLD} wrapMode="none" overflow="hidden">
-          {title()}
-        </text>
-      </Row>
-      <Row height={1}>
-        <text fg={theme.textMuted} wrapMode="none" overflow="hidden">
-          <span>{props.statusLabel}</span>
-          <span>{statusRest()}</span>
-        </text>
-      </Row>
-    </box>
-  )
-}
-
-function Row(props: { height: number; children: JSX.Element }) {
-  return (
-    <box height={props.height} flexShrink={0} overflow="hidden">
-      {props.children}
-    </box>
-  )
-}
-
-const PROMPT_MAX_CHARS = 240
-const REPLY_MAX_LINES = 12
-const REPLY_MAX_CHARS = 800
-
-function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
-  const { theme, syntax } = useTheme()
-  const userText = createMemo(() =>
-    props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined,
-  )
-  const assistantMarkdown = createMemo(() =>
-    props.exchange.assistant
-      ? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS)
-      : undefined,
-  )
-
-  return (
-    <box flexDirection="column" gap={1}>
-      <Show when={userText()}>
-        <text fg={theme.textMuted} wrapMode="word">
-          <span style={{ fg: theme.textMuted }}>› </span>
-          {userText()!}
-        </text>
-      </Show>
-      <Show when={assistantMarkdown()}>
-        <markdown
-          content={assistantMarkdown()!}
-          syntaxStyle={syntax()}
-          streaming={false}
-          internalBlockMode="top-level"
-          tableOptions={{ style: "columns" }}
-          conceal={false}
-          fg={theme.markdownText}
-          bg={theme.backgroundPanel}
-        />
-      </Show>
-      <Show when={!userText() && !assistantMarkdown()}>
-        <NonTextHint exchange={props.exchange} />
-      </Show>
-    </box>
-  )
-}
-
-function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
-  const { theme } = useTheme()
-  const summary = createMemo(() => {
-    const counts: Record<string, number> = {}
-    for (const item of [props.exchange.user, props.exchange.assistant]) {
-      if (!item) continue
-      for (const part of item.parts) {
-        counts[part.type] = (counts[part.type] ?? 0) + 1
-      }
-    }
-    return Object.entries(counts)
-      .map(([k, n]) => `${n} ${k}`)
-      .join(", ")
-  })
-  return (
-    <text fg={theme.textMuted} wrapMode="word">
-      <Show when={summary()} fallback="No text content in the latest messages">
-        Latest exchange has no text content ({summary()})
-      </Show>
-    </text>
-  )
-}

+ 0 - 54
packages/tui/src/feature-plugins/session/util.tsx

@@ -1,54 +0,0 @@
-import type { Part } from "@opencode-ai/sdk/v2"
-import { Locale } from "../../util/locale"
-
-export function relativeTime(timestamp: number): string {
-  const diff = Date.now() - timestamp
-  if (diff < 0) return "just now"
-  const seconds = Math.floor(diff / 1000)
-  if (seconds < 60) return "just now"
-  const minutes = Math.floor(seconds / 60)
-  if (minutes < 60) return `${minutes}m ago`
-  const hours = Math.floor(minutes / 60)
-  if (hours < 24) return `${hours}h ago`
-  const days = Math.floor(hours / 24)
-  if (days < 7) return `${days}d ago`
-  const d = new Date(timestamp)
-  return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
-}
-
-export function extractMessageText(parts: readonly Part[], maxLength: number): string {
-  const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim()
-  return Locale.truncate(joined, maxLength)
-}
-
-export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string {
-  const joined = collectTextParts(parts).join("\n\n").trim()
-  if (!joined) return joined
-
-  let truncated = joined
-  const lines = truncated.split("\n")
-  if (lines.length > maxLines) {
-    truncated = lines.slice(0, maxLines).join("\n")
-  }
-  if (truncated.length > maxChars) {
-    truncated = truncated.slice(0, maxChars).trimEnd()
-  }
-  if (truncated.length === joined.length) return joined
-  // Close any unterminated fenced code block so the renderer doesn't keep
-  // the rest of the panel in "code mode".
-  const fences = (truncated.match(/^```/gm) ?? []).length
-  if (fences % 2 === 1) truncated += "\n```"
-  return truncated + "\n\n…"
-}
-
-function collectTextParts(parts: readonly Part[]): string[] {
-  const chunks: string[] = []
-  for (const part of parts) {
-    if (part.type !== "text") continue
-    const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
-    if (p.synthetic || p.ignored) continue
-    if (!p.text) continue
-    chunks.push(p.text)
-  }
-  return chunks
-}

+ 0 - 4
packages/tui/src/ui/dialog-select.tsx

@@ -72,7 +72,6 @@ export interface DialogSelectOption<T = any> {
 export type DialogSelectRef<T> = {
   filter: string
   filtered: DialogSelectOption<T>[]
-  selected: DialogSelectOption<T> | undefined
   moveTo(value: T): void
 }
 
@@ -409,9 +408,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     get filtered() {
       return filtered()
     },
-    get selected() {
-      return selected()
-    },
     moveTo(value) {
       const index = flat().findIndex((option) => isDeepEqual(option.value, value))
       if (index >= 0) moveTo(index, true)

+ 4 - 2
packages/tui/src/util/renderer.ts

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

+ 0 - 95
packages/tui/test/context/aggregate-failures.test.ts

@@ -1,95 +0,0 @@
-/**
- * Regression test for the TUI bootstrap aggregation helper. Replaces the
- * pre-fix Promise.all behavior where the first rejection drowned every
- * sibling endpoint's failure as an unhandled rejection.
- */
-import { describe, expect, test } from "bun:test"
-import { aggregateFailures } from "@opencode-ai/tui/context/aggregate-failures"
-
-describe("aggregateFailures", () => {
-  test("returns null when every result is fulfilled", () => {
-    expect(
-      aggregateFailures([
-        { name: "config", result: { status: "fulfilled", value: 1 } },
-        { name: "providers", result: { status: "fulfilled", value: 2 } },
-      ]),
-    ).toBeNull()
-  })
-
-  test("names the failed endpoint when one rejects", () => {
-    const err = aggregateFailures([
-      { name: "config", result: { status: "fulfilled", value: 1 } },
-      {
-        name: "providers",
-        result: { status: "rejected", reason: new Error("Service unavailable") },
-      },
-    ])
-    expect(err).toBeInstanceOf(Error)
-    expect(err!.message).toContain("1 of 2")
-    expect(err!.message).toContain("providers: Service unavailable")
-  })
-
-  test("names every failed endpoint when multiple reject", () => {
-    const err = aggregateFailures([
-      { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
-      { name: "providers", result: { status: "fulfilled", value: 1 } },
-      { name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
-    ])
-    expect(err).toBeInstanceOf(Error)
-    expect(err!.message).toContain("2 of 3")
-    expect(err!.message).toContain("config: 400 Bad Request")
-    expect(err!.message).toContain("agents: boom")
-  })
-
-  test("formats structured config errors hidden inside SDK error causes", () => {
-    const configError = {
-      name: "ConfigInvalidError",
-      data: {
-        path: "/tmp/opencode.json",
-        issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
-      },
-    }
-    const err = aggregateFailures([
-      {
-        name: "config.get",
-        result: {
-          status: "rejected",
-          reason: new Error("ConfigInvalidError", {
-            cause: {
-              body: configError,
-            },
-          }),
-        },
-      },
-    ])
-
-    expect(err!.message).toContain("config.get: Configuration is invalid at /tmp/opencode.json")
-    expect(err!.message).toContain("Expected object provider.anthropic.options")
-  })
-
-  test("deduplicates identical failure messages across startup requests", () => {
-    const reason = new Error("same config problem")
-    const err = aggregateFailures([
-      { name: "config.providers", result: { status: "rejected", reason } },
-      { name: "provider.list", result: { status: "rejected", reason } },
-      { name: "app.agents", result: { status: "rejected", reason } },
-      { name: "config.get", result: { status: "rejected", reason } },
-      { name: "project.sync", result: { status: "fulfilled", value: undefined } },
-    ])
-
-    expect(err!.message).toContain("4 of 5 requests failed: same config problem")
-    expect(err!.message).toContain("Affected startup requests: config.providers, provider.list, app.agents, config.get")
-    expect(err!.message.match(/same config problem/g)?.length).toBe(1)
-  })
-
-  test("attaches structured failure list under .cause", () => {
-    const reason = new Error("nope")
-    const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
-    expect(err!.cause).toEqual({ failures: [{ name: "providers", reason }] })
-  })
-
-  test("falls back to String() for opaque reasons", () => {
-    const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }])
-    expect(err!.message).toContain("x: 42")
-  })
-})

+ 1 - 2
packages/ui/package.json

@@ -29,8 +29,7 @@
   },
   "scripts": {
     "typecheck": "tsgo --noEmit",
-    "test": "bun test src",
-    "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
+    "test": "bun test src --only-failures",
     "dev": "vite",
     "generate:tailwind": "bun run script/tailwind.ts",
     "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts"

+ 0 - 19
turbo.json

@@ -13,32 +13,13 @@
       "outputs": [],
       "passThroughEnv": ["*"]
     },
-    "test:ci": {
-      "outputs": [".artifacts/unit/junit.xml"],
-      "passThroughEnv": ["*"]
-    },
-    "opencode#test:ci": {
-      "dependsOn": ["^build"],
-      "outputs": [".artifacts/unit/junit.xml"],
-      "passThroughEnv": ["*"]
-    },
     "@opencode-ai/app#test": {
       "dependsOn": ["^build"],
       "outputs": []
     },
-    "@opencode-ai/app#test:ci": {
-      "dependsOn": ["^build"],
-      "outputs": [".artifacts/unit/junit.xml"],
-      "passThroughEnv": ["*"]
-    },
     "@opencode-ai/ui#test": {
       "dependsOn": ["^build"],
       "outputs": []
-    },
-    "@opencode-ai/ui#test:ci": {
-      "dependsOn": ["^build"],
-      "outputs": [".artifacts/unit/junit.xml"],
-      "passThroughEnv": ["*"]
     }
   }
 }