Merge branch 'dev' into nxl/background-subagents

This commit is contained in:
Shoubhit Dash 2026-05-01 15:29:32 +05:30
commit b26fe0d357
827 changed files with 30051 additions and 16619 deletions

View file

@ -7,3 +7,4 @@ src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
.artifacts

View file

@ -78,6 +78,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
- Use `Effect.callback` for callback-based APIs.
- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`.
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Module conventions

View file

@ -0,0 +1 @@
ALTER TABLE `session` ADD `path` text;

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,11 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.23",
"version": "1.14.31",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
@ -42,10 +41,9 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
@ -61,7 +59,6 @@
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@ -69,6 +66,7 @@
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@ -109,22 +107,20 @@
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@ -159,7 +155,7 @@
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"opentui-spinner": "catalog:",
"partial-json": "0.1.7",
"remeda": "catalog:",
"semver": "^7.6.3",

View file

@ -50,6 +50,7 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
@ -199,6 +200,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { z } from "zod"
import { Config } from "../src/config"
import { Config } from "@/config/config"
import { TuiConfig } from "../src/cli/cmd/tui/config/tui"
function generate(schema: z.ZodType) {

View file

@ -0,0 +1,52 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi

View file

@ -1,462 +1,401 @@
# HttpApi migration
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
## Goal
## End State
Use Effect `HttpApi` where it gives us a better typed contract for:
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
- route definition
- request decoding and validation
- typed success and error responses
- OpenAPI generation
- handler composition inside Effect
## Current State
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
## Core model
## Migration Rules
`HttpApi` is definition-first.
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
- Migrate one route group, or one coherent subset of a route group, at a time.
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
- `HttpApi` is the root API
- `HttpApiGroup` groups related endpoints
- `HttpApiEndpoint` defines a single route and its request / response schemas
- handlers are implemented separately from the contract
## Route Slice Checklist
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
Use this checklist for each small HttpApi migration PR:
## Why it is relevant here
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
The current route-effectification work is already pushing handlers toward:
## Hono Deletion Checklist
- one `AppRuntime.runPromise(Effect.gen(...))` body
- yielding services from context
- using typed Effect errors instead of Promise wrappers
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
## What HttpApi gives us
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
### Contracts
## Experimental Read Slice Guidance
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
For the experimental route group, port read-only JSON routes before mutations:
### Validation and decoding
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
- Preserve response headers such as pagination cursors if a route is ported.
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
## Schema Notes
### OpenAPI
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
## Phases
### Typed errors
### 1. Stabilize The Bridge
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
Before porting more routes, cover the bridge behavior that every route depends on.
## Likely fit for opencode
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
- Cover `directory` and `x-opencode-directory` instance selection.
- Verify generated SDK output remains unchanged for non-SDK work.
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
Best fit first:
### 2. Complete The Inventory
- JSON request / response endpoints
- route groups that already mostly delegate into services
- endpoints whose request and response models can be defined with Effect Schema
Create a route inventory from the actual Hono registrations and classify each route.
Harder / later fit:
Statuses:
- SSE endpoints
- websocket endpoints
- streaming handlers
- routes with heavy Hono-specific middleware assumptions
- `bridged`: served through the `HttpApi` bridge when the flag is on.
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
- `next`: good JSON candidate for near-term porting.
- `later`: portable, but needs schema/service cleanup first.
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
## Current blockers and gaps
### 3. Finish JSON Route Parity
### Schema split
Port remaining JSON routes in small batches.
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
Good near-term candidates:
### Mixed handler styles
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
- simple mutations: `POST /instance/dispose`
- experimental JSON reads: console, tool, worktree list, resource list
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
Keep large or stateful groups for later:
### Non-JSON routes
- `session`
- `sync`
- process-level experimental routes
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
### 4. Move OpenAPI And SDK Generation
### Existing Hono integration
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
## Recommended strategy
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
### 1. Finish the prerequisites first
Required before route deletion:
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
### 2. Start with one parallel group
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
### 5. Make HttpApi Default For JSON Routes
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
After JSON parity and SDK generation are covered:
Recommended first slice:
- Flip the bridge default for ported JSON routes.
- Keep a short-lived fallback flag for the old Hono implementation.
- Run the same tests against both the default and fallback path during rollout.
- Stop adding new Hono handlers for JSON routes once the default flips.
- start with `question`
- start with `GET /question`
- start with `POST /question/:requestID/reply`
### 6. Delete Hono Route Implementations
Why `question` first:
Delete Hono routes group-by-group after each group meets the deletion criteria.
- already JSON-only
- already delegates into an Effect service
- proves list + mutation + params + payload + OpenAPI in one small slice
- avoids the harder streaming and middleware cases
Deletion criteria:
### 3. Reuse existing services
- `HttpApi` route is mounted by default.
- Behavior is covered by bridge-level tests.
- OpenAPI/SDK generation comes from Effect for that path.
- SDK diff is zero or explicitly accepted.
- Legacy Hono route is no longer needed as a fallback.
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
After deleting a group:
### 4. Bridge into Hono behind a feature flag
- Remove its Hono route file or dead endpoints.
- Remove its `.route(...)` registration from `instance/index.ts`.
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
- Regenerate SDK and verify output.
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
### 7. Replace Special Routes
- one process, one port — no separate server
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
- Effect middleware handles auth and instance lookup independently from Hono middleware
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
Special routes need explicit designs before Hono can disappear completely.
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
- `event`: SSE
- `pty`: websocket
- `tui`: UI/control bridge behavior
- streaming `session` endpoints
```ts
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
```
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
## Current Route Status
### 5. Observability
| Area | Status | Notes |
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
| `question` | `bridged` | `GET /question`, reply, reject |
| `permission` | `bridged` | list and reply |
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
| `config` | `bridged` | read, providers, update |
| `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
| `sync` | `bridged` | start/replay/history |
| `event` | `bridged` | SSE via raw Effect HTTP |
| `pty` | `special` | websocket |
| `tui` | `special` | UI bridge |
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
## Full Route Checklist
This gives:
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
### Top-Level Instance Routes
### 6. Migrate JSON route groups gradually
As each route group is ported to `HttpApi`:
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Schema rule for HttpApi work
Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
Default rule:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
Practical implication for `HttpApi` migration:
- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
- avoid maintaining parallel Zod and Effect definitions for the same request or response type
Ordering for a route-group migration:
1. move implicated shared `schema.ts` leaf types to Effect Schema first
2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception:
- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
- if that happens, leave a short note so the type does not become a permanent second source of truth
## First vertical slice
The first `HttpApi` spike should be intentionally small and repeatable.
Chosen slice:
- group: `question`
- endpoints: `GET /question` and `POST /question/:requestID/reply`
Non-goals:
- no `session` routes
- no SSE or websocket routes
- no auth redesign
- no broad service refactor
Behavior rule:
- preserve current runtime behavior first
- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
## Repeatable slice template
Use the same sequence for each route group.
1. Pick one JSON-only route group that already mostly delegates into services.
2. Identify the shared DTOs, IDs, and errors implicated by that slice.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
Rule of thumb:
- migrate one route group at a time
- migrate one or two endpoints first, not the whole file
- keep business logic in the existing service
- keep the first spike easy to delete if the experiment is not worth continuing
## Example structure
Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
Each route-group spike should follow the same shape.
### 1. Contract
- define an experimental `HttpApi`
- define one `HttpApiGroup`
- define endpoint params, payload, success, and error schemas from canonical Effect schemas
- annotate summary, description, and operation ids explicitly so generated docs are stable
### 2. Handler layer
- implement with `HttpApiBuilder.group(api, groupName, ...)`
- yield the existing Effect service from context
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Bridged server
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
### Error mapping
- keep domain and service errors typed in the service layer
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
## Exit criteria for the spike
The first slice is successful if:
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings
### Schema
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
## Route inventory
Status legend:
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
- `done` - Effect HttpApi slice exists but not yet bridged
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `bridged`
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoints: `GET /config`, `GET /config/providers`
defer `PATCH /config` for now
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `bridged` (partial)
bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status`
defer search endpoints first
- `mcp` - `bridged` (partial)
bridged endpoints: `GET /mcp`
defer interactive OAuth/auth flows first
- `session` - `defer`
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
- `event` - `defer`
SSE only
- `global` - `defer`
mixed bag with SSE and process-level side effects
- `pty` - `defer`
websocket-heavy route surface
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence:
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `file` JSON read endpoints
3. `mcp` JSON read endpoints
- [x] `POST /instance/dispose` - dispose active instance after response.
- [x] `GET /path` - current directory and worktree paths.
- [x] `GET /vcs` - current VCS status.
- [x] `GET /vcs/diff` - VCS diff summary.
- [x] `GET /command` - command catalog.
- [x] `GET /agent` - agent catalog.
- [x] `GET /skill` - skill catalog.
- [x] `GET /lsp` - LSP status.
- [x] `GET /formatter` - formatter status.
### Config Routes
- [x] `GET /config` - read config.
- [x] `PATCH /config` - update config and dispose active instance after response.
- [x] `GET /config/providers` - config provider summary.
### Project Routes
- [x] `GET /project` - list projects.
- [x] `GET /project/current` - current project.
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
- [x] `PATCH /project/:projectID` - update project metadata.
### Provider Routes
- [x] `GET /provider` - list providers.
- [x] `GET /provider/auth` - list provider auth methods.
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
### Question Routes
- [x] `GET /question` - list questions.
- [x] `POST /question/:requestID/reply` - reply to question.
- [x] `POST /question/:requestID/reject` - reject question.
### Permission Routes
- [x] `GET /permission` - list permission requests.
- [x] `POST /permission/:requestID/reply` - reply to permission request.
### File Routes
- [x] `GET /find` - text search.
- [x] `GET /find/file` - file search.
- [x] `GET /find/symbol` - symbol search.
- [x] `GET /file` - list directory entries.
- [x] `GET /file/content` - read file content.
- [x] `GET /file/status` - file status.
### MCP Routes
- [x] `GET /mcp` - MCP status.
- [x] `POST /mcp` - add MCP server at runtime.
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
- [x] `POST /mcp/:name/connect` - connect MCP server.
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
### Experimental Routes
- [x] `GET /experimental/console` - active Console provider metadata.
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
- [x] `POST /experimental/console/switch` - switch active Console org.
- [x] `GET /experimental/tool/ids` - tool IDs.
- [x] `GET /experimental/tool` - tools for provider/model.
- [x] `GET /experimental/worktree` - list worktrees.
- [x] `POST /experimental/worktree` - create worktree.
- [x] `DELETE /experimental/worktree` - remove worktree.
- [x] `POST /experimental/worktree/reset` - reset worktree.
- [x] `GET /experimental/session` - global session list.
- [x] `GET /experimental/resource` - MCP resources.
### Workspace Routes
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
### Sync Routes
- [x] `POST /sync/start` - start workspace sync.
- [x] `POST /sync/replay` - replay sync events.
- [x] `POST /sync/history` - list sync event history.
### Session Routes
- [x] `GET /session` - list sessions.
- [x] `GET /session/status` - session status map.
- [x] `GET /session/:sessionID` - get session.
- [x] `GET /session/:sessionID/children` - get child sessions.
- [x] `GET /session/:sessionID/todo` - get session todos.
- [x] `POST /session` - create session.
- [x] `DELETE /session/:sessionID` - delete session.
- [x] `PATCH /session/:sessionID` - update session metadata.
- [x] `POST /session/:sessionID/init` - run project init command.
- [x] `POST /session/:sessionID/fork` - fork session.
- [x] `POST /session/:sessionID/abort` - abort session.
- [x] `POST /session/:sessionID/share` - share session.
- [x] `GET /session/:sessionID/diff` - session diff.
- [x] `DELETE /session/:sessionID/share` - unshare session.
- [x] `POST /session/:sessionID/summarize` - summarize session.
- [x] `GET /session/:sessionID/message` - list session messages.
- [x] `GET /session/:sessionID/message/:messageID` - get message.
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
- [x] `POST /session/:sessionID/command` - run command.
- [x] `POST /session/:sessionID/shell` - run shell command.
- [x] `POST /session/:sessionID/revert` - revert message.
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
### Event Routes
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
### PTY Routes
- [x] `GET /pty` - list PTY sessions.
- [x] `POST /pty` - create PTY session.
- [x] `GET /pty/:ptyID` - get PTY session.
- [x] `PUT /pty/:ptyID` - update PTY session.
- [x] `DELETE /pty/:ptyID` - remove PTY session.
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
### TUI Routes
- [x] `POST /tui/append-prompt` - append prompt.
- [x] `POST /tui/open-help` - open help.
- [x] `POST /tui/open-sessions` - open sessions.
- [x] `POST /tui/open-themes` - open themes.
- [x] `POST /tui/open-models` - open models.
- [x] `POST /tui/submit-prompt` - submit prompt.
- [x] `POST /tui/clear-prompt` - clear prompt.
- [x] `POST /tui/execute-command` - execute command.
- [x] `POST /tui/show-toast` - show toast.
- [x] `POST /tui/publish` - publish TUI event.
- [x] `POST /tui/select-session` - select session.
- [x] `GET /tui/control/next` - get next TUI request.
- [x] `POST /tui/control/response` - submit TUI control response.
## Remaining PR Plan
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
1. [x] Bridge `PATCH /project/:projectID`.
2. [x] Bridge MCP add/connect/disconnect routes.
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. [x] Bridge experimental console switch and tool list routes.
5. [x] Bridge experimental global session list.
6. [x] Bridge workspace create/remove/session-restore routes.
7. [x] Bridge sync start/replay/history routes.
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
## Checklist
- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
- [x] use Effect Schema request / response types for that slice
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [x] port `workspace` read endpoints
- [x] port `file` JSON read endpoints
- [x] port `mcp` status read endpoint
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
Do not start with the hardest route file.
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Centralize auth via Effect `Config` for the Effect backend.
- [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads.
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).

View file

@ -286,7 +286,6 @@ emitted JSON Schema must stay byte-identical.
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`

