Merge branch 'dev' into nxl/background-subagents
This commit is contained in:
commit
b26fe0d357
827 changed files with 30051 additions and 16619 deletions
1
packages/opencode/.gitignore
vendored
1
packages/opencode/.gitignore
vendored
|
|
@ -7,3 +7,4 @@ src/provider/models-snapshot.js
|
|||
src/provider/models-snapshot.d.ts
|
||||
script/build-*.ts
|
||||
temporary-*.md
|
||||
.artifacts
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `session` ADD `path` text;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
52
packages/opencode/scripts/diff-sdk-types.sh
Executable file
52
packages/opencode/scripts/diff-sdk-types.sh
Executable 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
67
packages/opencode/specs/v2/api.ts
Normal file
67
packages/opencode/specs/v2/api.ts
Normal 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))
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal file
11
packages/opencode/src/cli/cmd/debug/startup.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
280
packages/opencode/src/cli/cmd/tui/context/editor-zed.ts
Normal file
280
packages/opencode/src/cli/cmd/tui/context/editor-zed.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { TuiPluginRuntime } from "./runtime"
|
||||
export { createTuiApi } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export function DialogHelp() {
|
|||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return" || evt.name === "escape") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||
return
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -201,3 +201,5 @@ export async function copy(text: string): Promise<void> {
|
|||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
}
|
||||
|
||||
export * as Clipboard from "./clipboard"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue