refactor(tui): centralize application exit (#31524)
This commit is contained in:
parent
960eacebcf
commit
37522185d3
24
.github/workflows/test.yml
vendored
24
.github/workflows/test.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
packages/tui/src/context/exit.tsx
Normal file
8
packages/tui/src/context/exit.tsx
Normal 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,
|
||||||
|
})
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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] : []),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -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"
|
||||||
|
|||||||
19
turbo.json
19
turbo.json
@ -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": ["*"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user