View file

@ -40,7 +40,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
@ -79,7 +78,6 @@ Notable items that are already effectively on the target path and do not need se
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `edit.ts`

View file

@ -0,0 +1,67 @@
// @ts-nocheck
import { OpenCode } from "@opencode-ai/core"
import { ReadTool } from "@opencode-ai/core/tools"
const opencode = OpenCode.make({})
opencode.tool.add(ReadTool)
opencode.tool.add({
name: "bash",
schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The command to run.",
},
},
required: ["command"],
},
execute(input, ctx) {},
})
opencode.auth.add({
provider: "openai",
type: "api",
value: process.env.OPENAI_API_KEY,
})
opencode.agent.add({
name: "build",
permissions: [],
model: {
id: "gpt-5-5",
provider: "openai",
variant: "xhigh",
},
})
const sessionID = await opencode.session.create({
agent: "build",
})
opencode.subscribe((event) => {
console.log(event)
})
await opencode.session.prompt({
sessionID,
text: "hey what is up",
})
await opencode.session.prompt({
sessionID,
text: "what is up with this",
files: [
{
mime: "image/png",
uri: "data:image/png;base64,xxxx",
},
],
})
await opencode.session.wait()
console.log(await opencode.session.messages(sessionID))

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"

View file

@ -31,29 +31,30 @@ import {
type Usage,
} from "@agentclientprotocol/sdk"
import { Log } from "../util"
import * as Log from "@opencode-ai/core/util/log"
import { pathToFileURL } from "url"
import { Filesystem } from "../util"
import { Hash } from "@opencode-ai/shared/util/hash"
import { Filesystem } from "@/util/filesystem"
import { Hash } from "@opencode-ai/core/util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config"
import { Config } from "@/config/config"
import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { Result, Schema } from "effect"
import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { InstallationVersion } from "@/installation/version"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info)))
const DEFAULT_VARIANT_VALUE = "default"
@ -372,14 +373,14 @@ export class Agent implements ACPAgent {
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
const parsedTodos = decodeTodos(part.state.output)
if (Result.isSuccess(parsedTodos)) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
entries: parsedTodos.success.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
@ -394,7 +395,7 @@ export class Agent implements ACPAgent {
log.error("failed to send session update for todo", { error })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
log.error("failed to parse todo output", { error: parsedTodos.failure })
}
}
@ -901,14 +902,14 @@ export class Agent implements ACPAgent {
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info.zod).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
const parsedTodos = decodeTodos(part.state.output)
if (Result.isSuccess(parsedTodos)) {
await this.connection
.sessionUpdate({
sessionId,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
entries: parsedTodos.success.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
@ -923,7 +924,7 @@ export class Agent implements ACPAgent {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
log.error("failed to parse todo output", { error: parsedTodos.failure })
}
}

View file

@ -1,6 +1,6 @@
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import { Log } from "@/util"
import * as Log from "@opencode-ai/core/util/log"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
const log = Log.create({ service: "acp-session-manager" })

View file

@ -1,12 +1,11 @@
import { Config } from "../config"
import { Config } from "@/config/config"
import z from "zod"
import { Provider } from "../provider"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
import { Truncate } from "../tool"
import { Truncate } from "@/tool/truncate"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider"
import { ProviderTransform } from "@/provider/transform"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@ -15,41 +14,41 @@ import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect"
import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@/util/schema"
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
}),
),
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
@ -79,10 +78,14 @@ export const layer = Layer.effect(
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
]
const defaults = Permission.fromConfig({
"*": "allow",
@ -136,7 +139,7 @@ export const layer = Layer.effect(
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
@ -170,7 +173,6 @@ export const layer = Layer.effect(
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",

View file

@ -1,8 +1,9 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
expires: NonNegativeInt,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}

View file

@ -34,4 +34,16 @@ export function payloads() {
.toArray()
}
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event"

View file

@ -1,9 +1,9 @@
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { EffectBridge } from "@/effect/bridge"
import * as Log from "@opencode-ai/core/util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })

View file

@ -1,4 +1,4 @@
import { Log } from "@/util"
import * as Log from "@opencode-ai/core/util/log"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"

View file

@ -2,12 +2,12 @@ import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "../../global"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
import { Provider } from "../../provider"
import { Provider } from "@/provider/provider"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util/filesystem"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
@ -15,7 +15,22 @@ import type { Argv } from "yargs"
type AgentMode = "all" | "primary" | "subagent"
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "glob", "grep", "webfetch", "task", "todowrite"]
// Permission keys (not raw tool names). Multiple tools can map to a single
// permission — e.g. write/edit/apply_patch all gate on `edit` — so we configure
// agents at the permission level to match how the runtime actually enforces it.
const AVAILABLE_PERMISSIONS = [
"bash",
"read",
"edit",
"glob",
"grep",
"webfetch",
"task",
"todowrite",
"websearch",
"lsp",
"skill",
]
const AgentCreateCommand = cmd({
command: "create",
@ -35,9 +50,10 @@ const AgentCreateCommand = cmd({
describe: "agent mode",
choices: ["all", "primary", "subagent"] as const,
})
.option("tools", {
.option("permissions", {
type: "string",
describe: `comma-separated list of tools to enable (default: all). Available: "${AVAILABLE_TOOLS.join(", ")}"`,
alias: ["tools"],
describe: `comma-separated list of permissions to allow (default: all). Available: "${AVAILABLE_PERMISSIONS.join(", ")}"`,
})
.option("model", {
type: "string",
@ -51,9 +67,9 @@ const AgentCreateCommand = cmd({
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const cliTools = args.tools
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && cliTools !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
@ -120,21 +136,21 @@ const AgentCreateCommand = cmd({
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select tools
let selectedTools: string[]
if (cliTools !== undefined) {
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select tools to enable (Space to toggle)",
options: AVAILABLE_TOOLS.map((tool) => ({
label: tool,
value: tool,
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_TOOLS,
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selectedTools = result
selected = result
}
// Get mode
@ -167,11 +183,11 @@ const AgentCreateCommand = cmd({
mode = modeResult
}
// Build tools config
const tools: Record<string, boolean> = {}
for (const tool of AVAILABLE_TOOLS) {
if (!selectedTools.includes(tool)) {
tools[tool] = false
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
@ -179,13 +195,13 @@ const AgentCreateCommand = cmd({
const frontmatter: {
description: string
mode: AgentMode
tools?: Record<string, boolean>
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(tools).length > 0) {
frontmatter.tools = tools
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file

View file

@ -1,11 +1,11 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage"
import { Database } from "@/storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "../../storage"
import { JsonMigration } from "@/storage/json-migration"
import { EOL } from "os"
import { errorMessage } from "../../util/error"

View file

@ -2,11 +2,11 @@ import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider"
import { Session } from "../../../session"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool"
import { ToolRegistry } from "@/tool/registry"
import { Instance } from "../../../project/instance"
import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"

View file

@ -1,5 +1,5 @@
import { EOL } from "os"
import { Config } from "../../../config"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"

View file

@ -1,4 +1,4 @@
import { Global } from "../../../global"
import { Global } from "@opencode-ai/core/global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { ConfigCommand } from "./config"
@ -9,6 +9,7 @@ import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot"
import { AgentCommand } from "./agent"
import { StartupCommand } from "./startup"
export const DebugCommand = cmd({
command: "debug",
@ -22,6 +23,7 @@ export const DebugCommand = cmd({
.command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand)
.command(StartupCommand)
.command(AgentCommand)
.command(PathsCommand)
.command({

View file

@ -1,9 +1,9 @@
import { LSP } from "../../../lsp"
import { LSP } from "@/lsp/lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util"
import * as Log from "@opencode-ai/core/util/log"
import { EOL } from "os"
export const LSPCommand = cmd({

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
import { Project } from "../../../project"
import { Log } from "../../../util"
import { Project } from "@/project/project"
import * as Log from "@opencode-ai/core/util/log"
import { cmd } from "../cmd"
export const ScrapCommand = cmd({

View file

@ -0,0 +1,11 @@
import { EOL } from "os"
import { cmd } from "../cmd"
export const StartupCommand = cmd({
command: "startup",
describe: "print startup timing",
builder: (yargs) => yargs,
handler() {
process.stdout.write(performance.now().toString() + EOL)
},
})

View file

@ -1,5 +1,5 @@
import type { Argv } from "yargs"
import { Session } from "../../session"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { cmd } from "./cmd"
@ -245,10 +245,7 @@ export const ExportCommand = cmd({
output: process.stderr,
})
const sessions = []
for await (const session of Session.list()) {
sessions.push(session)
}
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
if (sessions.length === 0) {
prompts.log.error("No sessions found", {

View file

@ -1,15 +1,26 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
}
export const GenerateCommand = {
command: "generate",
handler: async () => {
const specs = await Server.openapi()
builder: (yargs) =>
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
handler: async (args) => {
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation?.operationId) continue
// @ts-expect-error
operation["x-codeSamples"] = [
{
lang: "js",
@ -47,4 +58,4 @@ export const GenerateCommand = {
})
})
},
} satisfies CommandModule
} satisfies CommandModule<object, Args>

View file

@ -1,6 +1,6 @@
import path from "path"
import { exec } from "child_process"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util/filesystem"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
import { Octokit } from "@octokit/rest"
@ -18,21 +18,21 @@ import type {
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider"
import { ModelsDev } from "@/provider/models"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share"
import { Session } from "../../session"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"
import { Provider } from "../../provider"
import { Provider } from "@/provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util"
import { Process } from "@/util/process"
import { Effect } from "effect"
type GitHubAuthor = {

View file

@ -1,18 +1,21 @@
import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage"
import { Database } from "@/storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
import { Schema } from "effect"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
| { type: "session"; data: SDKSession }
@ -169,7 +172,7 @@ export const ImportCommand = cmd({
)
for (const msg of exportData.messages) {
const msgInfo = MessageV2.Info.zod.parse(msg.info)
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
@ -185,7 +188,7 @@ export const ImportCommand = cmd({
)
for (const part of msg.parts) {
const partInfo = MessageV2.Part.zod.parse(part)
const partInfo = decodePart(part) as MessageV2.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db

View file

@ -7,15 +7,15 @@ import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config"
import { Config } from "@/config/config"
import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "path"
import { Global } from "../../global"
import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"

View file

@ -1,8 +1,8 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "../../provider"
import { ModelsDev } from "@/provider/models"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"

View file

@ -1,14 +1,14 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import type { Argv } from "yargs"
import { ConfigPaths } from "../../config"
import { Global } from "../../global"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { Instance } from "../../project/instance"
import { errorMessage } from "../../util/error"
import { Filesystem } from "../../util"
import { Process } from "../../util"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { UI } from "../ui"
import { cmd } from "./cmd"

View file

@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util"
import { Process } from "@/util/process"
export const PrCommand = cmd({
command: "pr <number>",

View file

@ -3,16 +3,16 @@ import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "../../provider"
import { ModelsDev } from "@/provider/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config"
import { Global } from "../../global"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util"
import { Process } from "@/util/process"
import { text } from "node:stream/consumers"
import { Effect } from "effect"
@ -156,28 +156,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
})
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false

View file

@ -3,29 +3,28 @@ import path from "path"
import { pathToFileURL } from "url"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { Filesystem } from "../../util"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider"
import { Provider } from "@/provider/provider"
import { Agent } from "../../agent/agent"
import { Permission } from "../../permission"
import { Tool } from "../../tool"
import { Tool } from "@/tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util"
import { Locale } from "@/util/locale"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = {
@ -145,13 +144,6 @@ function edit(info: ToolProps<typeof EditTool>) {
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
@ -415,7 +407,6 @@ export const RunCommand = cmd({
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))

View file

@ -1,7 +1,7 @@
import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = cmd({
command: "serve",

View file

@ -1,13 +1,13 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { Session } from "@/session/session"
import { SessionID } from "../../session/schema"
import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util"
import { Process } from "../../util"
import { Locale } from "@/util/locale"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
@ -91,7 +91,9 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
const sessions = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
)
if (sessions.length === 0) {
return

View file

@ -1,10 +1,10 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { Session } from "@/session/session"
import { bootstrap } from "../bootstrap"
import { Database } from "../../storage"
import { Database } from "@/storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project"
import { Project } from "@/project/project"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"

View file

@ -1,7 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@ -17,7 +16,7 @@ import {
on,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@ -30,7 +29,8 @@ import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogModel } from "@tui/component/dialog-model"
import { useConnected } from "@tui/component/use-connected"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
@ -50,16 +50,18 @@ import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
import { Session as SessionApi } from "@/session/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { FormatError, FormatUnknownError } from "@/cli/error"
import type { EventSource } from "./context/sdk"
@ -121,12 +123,6 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const mode = await Terminal.getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() because setRawMode(false)
// restores the original console mode, including processed input on Windows.
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
resolve()
@ -137,6 +133,7 @@ export function tui(input: {
}
const renderer = await createCliRenderer(rendererConfig(input.config))
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
return (
@ -304,6 +301,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
// Update terminal window title based on current route and session
createEffect(() => {
@ -730,6 +730,40 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
value: "app.toggle.file_context",
category: "System",
onSelect: (dialog) => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
dialog.clear()
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",

View file

@ -77,6 +77,8 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}

View file

@ -4,7 +4,7 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"

View file

@ -8,13 +8,7 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
}
import { useConnected } from "./use-connected"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()

View file

@ -14,6 +14,7 @@ import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@ -30,6 +31,7 @@ export function createDialogProviderOptions() {
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
@ -49,7 +51,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected ? <text fg={theme.success}></text> : undefined,
gutter: connected && onboarded() ? () => <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return

View file

@ -42,6 +42,8 @@ export function DialogSessionDeleteFailed(props: {
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
}
if (evt.name === "left" || evt.name === "up") {

View file

@ -3,14 +3,14 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
@ -32,11 +32,14 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
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)
@ -165,7 +168,7 @@ export function DialogSessionList() {
value: x.id,
category,
footer,
gutter: isWorking ? <Spinner /> : undefined,
gutter: isWorking ? () => <Spinner /> : undefined,
}
})
})

View file

@ -1,7 +1,7 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"

View file

@ -6,8 +6,7 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
import * as Log from "@/util/log"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
@ -17,8 +16,6 @@ type Adaptor = {
description: string
}
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@ -37,18 +34,9 @@ export async function openWorkspaceSession(input: {
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
while (true) {
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
@ -56,24 +44,11 @@ export async function openWorkspaceSession(input: {
})
return
}
log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000)
continue
}
if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
@ -85,10 +60,6 @@ export async function openWorkspaceSession(input: {
type: "session",
sessionID: result.data.id,
})
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear()
return
}
@ -104,27 +75,10 @@ export async function restoreWorkspaceSession(input: {
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
.catch(() => undefined)
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
@ -132,33 +86,11 @@ export async function restoreWorkspaceSession(input: {
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
input.toast.show({
message: "Session restored into the new workspace",
@ -230,47 +162,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => {
if (creating()) return
setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({
message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id)
setCreating(undefined)
}

View file

@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { InstallationVersion } from "@/installation/version"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"

View file

@ -1,4 +1,5 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
@ -554,6 +555,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState {
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme()
const renderer = useRenderer()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
@ -684,6 +686,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
})
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
const renderLine = (
line: string,
@ -789,7 +792,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
}
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█") {
if (char === "█" && useSubpixelBlocks()) {
return (
<text
fg={shade(inkTop, theme, n + p + e + b)}

View file

@ -14,7 +14,7 @@ import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -3,7 +3,7 @@ import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, S
import "opentui-spinner/solid"
import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@ -29,7 +29,7 @@ import * as Clipboard from "../../util/clipboard"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog"
@ -84,6 +84,32 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
return (
selection.selection.start.line !== selection.selection.end.line ||
selection.selection.start.character !== selection.selection.end.character
)
}
function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
if (!hasEditorRangeSelection(selection)) return
if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
return `#${selection.selection.start.line}-${selection.selection.end.line}`
}
function formatEditorContext(selection: EditorSelection) {
const selected = selection.ranges.filter(hasEditorRangeSelection)
if (selected.length === 0)
return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
const ranges = selected.map((range, index) => {
const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
})
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
@ -112,13 +138,22 @@ export function Prompt(props: PromptProps) {
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const editorPath = createMemo(() => editor.selection()?.filePath)
const editorSelectionLabel = createMemo(() => {
const selection = editor.selection()?.selection
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
const editorContext = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection() : undefined
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
})
const editorPath = createMemo(() => editorContext()?.filePath)
const editorSelectionLabel = createMemo(() => {
const ranges = editorContext()?.ranges
if (!ranges) return
const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
if (!first) return
return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
.filter(Boolean)
.join(" ")
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
@ -134,6 +169,8 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@ -149,6 +186,11 @@ export function Prompt(props: PromptProps) {
}
}
function dismissEditorContext() {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
@ -278,6 +320,16 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
@ -746,27 +798,25 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editor.selection()
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
if (start.line === end.line && start.character === end.character) {
return `Note: The user opened the file "${editorSelection.filePath}".`
}
if (start.line === end.line) {
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
}
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
})(),
synthetic: true,
},
]
: []
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorParts =
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: formatEditorContext(editorSelection),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
ranges: editorSelection.ranges,
},
},
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@ -829,6 +879,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
}
history.append({
...store.prompt,
@ -1197,7 +1248,7 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
@ -1379,7 +1430,18 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Show when={editorFileLabelDisplay()}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>

View file

@ -1,6 +1,6 @@
import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"

View file

@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util"
import { Keybind } from "@/util/keybind"
const TEXTAREA_ACTIONS = [
"submit",

View file

@ -0,0 +1,9 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
}

View file

@ -3,9 +3,10 @@ import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJ
import { unique } from "remeda"
import z from "zod"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem, Log } from "@/util"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })

View file

@ -7,18 +7,19 @@ import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
import { Npm } from "@opencode-ai/core/npm"
const log = Log.create({ service: "tui.config" })

View file

@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync"
import { Global } from "@/global"
import { Global } from "@opencode-ai/core/global"
export function useDirectory() {
const project = useProject()

View file

@ -0,0 +1,280 @@
import { Database } from "bun:sqlite"
import os from "node:os"
import path from "node:path"
import z from "zod"
import { Filesystem } from "@/util/filesystem"
import type { EditorSelection } from "./editor"
const ZedEditorRowSchema = z.object({
item_kind: z.string(),
editor_id: z.number().nullable(),
workspace_id: z.number(),
workspace_paths: z.string().nullable(),
timestamp: z.string(),
buffer_path: z.string().nullable(),
})
const ZedSelectionRowSchema = z.object({
selection_start: z.number().nullable(),
selection_end: z.number().nullable(),
})
const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
})
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
| { type: "empty" }
| { type: "unavailable" }
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
const active = queryZedActiveEditor(dbPath, cwd)
if (active.type !== "row") return active
const row = active.row
if (!row.buffer_path) return { type: "empty" }
const selections = queryZedEditorSelections(dbPath, row)
if (selections.type !== "selections") return selections
const byteRanges = selections.selections
.flatMap((selection) => {
if (selection.selection_start == null || selection.selection_end == null) return []
return [
{
start: Math.min(selection.selection_start, selection.selection_end),
end: Math.max(selection.selection_start, selection.selection_end),
},
]
})
.sort((left, right) => left.start - right.start || left.end - right.end)
if (byteRanges.length === 0) return { type: "unavailable" }
const contents = queryZedEditorContents(dbPath, row)
const text =
contents.type === "contents" && contents.contents != null
? contents.contents
: await Bun.file(row.buffer_path)
.text()
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const ranges = byteRanges.map((range) => {
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
return {
text: text.slice(startOffset, endOffset),
selection: offsetsToSelection(text, startOffset, endOffset),
}
})
return {
type: "selection",
selection: {
filePath: row.buffer_path,
source: "zed",
ranges,
},
}
}
function queryZedActiveEditor(dbPath: string, cwd: string) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
i.kind as item_kind,
e.item_id as editor_id,
i.workspace_id as workspace_id,
w.paths as workspace_paths,
w.timestamp as timestamp,
e.buffer_path as buffer_path
from items i
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
.all()
const rows = raw.flatMap((row) => {
const parsed = ZedEditorRowSchema.safeParse(row)
return parsed.success ? [parsed.data] : []
})
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
const row = rows
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
if (!row) return { type: "empty" as const }
if (row.item_kind !== "Editor") return { type: "unavailable" as const }
if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
return { type: "row" as const, row }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection)
return parsed.success ? [parsed.data] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const parsed = ZedEditorContentsSchema.safeParse(
db
.query(
`select contents
from editors
where item_id = $editorID and workspace_id = $workspaceID`,
)
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
)
if (!parsed.success) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.data.contents }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
return row.item_kind === "Editor" && row.editor_id != null
}
export function resolveZedDbPath() {
const candidates = [
process.env.OPENCODE_ZED_DB,
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return candidates.find((item) => isFile(item))
}
function isFile(item: string) {
try {
return Filesystem.stat(item)?.isFile() === true
} catch {
return false
}
}
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
return score
}, 0)
}
function zedWorkspacePaths(value: string | null) {
if (!value) return []
const parsed = parseJson(value)
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
return value.split(/\r?\n/).filter(Boolean)
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
const start = Math.max(0, Math.min(startOffset, text.length))
const end = Math.max(0, Math.min(endOffset, text.length))
let line = 1
let lineStart = 0
let startPosition = position(line, lineStart, start)
let endPosition = position(line, lineStart, end)
for (let index = 0; index <= end; index++) {
if (index === start) startPosition = position(line, lineStart, index)
if (index === end) {
endPosition = position(line, lineStart, index)
break
}
if (text[index] === "\n") {
line += 1
lineStart = index + 1
}
}
return { start: startPosition, end: endPosition }
}
function position(line: number, lineStart: number, offset: number) {
return {
line,
character: offset - lineStart + 1,
}
}
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function parseJson(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return
}
}

View file

@ -4,7 +4,9 @@ import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
import { isRecord } from "@/util/record"
import { createSimpleContext } from "./helper"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
const MCP_PROTOCOL_VERSION = "2025-11-25"
@ -26,15 +28,46 @@ const PositionSchema = z.object({
character: z.number(),
})
const EditorSelectionSchema = z.object({
const EditorSelectionRangeSchema = z.object({
text: z.string(),
filePath: z.string(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionSchema = z
.union([
z.object({
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
@ -72,8 +105,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@ -84,108 +118,161 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
const pending = new Map<number, string>()
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const scheduleReconnect = (delay: number) => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, delay)
}
const connect = () => {
if (closed) return
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
const connection = resolveEditorConnection(directory)
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect(1000)
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
scheduleReconnect()
})
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", selection.data)
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 30000)
scheduleReconnect(delay)
})
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
scheduleReconnect(0)
onMount(() => {
connect()
onCleanup(() => {
closed = true
@ -196,7 +283,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(resolveEditorConnection())
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@ -204,6 +291,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
selection() {
return store.selection
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
return () => mentionListeners.delete(listener)
@ -211,6 +302,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
},
})
@ -223,8 +318,16 @@ function parsePort(value: string | undefined) {
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
function resolveEditorConnection(directory: string): EditorConnection | undefined {
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
const lock = resolveEditorLockFile(directory)
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@ -232,16 +335,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
function resolveEditorLockFile(activeDirectory: string) {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@ -251,13 +347,16 @@ function resolveEditorLockFile() {
return
}
const cwd = process.cwd()
// longest workspace folder that contains the active session directory; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
.filter((entry): entry is EditorLockFile => Boolean(entry))
.sort((left, right) => scoreEditorLock(right, cwd) - scoreEditorLock(left, cwd))
.filter((entry) => bestMatchLength(entry) > 0)
// prefer locks with longer matching workspace folders, then more recent ones
.sort((left, right) => bestMatchLength(right) - bestMatchLength(left) || right.mtimeMs - left.mtimeMs)
return locks[0]
}
@ -284,20 +383,30 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}
function scoreEditorLock(lock: EditorLockFile, cwd: string) {
const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs
export function editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
selection.filePath,
...selection.ranges.flatMap((range) => [
range.selection.start.line,
range.selection.start.character,
range.selection.end.line,
range.selection.end.character,
range.text,
]),
].join("\0")
}
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
function pathContainsLength(parent: string, child: string) {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, {
return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
@ -313,7 +422,3 @@ function parseMessage(value: unknown) {
return
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -1,5 +1,5 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"

View file

@ -1,6 +1,6 @@
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { rename, rm } from "fs/promises"
import { createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"

View file

@ -5,13 +5,13 @@ import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "@/global"
import { Global } from "@opencode-ai/core/global"
import { iife } from "@/util/iife"
import { useToast } from "../ui/toast"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util"
import { Filesystem } from "@/util/filesystem"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")

View file

@ -2,7 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {

View file

@ -22,14 +22,16 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/shared/util/binary"
import { Binary } from "@opencode-ai/core/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util"
import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@ -107,10 +109,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@ -374,7 +391,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {

View file

@ -2,7 +2,7 @@ import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentu
import path from "path"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Glob } from "@opencode-ai/core/util/glob"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
@ -39,8 +39,8 @@ import carbonfox from "./theme/carbonfox.json" with { type: "json" }
import { useKV } from "./kv"
import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
import { isRecord } from "@/util/record"
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
@ -314,7 +314,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const mode = lock ?? props.mode
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
if (!lock && pick(kv.get("theme_mode")) !== undefined) {
kv.set("theme_mode", undefined)
}
@ -500,7 +500,8 @@ async function getCustomThemes() {
symlink: true,
})) {
const name = path.basename(item, ".json")
result[name] = await Filesystem.readJson(item)
const theme = await Filesystem.readJson(item)
if (isTheme(theme)) result[name] = theme
}
}
return result

View file

@ -1,6 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
export const TuiEvent = {
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
@ -36,7 +39,9 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.optional(Schema.Number).annotate({ description: "Duration in milliseconds" }),
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
),
SessionSelect: BusEvent.define(

View file

@ -1,6 +1,6 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Global } from "@/global"
import { Global } from "@opencode-ai/core/global"
const id = "internal:home-footer"

View file

@ -1,4 +1,4 @@
import { For } from "solid-js"
import { createMemo, For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
const themeCount = Object.keys(DEFAULT_THEMES).length
@ -30,9 +30,12 @@ function parse(tip: string): TipPart[] {
return parts
}
export function Tips() {
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
export function Tips(props: { connected?: boolean }) {
const theme = useTheme().theme
const parts = parse(TIPS[Math.floor(Math.random() * TIPS.length)])
const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)]
const parts = createMemo(() => parse(props.connected === false ? NO_MODELS_TIP : randomTip))
return (
<box flexDirection="row" maxWidth="100%">
@ -40,7 +43,7 @@ export function Tips() {
Tip{" "}
</text>
<text flexShrink={1}>
<For each={parts}>
<For each={parts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>

View file

@ -4,11 +4,11 @@ import { Tips } from "./tips-view"
const id = "internal:home-tips"
function View(props: { show: boolean }) {
function View(props: { show: boolean; connected: boolean }) {
return (
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
<Tips />
<Tips connected={props.connected} />
</Show>
</box>
)
@ -35,8 +35,13 @@ const tui: TuiPlugin = async (api) => {
home_bottom() {
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
const first = createMemo(() => api.state.session.count() === 0)
const show = createMemo(() => !first() && !hidden())
return <View show={show()} />
const connected = createMemo(() =>
api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View show={show()} connected={connected()} />
},
},
})

View file

@ -1,6 +1,6 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"
import { Global } from "@opencode-ai/core/global"
const id = "internal:sidebar-footer"

View file

@ -1,4 +1,4 @@
import { Keybind } from "@/util"
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"

View file

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

View file

@ -18,7 +18,7 @@ import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dia
import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@/installation/version"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
type RouteEntry = {
key: symbol

View file

@ -1,3 +0,0 @@
export { TuiPluginRuntime } from "./runtime"
export { createTuiApi } from "./api"
export type { RouteMap } from "./api"

View file

@ -13,7 +13,7 @@ import {
import path from "path"
import { fileURLToPath } from "url"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
@ -29,11 +29,11 @@ import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Process } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import { Flag } from "@/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flock } from "@opencode-ai/core/util/flock"
import { Flag } from "@opencode-ai/core/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"

View file

@ -8,7 +8,7 @@ import { useArgs } from "../context/args"
import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
let once = false
const placeholder = {

View file

@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog, type DialogContext } from "../../ui/dialog"

View file

@ -2,7 +2,7 @@ import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"

View file

@ -2,7 +2,7 @@ import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/dialog-model"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"

View file

@ -33,8 +33,8 @@ import type {
ReasoningPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util"
import type { Tool } from "@/tool"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash"
@ -44,13 +44,13 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
@ -64,7 +64,7 @@ import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar"
import { SubagentFooter } from "./subagent-footer.tsx"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import * as Clipboard from "../../util/clipboard"
@ -75,8 +75,8 @@ import * as Editor from "../../util/editor"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
@ -85,7 +85,7 @@ import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
@ -180,6 +180,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => {
const sessionID = route.sessionID
@ -207,6 +208,7 @@ export function Session() {
await sync.bootstrap({ fatal: false })
} catch {}
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@ -1565,9 +1567,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
@ -1948,15 +1947,6 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
)
}
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const metadata = props.metadata as { results?: number }
return (
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool>
)
}
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number }
return (

View file

@ -12,9 +12,9 @@ import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util"
import { Locale } from "@/util"
import { Global } from "@/global"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
@ -350,21 +350,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined

View file

@ -3,8 +3,8 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { getScrollAcceleration } from "../../util/scroll"

View file

@ -6,7 +6,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
import { useTerminalDimensions } from "@opentui/solid"
export function SubagentFooter() {

View file

@ -1,21 +1,26 @@
import { cmd } from "@/cli/cmd/cmd"
import { tui } from "./app"
import { Rpc } from "@/util"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util"
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
import {
OPENCODE_PROCESS_ROLE,
OPENCODE_RUN_ID,
ensureRunID,
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
declare global {
@ -66,6 +71,12 @@ async function input(value?: string) {
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@ -119,10 +130,7 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: Filesystem.resolve(process.cwd())
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)

View file

@ -15,6 +15,8 @@ export function DialogAlert(props: DialogAlertProps) {
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.()
dialog.clear()
}

View file

@ -4,7 +4,7 @@ import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util"
import { Locale } from "@/util/locale"
export type DialogConfirmProps = {
title: string
@ -25,6 +25,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()

View file

@ -35,6 +35,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,

View file

@ -11,6 +11,8 @@ export function DialogHelp() {
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
evt.preventDefault()
evt.stopPropagation()
dialog.clear()
}
})

View file

@ -29,6 +29,8 @@ export function DialogPrompt(props: DialogPromptProps) {
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.(textarea.plainText)
}
})

View file

@ -8,8 +8,8 @@ import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util"
import { Locale } from "@/util"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
@ -42,7 +42,7 @@ export interface DialogSelectOption<T = any> {
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
gutter?: () => JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
@ -407,7 +407,7 @@ function Option(props: {
active?: boolean
current?: boolean
footer?: JSX.Element | string
gutter?: JSX.Element
gutter?: () => JSX.Element
onMouseOver?: () => void
}) {
const { theme } = useTheme()
@ -422,7 +422,7 @@ function Option(props: {
</Show>
<Show when={!props.current && props.gutter}>
<box flexShrink={0} marginRight={0}>
{props.gutter}
{props.gutter?.()}
</box>
</Show>
<text

View file

@ -4,7 +4,7 @@ import { useTheme } from "@tui/context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Selection from "@tui/util/selection"
export function Dialog(

View file

@ -5,10 +5,13 @@ import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "../component/border"
import { TextAttributes } from "@opentui/core"
import { Schema } from "effect"
import { type TuiEvent } from "../event"
import { TuiEvent } from "../event"
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.properties>
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
@ -55,13 +58,13 @@ function init() {
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastOptions) {
const { duration, ...currentToast } = options
setStore("currentToast", currentToast)
show(options: ToastInput) {
const toastOptions = decodeToastOptions(options)
setStore("currentToast", toastOptions)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration).unref()
}, toastOptions.duration).unref()
},
error: (err: any) => {
if (err instanceof Error)

View file

@ -201,3 +201,5 @@ export async function copy(text: string): Promise<void> {
const method = await getCopyMethod()
await method(text)
}
export * as Clipboard from "./clipboard"

View file

@ -3,8 +3,8 @@ import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util"
import { Process } from "@/util"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
@ -33,3 +33,5 @@ export async function open(opts: { value: string; renderer: CliRenderer }): Prom
opts.renderer.requestRender()
}
}
export * as Editor from "./editor"

View file

@ -1,5 +0,0 @@
export * as Editor from "./editor"
export * as Selection from "./selection"
export * as Sound from "./sound"
export * as Terminal from "./terminal"
export * as Clipboard from "./clipboard"

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