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

This commit is contained in:
Dax 2026-06-09 11:46:02 -04:00 committed by GitHub
parent 960eacebcf
commit 37522185d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 176 additions and 1217 deletions

View File

@ -65,7 +65,7 @@ jobs:
- name: Run unit tests - name: Run unit tests
timeout-minutes: 20 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: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@ -74,26 +74,6 @@ jobs:
working-directory: packages/opencode working-directory: packages/opencode
run: bun run test:httpapi 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: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
strategy: strategy:
@ -151,7 +131,6 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
@ -162,6 +141,5 @@ jobs:
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 retention-days: 7
path: | path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results packages/app/e2e/test-results
packages/app/e2e/playwright-report packages/app/e2e/playwright-report

View File

@ -18,8 +18,7 @@
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "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 --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",

View File

@ -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 command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined 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({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
outputDir: "./e2e/test-results", outputDir: "./e2e/test-results",
@ -24,7 +18,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers, workers,
reporter, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: { webServer: {
command, command,
url: baseURL, url: baseURL,

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"setup:recording-env": "bun run script/setup-recording-env.ts", "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" "typecheck": "tsgo --noEmit"
}, },
"exports": { "exports": {

View File

@ -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.

View File

@ -7,8 +7,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"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", "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", "bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts", "profile:test": "bun run script/profile-test-files.ts",

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
import { Cause, Effect } from "effect" import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" 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) if (!defect) return Effect.failCause(cause)
const error = defect.defect 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)}` const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed", { ref, error, cause: Cause.pretty(cause) }).pipe( return Effect.logError("failed", { ref, error, cause: Cause.pretty(cause) }).pipe(

View File

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

View File

@ -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* () { Effect.gen(function* () {
const configError = new ConfigErrorV1.InvalidError({ const configError = new ConfigErrorV1.InvalidError({
path: "/tmp/opencode.json", path: "/tmp/opencode.json",
@ -70,11 +70,16 @@ describe("HttpApi error middleware", () => {
const body = yield* response.json const body = yield* response.json
const serialized = JSON.stringify(body) const serialized = JSON.stringify(body)
expect(response.status).toBe(500) expect(response.status).toBe(400)
expectUnknownErrorBody(body) expect(body).toMatchObject({
expect(serialized).not.toContain("/tmp/opencode.json") name: "ConfigInvalidError",
expect(serialized).not.toContain("provider") data: {
expect(serialized).not.toContain("anthropic") path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
},
})
expect(serialized).toContain("/tmp/opencode.json")
expect(serialized).toContain("anthropic")
}), }),
) )

View File

@ -6,7 +6,7 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit" "typecheck": "tsgo --noEmit"
}, },
"exports": { "exports": {
@ -15,6 +15,7 @@
"./config": "./src/config/index.tsx", "./config": "./src/config/index.tsx",
"./context/args": "./src/context/args.tsx", "./context/args": "./src/context/args.tsx",
"./context/epilogue": "./src/context/epilogue.tsx", "./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx", "./context/kv": "./src/context/kv.tsx",
"./context/project": "./src/context/project.tsx", "./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx", "./context/runtime": "./src/context/runtime.tsx",
@ -26,7 +27,6 @@
"./attention": "./src/attention.ts", "./attention": "./src/attention.ts",
"./editor": "./src/editor.ts", "./editor": "./src/editor.ts",
"./editor-zed": "./src/editor-zed.ts", "./editor-zed": "./src/editor-zed.ts",
"./context/aggregate-failures": "./src/context/aggregate-failures.ts",
"./runtime": "./src/runtime.tsx", "./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts", "./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts", "./config/keybind": "./src/config/keybind.ts",

View File

@ -5,6 +5,7 @@ import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue" import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection" import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
@ -80,6 +81,7 @@ import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio" import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer" import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
const appGlobalBindingCommands = [ const appGlobalBindingCommands = [
"session.list", "session.list",
@ -175,8 +177,8 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const global = yield* Global.Service const global = yield* Global.Service
const epilogue = { value: undefined as string | undefined } const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const output = yield* Effect.scoped( yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease( const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() => 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() win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer) 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)) yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
const shutdown = yield* Deferred.make<void>() const shutdown = yield* Deferred.make<unknown>()
const onSighup = () => destroyRenderer(renderer) const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease( yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)), Effect.sync(() => process.on("SIGHUP", onSighup)),
@ -229,103 +234,116 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
await render(() => { await render(() => {
return ( return (
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}> <ExitProvider
<TuiPathsProvider exit={(reason) => {
value={{ if (renderer.isDestroyed) return
cwd: process.cwd(), exit.reason = reason
home: global.home, destroyRenderer(renderer)
state: global.state, }}
worktree: global.data + "/worktree", >
}} <EpilogueProvider set={(value) => (exit.epilogue = value)}>
> <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiTerminalEnvironmentProvider <TuiPathsProvider
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={{ value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined, cwd: process.cwd(),
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}} }}
> >
<ClipboardProvider> <TuiTerminalEnvironmentProvider
<EpilogueProvider set={(value) => (epilogue.value = value)}> value={{
<OpencodeKeymapProvider keymap={keymap}> platform: process.platform,
<ArgsProvider {...input.args}> multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
<KVProvider> displayServer: process.env.WAYLAND_DISPLAY
<ToastProvider> ? "wayland"
<RouteProvider : process.env.DISPLAY
initialRoute={ ? "x11"
input.args.continue : undefined,
? { }}
type: "session", >
sessionID: "dummy", <TuiStartupProvider
} value={{
: undefined initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
} skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
> }}
<TuiConfigProvider config={input.config}> >
<PluginRuntimeProvider value={pluginRuntime}> <ClipboardProvider>
<SDKProvider <OpencodeKeymapProvider keymap={keymap}>
url={input.url} <ArgsProvider {...input.args}>
directory={input.directory} <KVProvider>
fetch={input.fetch} <ToastProvider>
headers={input.headers} <RouteProvider
events={input.events} initialRoute={
> input.args.continue
<ProjectProvider> ? {
<SyncProvider> type: "session",
<SyncProviderV2> sessionID: "dummy",
<ThemeProvider mode={mode}> }
<LocalProvider> : undefined
<PromptStashProvider> }
<DialogProvider> >
<FrecencyProvider> <TuiConfigProvider config={input.config}>
<PromptHistoryProvider> <PluginRuntimeProvider value={pluginRuntime}>
<PromptRefProvider> <SDKProvider
<EditorContextProvider> url={input.url}
<App directory={input.directory}
onSnapshot={input.onSnapshot} fetch={input.fetch}
pluginHost={input.pluginHost} headers={input.headers}
/> events={input.events}
</EditorContextProvider> >
</PromptRefProvider> <ProjectProvider>
</PromptHistoryProvider> <SyncProvider>
</FrecencyProvider> <SyncProviderV2>
</DialogProvider> <ThemeProvider mode={mode}>
</PromptStashProvider> <LocalProvider>
</LocalProvider> <PromptStashProvider>
</ThemeProvider> <DialogProvider>
</SyncProviderV2> <FrecencyProvider>
</SyncProvider> <PromptHistoryProvider>
</ProjectProvider> <PromptRefProvider>
</SDKProvider> <EditorContextProvider>
</PluginRuntimeProvider> <App
</TuiConfigProvider> onSnapshot={input.onSnapshot}
</RouteProvider> pluginHost={input.pluginHost}
</ToastProvider> />
</KVProvider> </EditorContextProvider>
</ArgsProvider> </PromptRefProvider>
</OpencodeKeymapProvider> </PromptHistoryProvider>
</EpilogueProvider> </FrecencyProvider>
</ClipboardProvider> </DialogProvider>
</TuiStartupProvider> </PromptStashProvider>
</TuiTerminalEnvironmentProvider> </LocalProvider>
</TuiPathsProvider> </ThemeProvider>
</ErrorBoundary> </SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
</ExitProvider>
) )
}, renderer) }, renderer)
}) })
yield* Deferred.await(shutdown) yield* Deferred.await(shutdown)
return epilogue.value
}), }),
) )
yield* Effect.sync(() => { yield* Effect.sync(() => {
win32FlushInputBuffer() 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 { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync() const sync = useSync()
const project = useProject() const project = useProject()
const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime() const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
@ -786,7 +805,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
title: "Exit the app", title: "Exit the app",
slashName: "exit", slashName: "exit",
slashAliases: ["quit", "q"], slashAliases: ["quit", "q"],
run: () => destroyRenderer(renderer), run: () => exit(),
category: "System", 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.`, `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
) )
destroyRenderer(renderer) void exit()
}) })
const plugin = createMemo(() => { const plugin = createMemo(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -26,13 +26,11 @@ import { useEvent } from "./event"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTuiStartup } from "./runtime" import { useTuiStartup } from "./runtime"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useRenderer } from "@opentui/solid" import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, onMount } from "solid-js" import { batch, onMount } from "solid-js"
import path from "path" import path from "path"
import { aggregateFailures } from "./aggregate-failures"
import { useKV } from "./kv" import { useKV } from "./kv"
import { destroyRenderer } from "../util/renderer"
const emptyConsoleState: ConsoleState = { const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [], consoleManagedProviders: [],
@ -424,7 +422,7 @@ export const {
} }
}) })
const renderer = useRenderer() const exit = useExit()
const args = useArgs() const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) { async function bootstrap(input: { fatal?: boolean } = {}) {
@ -442,23 +440,14 @@ export const {
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [ await Promise.all([
{ name: "config.providers", promise: providersPromise }, providersPromise,
{ name: "provider.list", promise: providerListPromise }, providerListPromise,
{ name: "app.agents", promise: agentsPromise }, agentsPromise,
{ name: "config.get", promise: configPromise }, configPromise,
{ name: "project.sync", promise: projectPromise }, projectPromise,
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ...(args.continue ? [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
})
.then(async () => { .then(async () => {
const providersResponse = providersPromise.then((x) => x.data!) const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!)
@ -523,7 +512,7 @@ export const {
stack: e instanceof Error ? e.stack : undefined, stack: e instanceof Error ? e.stack : undefined,
}) })
if (fatal) { if (fatal) {
destroyRenderer(renderer) exit(e)
} else { } else {
throw e throw e
} }

View File

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

View File

@ -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}`
}

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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