From 8555de81895845eb8572d4e4640f863f77059027 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 5 May 2026 21:33:47 -0400 Subject: [PATCH 001/812] Type session not-found errors (#25818) --- packages/opencode/specs/effect/errors.md | 329 ++++++++++++++++++ packages/opencode/src/cli/cmd/session.ts | 7 +- .../opencode/src/control-plane/workspace.ts | 17 +- .../server/routes/instance/httpapi/AGENTS.md | 2 + .../server/routes/instance/httpapi/errors.ts | 18 + .../routes/instance/httpapi/groups/pty.ts | 9 +- .../routes/instance/httpapi/groups/session.ts | 18 +- .../routes/instance/httpapi/groups/tui.ts | 3 +- .../routes/instance/httpapi/handlers/pty.ts | 7 +- .../httpapi/handlers/session-errors.ts | 9 + .../instance/httpapi/handlers/session.ts | 101 +++--- .../routes/instance/httpapi/handlers/tui.ts | 13 +- .../httpapi/middleware/workspace-routing.ts | 6 +- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/src/session/revert.ts | 14 +- packages/opencode/src/session/session.ts | 15 +- .../test/control-plane/workspace.test.ts | 4 +- .../test/server/httpapi-parity.test.ts | 23 +- .../opencode/test/server/httpapi-pty.test.ts | 18 +- .../opencode/test/server/httpapi-sdk.test.ts | 69 +++- .../test/server/httpapi-session.test.ts | 50 ++- .../opencode/test/server/httpapi-tui.test.ts | 15 +- 22 files changed, 623 insertions(+), 132 deletions(-) create mode 100644 packages/opencode/specs/effect/errors.md create mode 100644 packages/opencode/src/server/routes/instance/httpapi/errors.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md new file mode 100644 index 0000000000..746e658693 --- /dev/null +++ b/packages/opencode/specs/effect/errors.md @@ -0,0 +1,329 @@ +# Typed error migration + +Plan for moving `packages/opencode` from temporary defect/`NamedError` +compatibility toward typed Effect service errors and explicit HTTP error +contracts. + +## Goal + +- Expected service failures live on the Effect error channel. +- Service interfaces expose those failures in their return types. +- Domain errors are authored with Effect Schema so they are reusable by services, + tests, HTTP routes, tools, and OpenAPI generation. +- HTTP status codes and wire compatibility are handled at the HTTP boundary, not + inside service modules. +- `Effect.die`, `throw`, `catchDefect`, and global cause inspection are reserved + for defects, compatibility bridges, or final fallback behavior. + +## Current State + +- Many migrated services use Effect internally, but expected failures are still a + mix of `NamedError.create(...)`, `namedSchemaError(...)`, `class extends Error`, + `throw`, and `Effect.die(...)`. +- Some services already use `Schema.TaggedErrorClass`, for example `Account`, + `Auth`, `Permission`, `Question`, `Installation`, and parts of + `Workspace`. +- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a + few name-based cases, then emits the legacy `{ name, data }` JSON body. +- Effect `HttpApi` only knows how to encode errors that are declared on the + endpoint, group, or middleware. Undeclared expected errors become defects and + eventually fall through to generic HTTP handling. +- The temporary HttpApi error middleware catches defect-wrapped legacy errors to + preserve runtime behavior, but it is intentionally a bridge rather than the + final model. + +## End State + +Service modules own domain failures. + +```ts +export class SessionBusyError extends Schema.TaggedErrorClass()("SessionBusyError", { + sessionID: SessionID, + message: Schema.String, +}) {} + +export type Error = Storage.Error | SessionBusyError + +export interface Interface { + readonly get: (id: SessionID) => Effect.Effect +} +``` + +HTTP modules own transport mapping. + +```ts +const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { + return yield* session + .get(ctx.params.sessionID) + .pipe( + Effect.catchTag("StorageNotFoundError", () => new SessionNotFoundHttpError({ sessionID: ctx.params.sessionID })), + ) +}) +``` + +HTTP-visible error schemas carry their own response status through Effect +HttpApi's `httpApiStatus` annotation. Prefer `HttpApiSchema.status(...)`, or the +equivalent declaration annotation, instead of maintaining a parallel status map. + +```ts +export class SessionNotFoundHttpError extends Schema.TaggedErrorClass()( + "SessionNotFoundHttpError", + { + sessionID: SessionID, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} +``` + +Endpoint definitions still declare which HTTP-visible error schemas can be +emitted. The status annotation is only used if the error is part of the endpoint, +group, or middleware error schema and the handler fails with that error on the +typed error channel. + +```ts +HttpApiEndpoint.get("get", SessionPaths.get, { + success: Session.Info, + error: [SessionNotFoundHttpError, SessionBusyHttpError], +}) +``` + +The service error and HTTP error may be the same class when the wire shape is a +deliberate public contract. They should be different classes when the service +error contains internals, low-level causes, retry hints, or anything that should +not be exposed to API clients. + +## Rules + +- Use `Schema.TaggedErrorClass` for new expected domain errors. +- Include `cause: Schema.optional(Schema.Defect)` only when preserving an + underlying unknown failure is useful for logs or callers. +- Export a domain-level error union from each service module, for example + `export type Error = NotFoundError | BusyError | Storage.Error`. +- Put expected errors in service method signatures, for example + `Effect.Effect`. +- Use `yield* new DomainError(...)` for direct early failures inside + `Effect.gen` / `Effect.fn`. +- Use `Effect.try({ try, catch })`, `Effect.mapError`, or `Effect.catchTag` to + convert external exceptions into domain errors. +- Use `HttpApiSchema.status(...)` or `{ httpApiStatus: code }` on HTTP-visible + error schemas so Effect `HttpApiBuilder` and OpenAPI generation get the status + from the schema itself. +- Do not use `Effect.die(...)` for user, IO, validation, missing-resource, auth, + provider, worktree, or busy-state failures. +- Do not use `catchDefect` to recover expected domain errors. If recovery is + needed, the upstream effect should fail with a typed error instead. +- Do not make service modules import `HttpApiError`, `HttpServerResponse`, HTTP + status codes, or route-specific error schemas. +- Keep raw `HttpRouter` routes free to use `HttpServerRespondable` when that is + the right transport abstraction, but prefer declared `HttpApi` errors for + normal JSON API endpoints. + +## HTTP Boundary Shape + +Create an HttpApi-local error module, likely +`src/server/routes/instance/httpapi/errors.ts`. + +That module should provide: + +- Legacy-compatible public schemas for `{ name, data }` error bodies that must + remain SDK-compatible during the Hono migration. +- Small constructors or mapping helpers for common API errors such as not found, + bad request, conflict, and unknown internal errors. +- Route-group-specific adapters only when they encode domain-specific public + data. +- A single place to document which public error shape is legacy-compatible and + which shape is new Effect-native API surface. + +Avoid one giant `unknown -> status` mapper. Prefer small, explicit mappers close +to the handler or route group. + +```ts +const mapSessionError = (effect: Effect.Effect) => + effect.pipe( + Effect.catchTag("StorageNotFoundError", (error) => new SessionNotFoundHttpError({ message: error.message })), + Effect.catchTag("SessionBusyError", (error) => new SessionBusyHttpError({ message: error.message })), + ) +``` + +Use built-in `HttpApiError.BadRequest`, `HttpApiError.NotFound`, and related +types only when their generated response body and SDK surface are intentionally +acceptable. Use a custom schema-backed error when clients need the legacy +`{ name, data }` body or a domain-specific error payload. + +## Migration Phases + +### 1. Stabilize The Bridge + +Keep the temporary HttpApi error middleware only as a compatibility bridge while +typed errors are introduced. + +- Add tests that prove the bridge catches legacy `NamedError` defects. +- Add tests that prove declared HttpApi errors still use the declared endpoint + contract. +- Stop returning stack traces in unknown HTTP `500` responses; log the full + `Cause.pretty(cause)` server-side instead. +- Add a comment or TODO that names this plan and states the bridge must shrink + as route groups migrate. + +### 2. Define The Shared HTTP Error Helpers + +Add the `httpapi/errors.ts` module before converting route groups. + +- Define a legacy `{ name, data }` body helper for SDK-compatible errors. +- Define `UnknownError` for generic internal failures with a safe public message. +- Define `BadRequestError` and `NotFoundError` equivalents only if the actual + wire body must match the legacy Hono SDK surface. +- Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or + `{ httpApiStatus: code }`; do not keep a separate name-to-status table. +- Keep conversion helpers pure and small. They should not inspect `Cause` or + accept `unknown` unless they are final fallback helpers. + +### 3. Convert One Vertical Slice + +Start with session read routes because they already have local `mapNotFound` +logic and are heavily covered by existing HttpApi tests. + +- Convert `Session.BusyError` from a plain `Error` to a typed service error, or + add a typed wrapper while preserving the old constructor until callers are + migrated. +- Replace `catchDefect` in `httpapi/handlers/session.ts` with typed error + mapping. +- Add endpoint error schemas for the affected session endpoints. +- Prove behavior with focused tests in `test/server/httpapi-session.test.ts`. +- Remove the migrated cases from the global compatibility middleware. + +### 4. Convert Legacy NamedError Domains + +Move legacy `NamedError.create(...)` services to Effect Schema-backed errors in +small domain PRs. + +Priority order: + +1. `storage/storage.ts` and `storage/db.ts` not-found errors. +2. `worktree/index.ts` `Worktree*` errors. +3. `provider/auth.ts` validation failures and `provider/provider.ts` model-not-found errors. +4. `mcp/index.ts`, `skill/index.ts`, `lsp/client.ts`, and `ide/index.ts` service errors. +5. Config and CLI-only errors after HTTP-facing domains are stable. + +For each domain: + +- Replace `NamedError.create(...)` with `Schema.TaggedErrorClass` when the error + is primarily a service error. +- Keep or add a separate HTTP error schema when the legacy `{ name, data }` wire + shape must remain stable. +- Update service interface return types to include the new error union. +- Replace `throw new X(...)` inside `Effect.fn` with `yield* new X(...)`. +- Replace async exceptions with `Effect.try({ catch })` or explicit `mapError`. +- Add service-level tests that assert the error tag and data, not just the HTTP + status. + +### 5. Declare HttpApi Errors Group By Group + +For each HttpApi group: + +- Inventory every service call and the typed errors it can return. +- Add only the public error schemas that endpoint can actually emit. +- Map service errors to HTTP errors in the handler file. +- Keep built-in `HttpApiError` only for generic request/validation failures where + the generated contract is accepted. +- Update `httpapi/public.ts` compatibility transforms only when the generated + spec cannot represent the desired source shape directly. +- Regenerate the SDK after OpenAPI-visible changes and verify the diff is + intentional. + +Suggested route order: + +1. `session` not-found and busy-state reads. +2. `experimental` worktree mutations. +3. `provider` auth and model selection errors. +4. `mcp` OAuth and connection errors. +5. Remaining route groups as Hono deletion work progresses. + +### 6. Remove Defect Recovery + +After enough route groups declare their expected errors: + +- Delete `catchDefect` recovery for domain errors. +- Delete name-prefix checks such as `error.name.startsWith("Worktree")` from + HTTP middleware. +- Delete `NamedError` branches from the Effect HttpApi compatibility middleware + once no Effect route depends on them. +- Leave one final unknown-defect fallback that logs server-side and returns a + safe generic `500` body. + +## Inventory Checklist + +Use this checklist when touching a service or route group. + +- [ ] Does the service interface expose every expected failure in the Effect + error type? +- [ ] Are user-caused, provider-caused, IO, auth, missing-resource, and busy-state + failures modeled as typed errors instead of defects? +- [ ] Does the service avoid importing HTTP status, `HttpApiError`, or response + classes? +- [ ] Does the handler map each service error into a declared endpoint error? +- [ ] Does the endpoint `error` field include every public error the handler can + emit? +- [ ] Does OpenAPI/SDK output either stay byte-identical or have an explicitly + reviewed diff? +- [ ] Do tests cover both service-level error typing and HTTP-level status/body? +- [ ] Did the PR remove any now-unneeded case from the temporary compatibility + middleware? + +## Testing Requirements + +For service conversions: + +- Test the service method directly with `testEffect(...)`. +- Assert on `_tag` or class identity and the structured fields. +- Avoid testing by string-matching `Cause.pretty(...)`. + +For HttpApi conversions: + +- Add or update the focused `test/server/httpapi-*.test.ts` file. +- Assert status code, content type, and exact JSON body for declared public + errors. +- Add a regression test that the temporary middleware is no longer needed for the + migrated route. +- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is + deleted or the SDK contract intentionally changes. + +## Verification Commands + +Run from `packages/opencode` unless noted otherwise. + +```bash +bun run prettier --write +bunx oxlint +bun typecheck +bun run test -- test/server/httpapi-session.test.ts +``` + +Run SDK generation from the repo root when schemas or OpenAPI-visible errors +change. + +```bash +./packages/sdk/js/script/build.ts +``` + +## Open Questions + +- Should legacy V1 routes keep `{ name, data }` forever while V2 routes expose a + more Effect-native tagged error body? +- Should storage not-found remain generic, or should callers map it to + domain-specific not-found errors before crossing service boundaries? +- Should `namedSchemaError(...)` stay as a long-term public-wire helper, or only + as a migration bridge for old `NamedError` contracts? +- Which SDK version boundary lets us stop remapping built-in Effect HttpApi error + schemas in `httpapi/public.ts`? + +## Success Criteria + +- New service code no longer uses `die` for expected failures. +- A route reviewer can read an endpoint definition and see every public error it + can return. +- The temporary HttpApi error middleware shrinks over time instead of gaining new + name-based cases. +- Service tests prove domain error types without going through HTTP. +- HTTP tests prove status/body contracts without relying on defect recovery. diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 08c0df929c..33f1e78ac0 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -9,6 +9,7 @@ import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" +import { NotFoundError } from "@/storage/storage" import { EOL } from "os" import path from "path" import { which } from "../../util/which" @@ -59,9 +60,9 @@ export const SessionDeleteCommand = effectCmd({ handler: Effect.fn("Cli.session.delete")(function* (args) { const svc = yield* Session.Service const sessionID = SessionID.make(args.sessionID) - // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. - yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) - yield* svc.remove(sessionID) + yield* svc.remove(sessionID).pipe( + Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)), + ) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index fe651fe3e3..24ca0e61bf 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -24,11 +24,12 @@ import { Session } from "@/session/session" import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { NotFoundError } from "@/storage/storage" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" export const Info = WorkspaceInfoSchema @@ -739,9 +740,19 @@ export const layer = Layer.effect( const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) { const sessions = yield* db((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + db + .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, id)) + .all(), + ) + const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) + yield* Effect.forEach( + sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), + (sessionInfo) => + session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)), + { discard: true }, ) - yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true }) const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return diff --git a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md index 757d7aed0c..a6ccf794dd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md +++ b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md @@ -32,4 +32,6 @@ Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally re Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction. +Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary. + When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled. diff --git a/packages/opencode/src/server/routes/instance/httpapi/errors.ts b/packages/opencode/src/server/routes/instance/httpapi/errors.ts new file mode 100644 index 0000000000..e5df6f5abf --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/errors.ts @@ -0,0 +1,18 @@ +import { Schema } from "effect" + +export class ApiNotFoundError extends Schema.ErrorClass("NotFoundError")( + { + name: Schema.Literal("NotFoundError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 404 }, +) {} + +export function notFound(message: string) { + return new ApiNotFoundError({ + name: "NotFoundError", + data: { message }, + }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 3304ab9fbf..ad513e0ad4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -6,6 +6,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" @@ -64,7 +65,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, success: described(Pty.Info, "Session info"), - error: HttpApiError.NotFound, + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.get", @@ -76,7 +77,7 @@ export const PtyApi = HttpApi.make("pty") params: { ptyID: PtyID }, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "pty.update", @@ -87,7 +88,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, success: described(Schema.Boolean, "Session removed"), - error: HttpApiError.NotFound, + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "pty.remove", @@ -98,7 +99,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), - error: [HttpApiError.Forbidden, HttpApiError.NotFound], + error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connectToken", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 77d064ff5a..1159c88030 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -15,6 +15,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/session" @@ -123,7 +124,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, success: described(Session.Info, "Get session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.get", @@ -168,7 +169,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, query: MessagesQuery, success: described(Schema.Array(MessageV2.WithParts), "List of messages"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.messages", @@ -179,7 +180,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, success: described(MessageV2.WithParts, "Message"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.message", @@ -201,7 +202,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, success: described(Schema.Boolean, "Successfully deleted session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.delete", @@ -213,7 +214,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.update", @@ -225,6 +226,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: ForkPayload, success: described(Session.Info, "200"), + error: ApiNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", @@ -259,7 +261,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully shared session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -270,7 +272,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully unshared session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", @@ -282,7 +284,7 @@ export const SessionApi = HttpApi.make("session") params: { sessionID: SessionID }, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.summarize", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index efe73d95d1..8ab43f6654 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,6 +4,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/tui" @@ -155,7 +156,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), - error: [HttpApiError.BadRequest, HttpApiError.NotFound], + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "tui.selectSession", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index e5ff300a2a..7b8395d809 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -15,6 +15,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" +import * as ApiError from "../errors" import { CursorQuery, Params, PtyPaths } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" @@ -46,7 +47,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* new HttpApiError.NotFound({}) + if (!info) return yield* ApiError.notFound("Session not found") return info }) @@ -58,7 +59,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler ...ctx.payload, size: ctx.payload.size ? { ...ctx.payload.size } : undefined, }) - if (!info) return yield* new HttpApiError.NotFound({}) + if (!info) return yield* ApiError.notFound("Session not found") return info }) @@ -71,7 +72,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const request = yield* HttpServerRequest.HttpServerRequest if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) return yield* new HttpApiError.Forbidden({}) - if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* ApiError.notFound("Session not found") return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts new file mode 100644 index 0000000000..98ac2b9ad6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session-errors.ts @@ -0,0 +1,9 @@ +import type { NotFoundError as StorageNotFoundError } from "@/storage/storage" +import { Effect } from "effect" +import * as ApiError from "../errors" + +type StorageNotFound = InstanceType + +export function mapStorageNotFound(self: Effect.Effect) { + return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message))) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 4a67ba036e..56fa7adb15 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -37,14 +37,7 @@ import { SummarizePayload, UpdatePayload, } from "../groups/session" - -const mapNotFound = (self: Effect.Effect) => - self.pipe( - Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), - Effect.catchDefect((error) => - NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error), - ), - ) +import * as SessionError from "./session-errors" export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { @@ -79,7 +72,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { - return yield* mapNotFound(session.get(ctx.params.sessionID)) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -101,51 +94,49 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - return yield* mapNotFound( - Effect.gen(function* () { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before - yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.limit === undefined || ctx.query.limit === 0) { - yield* session.get(ctx.params.sessionID) - return yield* session.messages({ sessionID: ctx.params.sessionID }) - } + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } + yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) + if (ctx.query.limit === undefined || ctx.query.limit === 0) { + return yield* session.messages({ sessionID: ctx.params.sessionID }) + } - yield* session.get(ctx.params.sessionID) - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) - if (!page.cursor) return page.items + const page = MessageV2.page({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }) + if (!page.cursor) return page.items - const request = yield* HttpServerRequest.HttpServerRequest - // toURL() honors the Host + x-forwarded-proto headers, so the Link - // header echoes the real origin instead of a hard-coded localhost. - const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) - url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) - return HttpServerResponse.jsonUnsafe(page.items, { - headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", - Link: `<${url.toString()}>; rel="next"`, - "X-Next-Cursor": page.cursor, - }, - }) - }), - ) + const request = yield* HttpServerRequest.HttpServerRequest + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) + url.searchParams.set("limit", ctx.query.limit.toString()) + url.searchParams.set("before", page.cursor) + return HttpServerResponse.jsonUnsafe(page.items, { + headers: { + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + Link: `<${url.toString()}>; rel="next"`, + "X-Next-Cursor": page.cursor, + }, + }) }) const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - return yield* mapNotFound( - Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })), + return yield* SessionError.mapStorageNotFound( + Effect.try({ + try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), + catch: (error) => error, + }).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))), ) }) @@ -170,7 +161,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* session.remove(ctx.params.sessionID) + yield* SessionError.mapStorageNotFound(session.remove(ctx.params.sessionID)) return true }) @@ -178,7 +169,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const current = yield* session.get(ctx.params.sessionID) + const current = yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) if (ctx.payload.title !== undefined) { yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) } @@ -191,14 +182,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (ctx.payload.time?.archived !== undefined) { yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) } - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ForkPayload.Type }) { - return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }) + return yield* SessionError.mapStorageNotFound( + session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), + ) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -222,19 +215,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - return yield* session.get(ctx.params.sessionID) + return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID)) + yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))) const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) const defaultAgent = yield* agentSvc.defaultAgent() const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index cc85321685..0ecebf451f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -1,13 +1,12 @@ import { Bus } from "@/bus" import { TuiEvent } from "@/cli/cmd/tui/event" -import { SessionTable } from "@/session/session.sql" -import * as Database from "@/storage/db" -import { eq } from "drizzle-orm" +import { Session } from "@/session/session" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" +import * as SessionError from "./session-errors" const commandAliases = { session_new: "session.new", @@ -28,6 +27,7 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service + const session = yield* Session.Service const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) @@ -98,12 +98,7 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler payload: typeof TuiEvent.SessionSelect.properties.Type }) { if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) - const row = yield* Effect.sync(() => - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), - ), - ) - if (!row) return yield* new HttpApiError.NotFound({}) + yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID)) yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index a91a9992df..8ec9f74860 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -7,6 +7,7 @@ import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" +import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -178,7 +179,10 @@ function routeHttpApiWorkspace( const request = yield* HttpServerRequest.HttpServerRequest const sessionID = getWorkspaceRouteSessionID(requestURL(request)) const session = sessionID - ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) : undefined const plan = yield* planRequest(request, session?.workspaceID) return yield* routeWorkspace(client, effect, plan) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8286ecf8e6..fef8c43836 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -744,7 +744,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void const { msg, part, cwd } = yield* Effect.gen(function* () { const ctx = yield* InstanceState.context - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (session.revert) { yield* revert.cleanup(session) } @@ -1370,7 +1370,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1401,9 +1401,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (sessionID: SessionID) { const ctx = yield* InstanceState.context const slog = elog.with({ sessionID }) - let structured: unknown | undefined + let structured: unknown let step = 0 - const session = yield* sessions.get(sessionID) + const session = yield* sessions.get(sessionID).pipe(Effect.orDie) while (true) { yield* status.set(sessionID, { type: "busy" }) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 58d69a2040..abf7c3441f 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -44,7 +44,7 @@ export const layer = Layer.effect( yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) let rev: Session.Info["revert"] const patches: Snapshot.Patch[] = [] @@ -75,8 +75,8 @@ export const layer = Layer.effect( rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) yield* snap.revert(patches) - if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) - const range = all.filter((msg) => msg.info.id >= rev!.messageID) + if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot) + const range = all.filter((msg) => msg.info.id >= rev.messageID) const diffs = yield* summary.computeDiff({ messages: range }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) @@ -89,17 +89,17 @@ export const layer = Layer.effect( files: diffs.length, }, }) - return yield* sessions.get(input.sessionID) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) yield* state.assertNotBusy(input.sessionID) - const session = yield* sessions.get(input.sessionID) + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) if (!session.revert) return session - if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) + if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot) yield* sessions.clearRevert(input.sessionID) - return yield* sessions.get(input.sessionID) + return yield* sessions.get(input.sessionID).pipe(Effect.orDie) }) const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 09d2c8c3c3..5c938ff693 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -3,7 +3,6 @@ import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" -import z from "zod" import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -422,6 +421,8 @@ export class BusyError extends Error { } } +export type NotFound = InstanceType + export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly create: (input?: { @@ -432,9 +433,9 @@ export interface Interface { permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect - readonly get: (id: SessionID) => Effect.Effect + readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -448,7 +449,7 @@ export interface Interface { readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect @@ -534,13 +535,13 @@ export const layer: Layer.Layer d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) + if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) return fromRow(row) }) const list = Effect.fn("Session.list")(function* (input?: ListInput) { const ctx = yield* InstanceState.context - return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) })) + return Array.from(listByProject({ projectID: ctx.project.id, ...input })) }) const children = Effect.fn("Session.children")(function* (parentID: SessionID) { @@ -555,8 +556,8 @@ export const layer: Layer.Layer { yield* eventuallyEffect( Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from history") + expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from history") }), ) expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) @@ -1208,7 +1208,7 @@ describe("workspace-old sync state", () => { yield* eventuallyEffect( Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") + expect((yield* sessionSvc.get(session.id).pipe(Effect.orDie)).title).toBe("from sse") }), ) expect( diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts index 6922d8c43f..9d7eff4964 100644 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -105,23 +105,22 @@ describe("404 mapping for missing session", () => { }) // ────────────────────────────────────────────────────────────────────────────── -// Reproducer 3: 404 response body shape should match Hono's NamedError -// envelope `{ name, data: { message } }`. HttpApi returns the typed-error -// shape `{ _tag }` instead. SDK consumers reading `error.data.message` -// see undefined. -// -// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// Reproducer 3: 404 response body shape should match Hono's public NamedError +// envelope `{ name, data: { message } }`. SDK consumers read +// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a +// compatibility break. // ────────────────────────────────────────────────────────────────────────────── describe("Error JSON shape parity", () => { - test.todo("HttpApi 404 body matches NamedError shape", async () => { + test("HttpApi 404 body matches Hono shape", async () => { await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } - const response = await app(true).request("/session/ses_does_not_exist", { - headers: { "x-opencode-directory": tmp.path }, - }) + const hono = await app(false).request("/session/ses_does_not_exist", { headers }) + const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) - expect(response.status).toBe(404) - const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(httpapi.status).toBe(hono.status) + const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } + expect(body).toEqual(await hono.json()) expect(body.name).toBe("NotFoundError") expect(typeof body.data?.message).toBe("string") }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 2b6284a310..5e63eae61c 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -50,9 +50,9 @@ const effectIt = testEffect( ), ) -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function serverUrl() { @@ -121,6 +121,18 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) + test("matches Hono missing PTY error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) + + const hono = await app(false).request(path, { headers }) + const httpapi = await app().request(path, { headers }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index ce774ccfd0..6d2df45078 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -4,6 +4,7 @@ import type * as Scope from "effect/Scope" import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { validateSession } from "../../src/cli/cmd/tui/validate-session" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" @@ -13,6 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Config } from "@/config/config" import { Session as SessionNs } from "@/session/session" +import { errorMessage } from "../../src/util/error" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" @@ -64,20 +66,23 @@ function client( directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { - const serverApp = app(backend, input) - const fetch = Object.assign( - async (request: RequestInfo | URL, init?: RequestInit) => - await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), - { preconnect: globalThis.fetch.preconnect }, - ) satisfies typeof globalThis.fetch return createOpencodeClient({ baseUrl: "http://localhost", directory, headers: input?.headers, - fetch, + fetch: serverFetch(backend, input), }) } +function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { + const serverApp = app(backend, input) + return Object.assign( + async (request: RequestInfo | URL, init?: RequestInit) => + await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -129,6 +134,16 @@ function capture(request: () => Promise) { ) } +function captureThrown(request: () => Promise) { + return call(async () => { + try { + await request() + } catch (error) { + return error + } + }) +} + function expectStatus(request: () => Promise<{ response: Response }>, status: number) { return call(request).pipe( Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))), @@ -338,6 +353,46 @@ describe("HttpApi SDK", () => { ), ) + parity("matches generated SDK missing session errors across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + Effect.gen(function* () { + const sessionID = "ses_missing" + const expected = { + name: "NotFoundError", + data: { message: `Session not found: ${sessionID}` }, + } + const missing = yield* capture(() => sdk.session.get({ sessionID })) + const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + + expect(missing.error).toEqual(expected) + expect(thrown).toEqual(expected) + return { + status: missing.status, + error: missing.error, + thrown, + } + }), + ), + ) + + parity("formats missing session validation errors for -s", (backend) => + withStandardProject(backend, ({ directory }) => + Effect.gen(function* () { + const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" + const thrown = yield* captureThrown(() => + validateSession({ + url: "http://localhost", + directory, + sessionID, + fetch: serverFetch(backend), + }), + ) + expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) + return errorMessage(thrown) + }), + ), + ) + parity("matches generated SDK basic auth behavior across backends", (backend) => withStandardProject(backend, ({ directory }) => Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 34cecd80d0..c45aacce75 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -8,13 +8,12 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" @@ -55,7 +54,7 @@ function createSession(directory: string, input?: Session.CreateInput) { ) } -function createTextMessage(directory: string, sessionID: SessionID, text: string) { +function createTextMessage(directory: string, sessionID: SessionIDType, text: string) { return Effect.promise( async () => await WithInstance.provide({ @@ -125,6 +124,10 @@ function json(response: Response) { }) } +function responseJson(response: Response) { + return Effect.promise(() => response.json()) +} + function requestJson(path: string, init?: RequestInit) { return request(path, init).pipe(Effect.flatMap(json)) } @@ -147,6 +150,47 @@ afterEach(async () => { }) describe("session HttpApi", () => { + it.live( + "returns declared not found errors for read routes", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const missingSession = SessionID.descending() + const missingSessionBody = { + name: "NotFoundError", + data: { message: `Session not found: ${missingSession}` }, + } + + const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers }) + expect(get.status).toBe(404) + expect(yield* responseJson(get)).toEqual(missingSessionBody) + + const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers }) + expect(messages.status).toBe(404) + expect(yield* responseJson(messages)).toEqual(missingSessionBody) + + const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), { + headers, + method: "DELETE", + }) + expect(remove.status).toBe(404) + expect(yield* responseJson(remove)).toEqual(missingSessionBody) + + const session = yield* createSession(tmp.path, { title: "missing message" }) + const missingMessage = MessageID.ascending() + const message = yield* request( + pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }), + { headers }, + ) + expect(message.status).toBe(404) + expect(yield* responseJson(message)).toEqual({ + name: "NotFoundError", + data: { message: `Message not found: ${missingMessage}` }, + }) + }), + ), + ) + it.live( "serves read routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 8d2670c492..91cad362a9 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -72,14 +72,27 @@ describe("tui HttpApi bridge", () => { properties: { text: "from publish" }, }) + const missingSessionID = SessionID.descending() const missing = await app().request(TuiPaths.selectSession, { method: "POST", headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ sessionID: SessionID.descending() }), + body: JSON.stringify({ sessionID: missingSessionID }), }) expect(missing.status).toBe(404) }) + test("matches Hono missing selected session error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ sessionID: SessionID.descending() }) + + const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) + const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) + + expect(httpapi.status).toBe(hono.status) + expect(await httpapi.json()).toEqual(await hono.json()) + }) + test("matches legacy unknown execute command behavior", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } From 6e7c9eb820a2dd85724bcc8b4ad521776f937bca Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 6 May 2026 01:35:13 +0000 Subject: [PATCH 002/812] chore: generate --- packages/opencode/src/cli/cmd/session.ts | 6 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 +- packages/sdk/js/src/v2/gen/types.gen.ts | 47 +++++++++------- packages/sdk/openapi.json | 72 ++++++++++++++---------- 4 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 33f1e78ac0..1240fa92ce 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -60,9 +60,9 @@ export const SessionDeleteCommand = effectCmd({ handler: Effect.fn("Cli.session.delete")(function* (args) { const svc = yield* Session.Service const sessionID = SessionID.make(args.sessionID) - yield* svc.remove(sessionID).pipe( - Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`)), - ) + yield* svc + .remove(sessionID) + .pipe(Effect.catchIf(NotFoundError.isInstance, () => fail(`Session not found: ${args.sessionID}`))) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index fba70b5bf6..803d9ed16e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -131,6 +131,7 @@ import type { SessionDeleteResponses, SessionDelivery, SessionDiffResponses, + SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, @@ -3320,7 +3321,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d8ea6d94e5..b58f6cfc2b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1561,6 +1561,13 @@ export type McpUnsupportedOAuthError = { error: string } +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -3224,13 +3231,6 @@ export type BadRequestError = { success: false } -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type AuthRemoveData = { body?: never path: { @@ -4571,7 +4571,7 @@ export type PtyRemoveData = { export type PtyRemoveErrors = { /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -4601,7 +4601,7 @@ export type PtyGetData = { export type PtyGetErrors = { /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -4671,7 +4671,7 @@ export type PtyConnectTokenErrors = { */ 403: EffectHttpApiErrorForbidden /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5070,7 +5070,7 @@ export type SessionDeleteErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5104,7 +5104,7 @@ export type SessionGetErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5144,7 +5144,7 @@ export type SessionUpdateErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5270,7 +5270,7 @@ export type SessionMessagesErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5395,7 +5395,7 @@ export type SessionMessageErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5428,6 +5428,15 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } +export type SessionForkErrors = { + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] + export type SessionForkResponses = { /** * 200 @@ -5527,7 +5536,7 @@ export type SessionUnshareErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5561,7 +5570,7 @@ export type SessionShareErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -5599,7 +5608,7 @@ export type SessionSummarizeErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } @@ -6463,7 +6472,7 @@ export type TuiSelectSessionErrors = { */ 400: BadRequestError /** - * Not found + * NotFoundError */ 404: NotFoundError } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 007da60269..477145f017 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3241,7 +3241,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -3394,7 +3394,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -3479,7 +3479,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4454,7 +4454,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4526,7 +4526,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4597,7 +4597,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -4952,7 +4952,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5200,7 +5200,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5342,6 +5342,16 @@ } } } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -5592,7 +5602,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5663,7 +5673,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -5737,7 +5747,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -7897,7 +7907,7 @@ } }, "404": { - "description": "Not found", + "description": "NotFoundError", "content": { "application/json": { "schema": { @@ -12904,6 +12914,25 @@ "required": ["error"], "additionalProperties": false }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } + }, "effect_HttpApiError_Forbidden": { "type": "object", "properties": { @@ -18026,25 +18055,6 @@ "enum": [false] } } - }, - "NotFoundError": { - "type": "object", - "required": ["name", "data"], - "properties": { - "name": { - "type": "string", - "enum": ["NotFoundError"] - }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } - } - } } } }, From 5013e8a8ecc7912044f29918d5567c1b37942350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?imduchuyyy=20=F0=9F=90=AC?= Date: Wed, 6 May 2026 11:15:59 +0700 Subject: [PATCH 003/812] docs: update desktop app references from Tauri to Electron (#25965) --- CONTRIBUTING.md | 24 ++++++------------------ README.ar.md | 4 ++-- README.bn.md | 6 +++--- README.br.md | 4 ++-- README.bs.md | 4 ++-- README.da.md | 4 ++-- README.de.md | 4 ++-- README.es.md | 4 ++-- README.fr.md | 4 ++-- README.gr.md | 4 ++-- README.it.md | 4 ++-- README.ja.md | 4 ++-- README.ko.md | 4 ++-- README.md | 12 ++++++------ README.no.md | 4 ++-- README.pl.md | 4 ++-- README.ru.md | 4 ++-- README.th.md | 4 ++-- README.tr.md | 4 ++-- README.uk.md | 4 ++-- README.vi.md | 4 ++-- README.zh.md | 4 ++-- README.zht.md | 4 ++-- 23 files changed, 55 insertions(+), 67 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ae3fc6f2f..e1a62ae9ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ Replace `` with your platform (e.g., `darwin-arm64`, `linux-x64`). - `packages/opencode`: OpenCode core business logic & server. - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui) - `packages/app`: The shared web UI components, written in SolidJS - - `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`) + - `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`) - `packages/plugin`: Source for `@opencode-ai/plugin` ### Understanding bun dev vs opencode @@ -123,33 +123,21 @@ This starts a local dev server at http://localhost:5173 (or similar port shown i ### Running the Desktop App -The desktop app is a native Tauri application that wraps the web UI. +The desktop app is an Electron application that wraps the web UI. -To run the native desktop app: - -```bash -bun run --cwd packages/desktop tauri dev -``` - -This starts the web dev server on http://localhost:1420 and opens the native window. - -If you only want the web dev server (no native shell): +To run the desktop app in development: ```bash bun run --cwd packages/desktop dev ``` -To create a production `dist/` and build the native app bundle: +To create a production build and package the app: ```bash -bun run --cwd packages/desktop tauri build +bun run --cwd packages/desktop build +bun run --cwd packages/desktop package ``` -This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `beforeBuildCommand`. - -> [!NOTE] -> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. - > [!NOTE] > If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files. diff --git a/README.ar.md b/README.ar.md index beb44589e6..e6781325f2 100644 --- a/README.ar.md +++ b/README.ar.md @@ -70,8 +70,8 @@ nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث | المنصة | التنزيل | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb` او `.rpm` او AppImage | diff --git a/README.bn.md b/README.bn.md index c7abc7346a..b6d981e383 100644 --- a/README.bn.md +++ b/README.bn.md @@ -70,10 +70,10 @@ OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসে | প্ল্যাটফর্ম | ডাউনলোড | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, or AppImage | +| Linux | `.deb`, `.rpm`, or `.AppImage` | ```bash # macOS (Homebrew) diff --git a/README.br.md b/README.br.md index 6d1de21562..ae01949394 100644 --- a/README.br.md +++ b/README.br.md @@ -70,8 +70,8 @@ O OpenCode também está disponível como aplicativo desktop. Baixe diretamente | Plataforma | Download | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` ou AppImage | diff --git a/README.bs.md b/README.bs.md index 2cff8e0279..c2035bb1b3 100644 --- a/README.bs.md +++ b/README.bs.md @@ -70,8 +70,8 @@ OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice | Platforma | Preuzimanje | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, ili AppImage | diff --git a/README.da.md b/README.da.md index ac522f29c4..a89cb21193 100644 --- a/README.da.md +++ b/README.da.md @@ -70,8 +70,8 @@ OpenCode findes også som desktop-app. Download direkte fra [releases-siden](htt | Platform | Download | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, eller AppImage | diff --git a/README.de.md b/README.de.md index 87a670f3fc..41f651849b 100644 --- a/README.de.md +++ b/README.de.md @@ -70,8 +70,8 @@ OpenCode ist auch als Desktop-Anwendung verfügbar. Lade sie direkt von der [Rel | Plattform | Download | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` oder AppImage | diff --git a/README.es.md b/README.es.md index 9e456af1c0..20f749fbb6 100644 --- a/README.es.md +++ b/README.es.md @@ -70,8 +70,8 @@ OpenCode también está disponible como aplicación de escritorio. Descárgala d | Plataforma | Descarga | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, o AppImage | diff --git a/README.fr.md b/README.fr.md index c1fca23376..30e089cd6f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -70,8 +70,8 @@ OpenCode est aussi disponible en application de bureau. Téléchargez-la directe | Plateforme | Téléchargement | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, ou AppImage | diff --git a/README.gr.md b/README.gr.md index 2b2c2679d8..d7e9885a2d 100644 --- a/README.gr.md +++ b/README.gr.md @@ -70,8 +70,8 @@ nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση | Πλατφόρμα | Λήψη | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, ή AppImage | diff --git a/README.it.md b/README.it.md index 3e516a9027..03b6f2427d 100644 --- a/README.it.md +++ b/README.it.md @@ -70,8 +70,8 @@ OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla diretta | Piattaforma | Download | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, oppure AppImage | diff --git a/README.ja.md b/README.ja.md index 144dc7b6f8..d5c68d8c3f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -70,8 +70,8 @@ OpenCode はデスクトップアプリとしても利用できます。[release | プラットフォーム | ダウンロード | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`、`.rpm`、または AppImage | diff --git a/README.ko.md b/README.ko.md index 32defc0a5e..b8b4b5164a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -70,8 +70,8 @@ OpenCode 는 데스크톱 앱으로도 제공됩니다. [releases page](https:// | 플랫폼 | 다운로드 | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, 또는 AppImage | diff --git a/README.md b/README.md index 3ebfb1627c..ccce3e97bb 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download). -| Platform | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, or AppImage | +| Platform | Download | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, or `.AppImage` | ```bash # macOS (Homebrew) diff --git a/README.no.md b/README.no.md index c3348286b2..866de55d4f 100644 --- a/README.no.md +++ b/README.no.md @@ -70,8 +70,8 @@ OpenCode er også tilgjengelig som en desktop-app. Last ned direkte fra [release | Plattform | Nedlasting | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` eller AppImage | diff --git a/README.pl.md b/README.pl.md index 4c5a076656..468a5a5edb 100644 --- a/README.pl.md +++ b/README.pl.md @@ -70,8 +70,8 @@ OpenCode jest także dostępny jako aplikacja desktopowa. Pobierz ją bezpośred | Platforma | Pobieranie | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` lub AppImage | diff --git a/README.ru.md b/README.ru.md index e507be70e6..c19175cca6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -70,8 +70,8 @@ OpenCode также доступен как десктопное приложе | Платформа | Загрузка | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` или AppImage | diff --git a/README.th.md b/README.th.md index 4a4ea62c95..b68a7cd6f3 100644 --- a/README.th.md +++ b/README.th.md @@ -70,8 +70,8 @@ OpenCode มีให้ใช้งานเป็นแอปพลิเค | แพลตฟอร์ม | ดาวน์โหลด | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, หรือ AppImage | diff --git a/README.tr.md b/README.tr.md index e88b40f875..7657a846c9 100644 --- a/README.tr.md +++ b/README.tr.md @@ -70,8 +70,8 @@ OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm | Platform | İndirme | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` veya AppImage | diff --git a/README.uk.md b/README.uk.md index a1a0259b6d..331637862c 100644 --- a/README.uk.md +++ b/README.uk.md @@ -70,8 +70,8 @@ OpenCode також доступний як десктопний застосу | Платформа | Завантаження | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm` або AppImage | diff --git a/README.vi.md b/README.vi.md index 0932c50f78..166daa25e0 100644 --- a/README.vi.md +++ b/README.vi.md @@ -70,8 +70,8 @@ OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiế | Nền tảng | Tải xuống | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, hoặc AppImage | diff --git a/README.zh.md b/README.zh.md index 46d9f761cb..0366a0868b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -70,8 +70,8 @@ OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](htt | 平台 | 下载文件 | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`、`.rpm` 或 AppImage | diff --git a/README.zht.md b/README.zht.md index 7ef51d8fdd..721623e72c 100644 --- a/README.zht.md +++ b/README.zht.md @@ -70,8 +70,8 @@ OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (rele | 平台 | 下載連結 | | --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | Windows | `opencode-desktop-windows-x64.exe` | | Linux | `.deb`, `.rpm`, 或 AppImage | From 2f05676e0470292633cbfb1feb96a1d6bf555245 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 6 May 2026 04:17:05 +0000 Subject: [PATCH 004/812] chore: generate --- README.ar.md | 10 +++++----- README.bn.md | 10 +++++----- README.br.md | 10 +++++----- README.bs.md | 10 +++++----- README.da.md | 10 +++++----- README.de.md | 10 +++++----- README.es.md | 10 +++++----- README.fr.md | 10 +++++----- README.gr.md | 10 +++++----- README.it.md | 10 +++++----- README.ja.md | 10 +++++----- README.ko.md | 10 +++++----- README.no.md | 10 +++++----- README.pl.md | 10 +++++----- README.ru.md | 10 +++++----- README.th.md | 10 +++++----- README.tr.md | 10 +++++----- README.uk.md | 10 +++++----- README.vi.md | 10 +++++----- README.zh.md | 10 +++++----- README.zht.md | 10 +++++----- 21 files changed, 105 insertions(+), 105 deletions(-) diff --git a/README.ar.md b/README.ar.md index e6781325f2..a590f1ca58 100644 --- a/README.ar.md +++ b/README.ar.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث يتوفر OpenCode ايضا كتطبيق سطح مكتب. قم بالتنزيل مباشرة من [صفحة الاصدارات](https://github.com/anomalyco/opencode/releases) او من [opencode.ai/download](https://opencode.ai/download). -| المنصة | التنزيل | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| المنصة | التنزيل | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb` او `.rpm` او AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb` او `.rpm` او AppImage | ```bash # macOS (Homebrew) diff --git a/README.bn.md b/README.bn.md index b6d981e383..b80b1e202c 100644 --- a/README.bn.md +++ b/README.bn.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন। -| প্ল্যাটফর্ম | ডাউনলোড | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| প্ল্যাটফর্ম | ডাউনলোড | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, or `.AppImage` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, or `.AppImage` | ```bash # macOS (Homebrew) diff --git a/README.br.md b/README.br.md index ae01949394..60a9e72f70 100644 --- a/README.br.md +++ b/README.br.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch O OpenCode também está disponível como aplicativo desktop. Baixe diretamente pela [página de releases](https://github.com/anomalyco/opencode/releases) ou em [opencode.ai/download](https://opencode.ai/download). -| Plataforma | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Plataforma | Download | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` ou AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` ou AppImage | ```bash # macOS (Homebrew) diff --git a/README.bs.md b/README.bs.md index c2035bb1b3..4c3083c4c0 100644 --- a/README.bs.md +++ b/README.bs.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download). -| Platforma | Preuzimanje | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Platforma | Preuzimanje | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ili AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, ili AppImage | ```bash # macOS (Homebrew) diff --git a/README.da.md b/README.da.md index a89cb21193..c7a99f7d89 100644 --- a/README.da.md +++ b/README.da.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste OpenCode findes også som desktop-app. Download direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). -| Platform | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Platform | Download | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, eller AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, eller AppImage | ```bash # macOS (Homebrew) diff --git a/README.de.md b/README.de.md index 41f651849b..340cbe5bd3 100644 --- a/README.de.md +++ b/README.de.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neu OpenCode ist auch als Desktop-Anwendung verfügbar. Lade sie direkt von der [Releases-Seite](https://github.com/anomalyco/opencode/releases) oder [opencode.ai/download](https://opencode.ai/download) herunter. -| Plattform | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Plattform | Download | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` oder AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` oder AppImage | ```bash # macOS (Homebrew) diff --git a/README.es.md b/README.es.md index 20f749fbb6..9180e689fc 100644 --- a/README.es.md +++ b/README.es.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama de OpenCode también está disponible como aplicación de escritorio. Descárgala directamente desde la [página de releases](https://github.com/anomalyco/opencode/releases) o desde [opencode.ai/download](https://opencode.ai/download). -| Plataforma | Descarga | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Plataforma | Descarga | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, o AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, o AppImage | ```bash # macOS (Homebrew) diff --git a/README.fr.md b/README.fr.md index 30e089cd6f..8ca10b080d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branch OpenCode est aussi disponible en application de bureau. Téléchargez-la directement depuis la [page des releases](https://github.com/anomalyco/opencode/releases) ou [opencode.ai/download](https://opencode.ai/download). -| Plateforme | Téléchargement | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Plateforme | Téléchargement | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ou AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, ou AppImage | ```bash # macOS (Homebrew) diff --git a/README.gr.md b/README.gr.md index d7e9885a2d..6f7c67b30e 100644 --- a/README.gr.md +++ b/README.gr.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download). -| Πλατφόρμα | Λήψη | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Πλατφόρμα | Λήψη | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ή AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, ή AppImage | ```bash # macOS (Homebrew) diff --git a/README.it.md b/README.it.md index 03b6f2427d..d17de67987 100644 --- a/README.it.md +++ b/README.it.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ul OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download). -| Piattaforma | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Piattaforma | Download | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, oppure AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, oppure AppImage | ```bash # macOS (Homebrew) diff --git a/README.ja.md b/README.ja.md index d5c68d8c3f..4002433824 100644 --- a/README.ja.md +++ b/README.ja.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # または github:anomalyco/opencode で最 OpenCode はデスクトップアプリとしても利用できます。[releases page](https://github.com/anomalyco/opencode/releases) から直接ダウンロードするか、[opencode.ai/download](https://opencode.ai/download) を利用してください。 -| プラットフォーム | ダウンロード | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| プラットフォーム | ダウンロード | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`、`.rpm`、または AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`、`.rpm`、または AppImage | ```bash # macOS (Homebrew) diff --git a/README.ko.md b/README.ko.md index b8b4b5164a..5b7329db05 100644 --- a/README.ko.md +++ b/README.ko.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 OpenCode 는 데스크톱 앱으로도 제공됩니다. [releases page](https://github.com/anomalyco/opencode/releases) 에서 직접 다운로드하거나 [opencode.ai/download](https://opencode.ai/download) 를 이용하세요. -| 플랫폼 | 다운로드 | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| 플랫폼 | 다운로드 | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, 또는 AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, 또는 AppImage | ```bash # macOS (Homebrew) diff --git a/README.no.md b/README.no.md index 866de55d4f..6abd214d64 100644 --- a/README.no.md +++ b/README.no.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste OpenCode er også tilgjengelig som en desktop-app. Last ned direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). -| Plattform | Nedlasting | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Plattform | Nedlasting | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` eller AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` eller AppImage | ```bash # macOS (Homebrew) diff --git a/README.pl.md b/README.pl.md index 468a5a5edb..0beb6d996b 100644 --- a/README.pl.md +++ b/README.pl.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowsze OpenCode jest także dostępny jako aplikacja desktopowa. Pobierz ją bezpośrednio ze strony [releases](https://github.com/anomalyco/opencode/releases) lub z [opencode.ai/download](https://opencode.ai/download). -| Platforma | Pobieranie | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Platforma | Pobieranie | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` lub AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` lub AppImage | ```bash # macOS (Homebrew) diff --git a/README.ru.md b/README.ru.md index c19175cca6..c5f9eceda5 100644 --- a/README.ru.md +++ b/README.ru.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # или github:anomalyco/opencode для с OpenCode также доступен как десктопное приложение. Скачайте его со [страницы релизов](https://github.com/anomalyco/opencode/releases) или с [opencode.ai/download](https://opencode.ai/download). -| Платформа | Загрузка | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Платформа | Загрузка | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` или AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` или AppImage | ```bash # macOS (Homebrew) diff --git a/README.th.md b/README.th.md index b68a7cd6f3..3781b028f8 100644 --- a/README.th.md +++ b/README.th.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # หรือ github:anomalyco/opencode ส OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download) -| แพลตฟอร์ม | ดาวน์โหลด | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| แพลตฟอร์ม | ดาวน์โหลด | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, หรือ AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, หรือ AppImage | ```bash # macOS (Homebrew) diff --git a/README.tr.md b/README.tr.md index 7657a846c9..15fc79233d 100644 --- a/README.tr.md +++ b/README.tr.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # veya en güncel geliştirme dalı için git OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz. -| Platform | İndirme | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Platform | İndirme | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` veya AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` veya AppImage | ```bash # macOS (Homebrew) diff --git a/README.uk.md b/README.uk.md index 331637862c..987dd784ee 100644 --- a/README.uk.md +++ b/README.uk.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # або github:anomalyco/opencode для н OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download). -| Платформа | Завантаження | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Платформа | Завантаження | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` або AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` або AppImage | ```bash # macOS (Homebrew) diff --git a/README.vi.md b/README.vi.md index 166daa25e0..a2f9c3708c 100644 --- a/README.vi.md +++ b/README.vi.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download). -| Nền tảng | Tải xuống | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| Nền tảng | Tải xuống | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, hoặc AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, hoặc AppImage | ```bash # macOS (Homebrew) diff --git a/README.zh.md b/README.zh.md index 0366a0868b..99b701b896 100644 --- a/README.zh.md +++ b/README.zh.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最 OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。 -| 平台 | 下载文件 | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| 平台 | 下载文件 | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`、`.rpm` 或 AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`、`.rpm` 或 AppImage | ```bash # macOS (Homebrew Cask) diff --git a/README.zht.md b/README.zht.md index 721623e72c..1d31e1a591 100644 --- a/README.zht.md +++ b/README.zht.md @@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取 OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。 -| 平台 | 下載連結 | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | +| 平台 | 下載連結 | +| --------------------- | ---------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Intel) | `opencode-desktop-mac-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, 或 AppImage | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, 或 AppImage | ```bash # macOS (Homebrew Cask) From efd8024430f8ec6d90086688bf6bb259a1b5af4c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 12:30:20 +0800 Subject: [PATCH 005/812] feat(desktop): add OPENCODE_TEST_ONBOARDING env (#25968) --- packages/desktop/src/main/index.ts | 35 ++++++++++++++++++++++++----- packages/desktop/src/main/server.ts | 2 +- packages/desktop/src/main/store.ts | 8 ++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index a1eba8b98d..cbac5aa449 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" -import { existsSync } from "node:fs" +import { existsSync, mkdirSync, rmSync } from "node:fs" import * as http from "node:http" import { createServer } from "node:net" -import { homedir } from "node:os" +import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" @@ -30,10 +30,17 @@ const APP_IDS: Record = { beta: "ai.opencode.desktop.beta", prod: "ai.opencode.desktop", } +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" +const onboardingTestRoot = setupOnboardingTestEnv() app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") app.setAppUserModelId(appId) -app.setPath("userData", join(app.getPath("appData"), appId)) +app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), +) +if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) +const logger = initLogging() const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" @@ -65,13 +72,29 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() -const logger = initLogging() useSystemCertificates() +function setupOnboardingTestEnv() { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root +} + logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), }) setupApp() @@ -118,7 +141,7 @@ function setupApp() { } void app.whenReady().then(async () => { - migrate() + if (!TEST_ONBOARDING) migrate() app.setAsDefaultProtocolClient("opencode") registerRendererProtocol() setDockIcon() @@ -344,6 +367,8 @@ async function getSidecarPort() { } function sqliteFileExists() { + if (process.env.OPENCODE_DB === ":memory:") return true + const xdg = process.env.XDG_DATA_HOME const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") return existsSync(join(base, "opencode", "opencode.db")) diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index fab09eb1b1..4b8cb04943 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -70,7 +70,7 @@ function prepareServerEnv(password: string) { OPENCODE_CLIENT: "desktop", OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: app.getPath("userData"), + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"), } Object.assign(process.env, env) } diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index 7b3bd7c660..a591f878de 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -1,4 +1,5 @@ import Store from "electron-store" +import { app } from "electron" import { SETTINGS_STORE } from "./constants" @@ -11,7 +12,12 @@ const cache = new Map() export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached - const next = new Store({ name, fileExtension: "", accessPropertiesByDotNotation: false }) + const next = new Store({ + name, + cwd: app.getPath("userData"), + fileExtension: "", + accessPropertiesByDotNotation: false, + }) cache.set(name, next) return next } From b4c60e1b213c899fce258e8fdc9978924ee01740 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 6 May 2026 04:32:49 +0000 Subject: [PATCH 006/812] chore: generate --- packages/desktop/src/main/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index cbac5aa449..1360c29523 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -35,10 +35,7 @@ const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" const onboardingTestRoot = setupOnboardingTestEnv() app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") app.setAppUserModelId(appId) -app.setPath( - "userData", - onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), -) +app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) const logger = initLogging() const { autoUpdater } = pkg From 89afac3d9d8cf57a47facfb619a8262877c1474d Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 6 May 2026 12:39:52 +0800 Subject: [PATCH 007/812] go: restore Kimi K2.6 limits (#25969) --- packages/console/app/src/i18n/ar.ts | 2 -- packages/console/app/src/i18n/br.ts | 2 -- packages/console/app/src/i18n/da.ts | 2 -- packages/console/app/src/i18n/de.ts | 2 -- packages/console/app/src/i18n/en.ts | 2 -- packages/console/app/src/i18n/es.ts | 2 -- packages/console/app/src/i18n/fr.ts | 2 -- packages/console/app/src/i18n/it.ts | 2 -- packages/console/app/src/i18n/ja.ts | 2 -- packages/console/app/src/i18n/ko.ts | 2 -- packages/console/app/src/i18n/no.ts | 2 -- packages/console/app/src/i18n/pl.ts | 2 -- packages/console/app/src/i18n/ru.ts | 2 -- packages/console/app/src/i18n/th.ts | 2 -- packages/console/app/src/i18n/tr.ts | 2 -- packages/console/app/src/i18n/zh.ts | 2 -- packages/console/app/src/i18n/zht.ts | 2 -- packages/console/app/src/routes/go/index.css | 35 -------------------- packages/console/app/src/routes/go/index.tsx | 22 ++---------- 19 files changed, 2 insertions(+), 89 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 5c0919e8e2..42258db866 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -261,8 +261,6 @@ export const dict = { "go.cta.promo": "$5 للشهر الأول", "go.pricing.body": "استخدمه مع أي وكيل. $5 للشهر الأول، ثم $10/شهر. قم بزيادة الرصيد إذا لزم الأمر. الإلغاء في أي وقت.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: حد الاستخدام 3 أضعاف حتى 27 أبريل", "go.graph.free": "مجاني", "go.graph.freePill": "Big Pickle ونماذج مجانية", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 76e6987d3e..a848ba38da 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -265,8 +265,6 @@ export const dict = { "go.cta.promo": "$5 no primeiro mês", "go.pricing.body": "Use com qualquer agente. $5 no primeiro mês, depois $10/mês. Recarregue o crédito se necessário. Cancele a qualquer momento.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: limite de uso 3x maior até 27 de abril", "go.graph.free": "Grátis", "go.graph.freePill": "Big Pickle e modelos gratuitos", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index b97ee2cc0a..c54aca32e1 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -263,8 +263,6 @@ export const dict = { "go.cta.promo": "$5 første måned", "go.pricing.body": "Brug med enhver agent. $5 første måned, derefter $10/måned. Tank op med kredit efter behov. Afmeld når som helst.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: brugsgrænsen tredoblet til 27. april", "go.graph.free": "Gratis", "go.graph.freePill": "Big Pickle og gratis modeller", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 33b6e1b3de..6e14778de8 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -265,8 +265,6 @@ export const dict = { "go.cta.promo": "$5 im ersten Monat", "go.pricing.body": "Mit jedem Agenten nutzbar. $5 im ersten Monat, danach $10/Monat. Guthaben bei Bedarf aufladen. Jederzeit kündbar.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: Nutzungslimit bis zum 27. April verdreifacht", "go.graph.free": "Kostenlos", "go.graph.freePill": "Big Pickle und kostenlose Modelle", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index b6934b94de..0d0869da53 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -249,8 +249,6 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6 gets 3× usage limits through April 27", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index c5cc71ae1e..fd13a54de6 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -266,8 +266,6 @@ export const dict = { "go.cta.promo": "$5 el primer mes", "go.pricing.body": "Úsalo con cualquier agente. $5 el primer mes, luego 10 $/mes. Recarga crédito si es necesario. Cancela en cualquier momento.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: límite de uso triplicado hasta el 27 de abril", "go.graph.free": "Gratis", "go.graph.freePill": "Big Pickle y modelos gratuitos", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 04e6e3bc62..3762915abf 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -267,8 +267,6 @@ export const dict = { "go.cta.promo": "$5 le premier mois", "go.pricing.body": "Utilisez-le avec n'importe quel agent. $5 le premier mois, puis 10 $/mois. Rechargez du crédit si nécessaire. Annulez à tout moment.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6 : limites d’utilisation triplées jusqu’au 27 avril", "go.graph.free": "Gratuit", "go.graph.freePill": "Big Pickle et modèles gratuits", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 13f33bfc39..04d0e2451c 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -263,8 +263,6 @@ export const dict = { "go.cta.promo": "$5 il primo mese", "go.pricing.body": "Usalo con qualsiasi agente. $5 il primo mese, poi $10/mese. Ricarica il credito se necessario. Annulla in qualsiasi momento.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: limite d'uso triplicato fino al 27 aprile", "go.graph.free": "Gratis", "go.graph.freePill": "Big Pickle e modelli gratuiti", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 845faebf61..71404c91eb 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -262,8 +262,6 @@ export const dict = { "go.cta.promo": "初月 $5", "go.pricing.body": "どのエージェントでも使えます。最初の月$5、その後$10/月。必要に応じてクレジットを追加。いつでもキャンセルできます。", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6、4月27日まで利用上限が3倍に", "go.graph.free": "無料", "go.graph.freePill": "Big Pickleと無料モデル", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 7efe563a07..6a7d52bbd5 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -259,8 +259,6 @@ export const dict = { "go.cta.promo": "첫 달 $5", "go.pricing.body": "어떤 에이전트와도 사용할 수 있습니다. 첫 달 $5, 이후 $10/월. 필요하면 크레딧을 충전하세요. 언제든지 취소할 수 있습니다.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6, 4월 27일까지 사용 한도 3배 확대", "go.graph.free": "무료", "go.graph.freePill": "Big Pickle 및 무료 모델", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 8948e158b0..629e690c64 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -263,8 +263,6 @@ export const dict = { "go.cta.promo": "$5 første måned", "go.pricing.body": "Bruk med hvilken som helst agent. $5 første måned, deretter $10/måned. Fyll på kreditt ved behov. Avslutt når som helst.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: bruksgrensen er tredoblet til 27. april", "go.graph.free": "Gratis", "go.graph.freePill": "Big Pickle og gratis modeller", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index f879ed7057..0f465df9d9 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -264,8 +264,6 @@ export const dict = { "go.cta.promo": "$5 pierwszy miesiąc", "go.pricing.body": "Używaj z dowolnym agentem. $5 za pierwszy miesiąc, potem $10/miesiąc. Doładuj konto w razie potrzeby. Anuluj w dowolnym momencie.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: limit użycia zwiększony 3× do 27 kwietnia", "go.graph.free": "Darmowe", "go.graph.freePill": "Big Pickle i darmowe modele", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 9ba36d2208..90019dbe54 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -267,8 +267,6 @@ export const dict = { "go.cta.promo": "$5 первый месяц", "go.pricing.body": "Используйте с любым агентом. $5 за первый месяц, затем $10/месяц. Пополняйте баланс при необходимости. Отменить можно в любое время.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: лимит использования увеличен в 3 раза до 27 апреля", "go.graph.free": "Бесплатно", "go.graph.freePill": "Big Pickle и бесплатные модели", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 01b2b19c39..9f210ada49 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -261,8 +261,6 @@ export const dict = { "go.cta.price": "$10/เดือน", "go.cta.promo": "$5 เดือนแรก", "go.pricing.body": "ใช้กับเอเจนต์ใดก็ได้ $5 ในเดือนแรก จากนั้น $10/เดือน เติมเครดิตหากจำเป็น ยกเลิกได้ตลอดเวลา", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6 โควตาการใช้งานเพิ่มเป็น 3 เท่า ถึง 27 เม.ย.", "go.graph.free": "ฟรี", "go.graph.freePill": "Big Pickle และโมเดลฟรี", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 0345277b87..3d2f8f39de 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -265,8 +265,6 @@ export const dict = { "go.cta.promo": "İlk ay $5", "go.pricing.body": "Herhangi bir ajanla kullanın. İlk ay $5, sonrasında ayda 10$. Gerekirse kredi yükleyin. İstediğiniz zaman iptal edin.", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6: kullanım limiti 27 Nisan'a kadar 3 katına çıktı", "go.graph.free": "Ücretsiz", "go.graph.freePill": "Big Pickle ve ücretsiz modeller", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index b9300cc87e..fdcb7d37a0 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -252,8 +252,6 @@ export const dict = { "go.cta.price": "$10/月", "go.cta.promo": "首月 $5", "go.pricing.body": "可配合任何代理使用。首月 $5,之后 $10/月。如有需要可充值。随时取消。", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6 使用额度提升至 3 倍,限时至 4 月 27 日", "go.graph.free": "免费", "go.graph.freePill": "Big Pickle 和免费模型", "go.graph.go": "Go", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index f129a99d02..bfbfcf7e81 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -252,8 +252,6 @@ export const dict = { "go.cta.price": "$10/月", "go.cta.promo": "首月 $5", "go.pricing.body": "可搭配任何代理使用。首月 $5,之後 $10/月。如有需要可儲值。隨時取消。", - "go.banner.badge": "3x", - "go.banner.text": "Kimi K2.6 使用額度提升至 3 倍,限時至 4 月 27 日", "go.graph.free": "免費", "go.graph.freePill": "Big Pickle 與免費模型", "go.graph.go": "Go", diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index de8dce4724..25ae00e5f8 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -326,37 +326,6 @@ body { } } - [data-component="desktop-app-banner"] { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 32px; - - [data-slot="badge"] { - background: var(--color-background-strong); - color: var(--color-text-inverted); - font-weight: 500; - padding: 4px 8px; - line-height: 1; - flex-shrink: 0; - } - - [data-slot="content"] { - display: flex; - align-items: center; - gap: 1ch; - } - - [data-slot="text"] { - color: var(--color-text-strong); - line-height: 1.4; - - @media (max-width: 30.625rem) { - display: none; - } - } - } - [data-slot="hero-copy"] { img { margin-bottom: 24px; @@ -662,10 +631,6 @@ body { fill: var(--color-text-strong); } - [data-bar][data-kind="promo"] { - fill: color-mix(in srgb, var(--bar-go) 50%, transparent); - } - [data-val] { fill: var(--color-text-strong); font-size: 13px; diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 67ae58ae88..1ec83b25fe 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -63,7 +63,7 @@ function LimitsGraph(props: { href: string }) { const free = 200 const graph = [ { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, - { id: "kimi-k2.6", name: "Kimi K2.6 (3x usage)", req: 3450, baseReq: 1150, d: "150ms" }, + { id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, @@ -157,24 +157,12 @@ function LimitsGraph(props: { href: string }) { - {m.baseReq && ( - - )} )} @@ -264,12 +252,6 @@ export default function Home() {
-
- {i18n.t("home.banner.badge")} -
- {i18n.t("go.banner.text")} -
-
From 7c8cf6ca5be788b65598d565acb0c6511e6f60d9 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 12:44:40 +0800 Subject: [PATCH 008/812] fix(desktop): suppress browser API Sentry errors in prod (#25972) --- packages/desktop/src/renderer/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 97c7ed23a2..f9114c7550 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -43,7 +43,11 @@ if (import.meta.env.VITE_SENTRY_DSN) { integrations: (integrations) => { return integrations.filter( (i) => - i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + i.name !== "Breadcrumbs" && + !( + import.meta.env.OPENCODE_CHANNEL === "prod" && + (i.name === "GlobalHandlers" || i.name === "BrowserApiErrors") + ), ) }, }) From 9d178e094437dc8698e749e02b8dcd105c2824a3 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 01:05:09 -0400 Subject: [PATCH 009/812] sync --- packages/console/app/src/routes/incident/webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/incident/webhook.ts b/packages/console/app/src/routes/incident/webhook.ts index 3f4aa5f7ce..62ee202743 100644 --- a/packages/console/app/src/routes/incident/webhook.ts +++ b/packages/console/app/src/routes/incident/webhook.ts @@ -37,7 +37,7 @@ const postDiscordMessage = async (incident: Incident) => { `**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`, incident.summary, "", - "@everyone", + "@inference", "", incident.permalink, ] From acca2e92dcd6cd2548a28929407e49a5ce596656 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 14:39:20 +0800 Subject: [PATCH 010/812] fix(desktop): disable auto install on app quit (#25976) --- packages/desktop/src/main/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 1360c29523..d3c8fcc04e 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -378,7 +378,7 @@ function setupAutoUpdater() { autoUpdater.allowPrerelease = false autoUpdater.allowDowngrade = true autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = true + autoUpdater.autoInstallOnAppQuit = false logger.log("auto updater configured", { channel: autoUpdater.channel, allowPrerelease: autoUpdater.allowPrerelease, From 754a1fb712e2b79c8786e27af58f7ae0c1e34d65 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 15:30:18 +0800 Subject: [PATCH 011/812] fix(desktop): suppress EPIPE errors in console transport (#25980) --- packages/desktop/src/main/logging.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index d315b2d344..1f1c5e54e3 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -7,6 +7,7 @@ const TAIL_LINES = 1000 export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 + initConsoleTransport() cleanup() return log } @@ -38,3 +39,19 @@ function cleanup() { } } } + +function initConsoleTransport() { + const write = log.transports.console.writeFn.bind(log.transports.console) + log.transports.console.writeFn = (options) => { + try { + write(options) + } catch (err) { + if (!isBrokenPipe(err)) throw err + log.transports.console.level = false + } + } +} + +function isBrokenPipe(err: unknown) { + return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" +} From c235ba1bef3f06530176974d854401389255f4dd Mon Sep 17 00:00:00 2001 From: Guiii <68971828+kill74@users.noreply.github.com> Date: Wed, 6 May 2026 08:56:38 +0100 Subject: [PATCH 012/812] docs: fix CLI attach section order (#25749) --- packages/web/src/content/docs/cli.mdx | 62 +++++++++++++-------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 8ecb6a6eb9..ac8a1a3044 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -61,37 +61,6 @@ opencode agent [command] --- -### attach - -Attach a terminal to an already running OpenCode backend server started via `serve` or `web` commands. - -```bash -opencode attach [url] -``` - -This allows using the TUI with a remote OpenCode backend. For example: - -```bash -# Start the backend server for web/mobile access -opencode web --port 4096 --hostname 0.0.0.0 - -# In another terminal, attach the TUI to the running backend -opencode attach http://10.20.30.40:4096 -``` - -#### Flags - -| Flag | Short | Description | -| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | -| {"--dir"} | | Working directory to start TUI in | -| {"--continue"} | `-c` | Continue the last session | -| {"--session"} | `-s` | Session ID to continue | -| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | -| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | -| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | - ---- - #### create Create a new agent with custom configuration. @@ -126,6 +95,37 @@ opencode agent list --- +### attach + +Attach a terminal to an already running OpenCode backend server started via `serve` or `web` commands. + +```bash +opencode attach [url] +``` + +This allows using the TUI with a remote OpenCode backend. For example: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### Flags + +| Flag | Short | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--dir"} | | Working directory to start TUI in | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | + +--- + ### auth Command to manage credentials and login for providers. From 518503b29ba9826af296e7c089c400ad99d581bf Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Wed, 6 May 2026 05:06:37 -0300 Subject: [PATCH 013/812] fix(ui): preserve SVG tags in DOMPurify config for KaTeX math rendering (#25866) --- packages/ui/src/components/markdown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 56e2d9d709..7ee73af10f 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -33,6 +33,8 @@ const config = { SANITIZE_NAMED_PROPS: true, FORBID_TAGS: ["style"], FORBID_CONTENTS: ["style", "script"], + ADD_TAGS: ["svg", "path"], + ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns"], } const iconPaths = { From 901d1171a6a3d987aa7e8afe1b8149dd60091e68 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 16:37:10 +0800 Subject: [PATCH 014/812] chore(desktop): add @parcel/watcher platform packages to optionalDependencies (#25996) --- bun.lock | 8 ++++++++ packages/desktop/package.json | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 35075c1441..77ad4d982f 100644 --- a/bun.lock +++ b/bun.lock @@ -271,6 +271,14 @@ "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", }, }, "packages/enterprise": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cbc20b9061..60ccd6cfb6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -63,6 +63,14 @@ "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", - "@lydell/node-pty-win32-x64": "1.2.0-beta.10" + "@lydell/node-pty-win32-x64": "1.2.0-beta.10", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" } } From 043a5c7c0dc43219a0268969baac3deee43008ef Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 6 May 2026 16:40:45 +0800 Subject: [PATCH 015/812] feat(desktop): implement clipboard write permission handling (#25998) --- packages/desktop/src/main/windows.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 387e793b0e..41abfc784d 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -8,6 +8,7 @@ const root = dirname(fileURLToPath(import.meta.url)) const rendererRoot = join(root, "../renderer") const rendererProtocol = "oc" const rendererHost = "renderer" +const clipboardWritePermission = "clipboard-sanitized-write" protocol.registerSchemesAsPrivileged([ { @@ -107,6 +108,8 @@ export function createMainWindow() { }, }) + allowClipboardWrite(win) + win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { const { requestHeaders } = details upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"]) @@ -157,6 +160,8 @@ export function createLoadingWindow() { }, }) + allowClipboardWrite(win) + loadWindow(win, "loading.html") return win @@ -191,6 +196,31 @@ function loadWindow(win: BrowserWindow, html: string) { void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } + +function allowClipboardWrite(win: BrowserWindow) { + win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => { + callback( + permission === clipboardWritePermission && + isTrustedRendererUrl(details.requestingUrl) && + webContents.id === win.webContents.id, + ) + }) + win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { + if (permission !== clipboardWritePermission) return false + if (webContents && webContents.id !== win.webContents.id) return false + return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin) + }) +} + +function isTrustedRendererUrl(value?: string) { + if (!value || !URL.canParse(value)) return false + const url = new URL(value) + if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true + const devUrl = process.env.ELECTRON_RENDERER_URL + if (!devUrl || !URL.canParse(devUrl)) return false + return url.origin === new URL(devUrl).origin +} + function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { From d49d217e9d703c230e569e74c574d4d563aa5268 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 6 May 2026 14:14:31 +0530 Subject: [PATCH 016/812] fix(tui): preserve selected model on refresh (#25993) --- .../src/cli/cmd/tui/context/local.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 0b8c902c49..2958b573dd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -397,23 +397,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, } - // Automatically update model when agent changes createEffect(() => { const value = agent.current() - if (!value) return - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } + if (!value?.model) return + if (isModelValid(value.model)) return + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) }) const result = { From aa3c99a3c0a609ea4dd485355627e3161251584a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 6 May 2026 08:50:56 +0000 Subject: [PATCH 017/812] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index dc4ab9a32e..3792b80503 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Oo27Xkoo5HOzLaRs7FmSobzb1SNyidKIqk1+/BWtcqg=", - "aarch64-linux": "sha256-/d3ukZERWvV7egmc2Rtxg5vroZaXkCs7yVcIjIa4CUE=", - "aarch64-darwin": "sha256-1CX6n+9Wo2vAuPLekGsdjByReHQBbpKHwuK3L7Pfous=", - "x86_64-darwin": "sha256-Jqx3LDSoLSy8em7c/455xLEy9Pn4DmoYLHDemA1i+9w=" + "x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=", + "aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=", + "aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=", + "x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg=" } } From 2abc4507b23834986f08c22b87cf15ff91417782 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 6 May 2026 10:25:42 -0400 Subject: [PATCH 018/812] fix(tui): filter only connected workspaces in dialog; add warp synthetic message (#25915) --- .../tui/component/dialog-workspace-create.tsx | 78 ++++++++++++++----- .../cmd/tui/dialog-workspace-create.test.ts | 38 +++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index e372c59b99..157ca20582 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -33,6 +33,28 @@ export type WorkspaceSelection = type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } type ExistingWorkspaceSelectValue = { workspace: Workspace } +export function recentConnectedWorkspaces(input: { + sessions: readonly { workspaceID?: string; time: { updated: number } }[] + get: (workspaceID: string) => WorkspaceInfo | undefined + status: (workspaceID: string) => string | undefined + limit?: number +}) { + const workspaces = input.sessions + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => { + const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined + return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] + }) + .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) + const recent = workspaces.slice(0, input.limit ?? 3) + + return { recent, hasMore: recent.length < workspaces.length } +} + +export function warpReminderText(dir: string) { + return `The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` +} + async function loadWorkspaceAdapters(input: { sdk: ReturnType sync: ReturnType @@ -77,7 +99,7 @@ export async function warpWorkspaceSession(input: { }): Promise { const result = await input.sdk.client.experimental.workspace .warp({ - id: input.workspaceID ?? undefined, + id: input.workspaceID, sessionID: input.sessionID, }) .catch(() => undefined) @@ -93,10 +115,30 @@ export async function warpWorkspaceSession(input: { await input.sync.bootstrap({ fatal: false }).catch(() => undefined) + const dir = input.project.instance.directory() || input.sync.path.directory + if (dir) { + await input.sdk.client.session + .promptAsync({ + sessionID: input.sessionID, + workspace: input.workspaceID ?? undefined, + noReply: true, + parts: [ + { + type: "text", + text: warpReminderText(dir), + synthetic: true, + }, + ], + }) + .catch(() => undefined) + } + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - input.done?.() - if (input.done) return true + if (input.done) { + input.done() + return true + } input.dialog.clear() return true } @@ -125,15 +167,11 @@ export function DialogWorkspaceSelect(props: { const options = createMemo[]>(() => { const list = adapters() if (!list) return [] - const recent = sync.data.session - .toSorted((a, b) => b.time.updated - a.time.updated) - .flatMap((session) => (session.workspaceID ? [session.workspaceID] : [])) - .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index) - .flatMap((workspaceID) => { - const workspace = project.workspace.get(workspaceID) - return workspace && project.workspace.status(workspace.id) === "connected" ? [workspace] : [] - }) - .slice(0, 3) + const { recent, hasMore } = recentConnectedWorkspaces({ + sessions: sync.data.session, + get: project.workspace.get, + status: project.workspace.status, + }) return [ ...list.map((adapter) => ({ title: adapter.name, @@ -158,12 +196,16 @@ export function DialogWorkspaceSelect(props: { }, category: "Choose workspace", })), - { - title: "View all workspaces", - value: { type: "existing-list" as const }, - description: "Choose from all workspaces", - category: "Choose workspace", - }, + ...(hasMore + ? [ + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] + : []), ] }) diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts new file mode 100644 index 0000000000..7d051923f6 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create" + +describe("recentConnectedWorkspaces", () => { + test("returns unique connected workspaces after filtering missing and inactive entries", () => { + const workspaces = [ + { id: "wrk_a", name: "alpha" }, + { id: "wrk_b", name: "beta" }, + { id: "wrk_c", name: "gamma" }, + { id: "wrk_d", name: "delta" }, + { id: "wrk_e", name: "epsilon" }, + ] + const status = { + wrk_a: "connected", + wrk_b: "disconnected", + wrk_c: "error", + wrk_d: "connected", + wrk_e: "connected", + } as const + + const { recent } = recentConnectedWorkspaces({ + sessions: [ + { time: { updated: 900 } }, + { workspaceID: "wrk_b", time: { updated: 800 } }, + { workspaceID: "wrk_a", time: { updated: 700 } }, + { workspaceID: "wrk_a", time: { updated: 600 } }, + { workspaceID: "wrk_missing", time: { updated: 500 } }, + { workspaceID: "wrk_c", time: { updated: 400 } }, + { workspaceID: "wrk_d", time: { updated: 300 } }, + { workspaceID: "wrk_e", time: { updated: 200 } }, + ], + get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + status: (workspaceID) => status[workspaceID as keyof typeof status], + }) + + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) + }) +}) From 889f979c0ba547842ee6716cc7f38329bff729b7 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Wed, 6 May 2026 16:57:34 +0200 Subject: [PATCH 019/812] chore: fix model alerts (#25990) --- infra/monitoring.ts | 18 ++++++++---------- .../console/app/src/routes/incident/webhook.ts | 6 ++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index f500b099a0..85d68a7c5f 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -178,7 +178,7 @@ new incident.AlertRoute("HoneycombAlertRoute", { reference: $interpolate`alert.attributes.${fields.product.id}`, }, ], - groupingWindowSeconds: 900, + groupingWindowSeconds: 3600, }, incidentTemplate: { name: { @@ -215,7 +215,6 @@ type Trigger = (opts: { model: string; product: Product }) => { description: string json: honeycomb.GetQuerySpecificationOutputArgs threshold: { op: ">=" | "<="; value: number } - baseline: 3600 | 86400 } type Model = { id: string; products: Product[]; triggers: Trigger[] } @@ -232,6 +231,8 @@ const httpErrors: Trigger = ({ model, product }) => ({ filterCombination: "AND", filters: [ { column: "model", op: "=", value: model }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ], }, @@ -241,6 +242,8 @@ const httpErrors: Trigger = ({ model, product }) => ({ filterCombination: "AND", filters: [ { column: "model", op: "=", value: model }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, { column: "status", op: ">=", value: "400" }, { column: "status", op: "!=", value: "401" }, @@ -250,10 +253,7 @@ const httpErrors: Trigger = ({ model, product }) => ({ formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }], timeRange: 900, }, - // Alert when errors surge 50% compared to the previous period - threshold: { op: ">=", value: 50 }, - // What previous time period to evaluate against - baseline: 3600, + threshold: { op: ">=", value: 0.8 }, }) const models: Model[] = [ @@ -296,10 +296,8 @@ for (const model of models) { name: spec.title, description: spec.description, queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json, - alertType: "on_change", - // This is the minimum when using % change detection - frequency: 900, - baselineDetails: [{ type: "percentage", offsetMinutes: spec.baseline / 60 }], + alertType: "on_true", + frequency: 300, thresholds: [{ ...spec.threshold, exceededLimit: 1 }], recipients: [ { diff --git a/packages/console/app/src/routes/incident/webhook.ts b/packages/console/app/src/routes/incident/webhook.ts index 62ee202743..ce7b0a0d9f 100644 --- a/packages/console/app/src/routes/incident/webhook.ts +++ b/packages/console/app/src/routes/incident/webhook.ts @@ -2,6 +2,8 @@ import type { APIEvent } from "@solidjs/start/server" import { Resource } from "@opencode-ai/console-resource" import { Webhook } from "svix" +const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838" + type Incident = { mode?: "test" | "standard" name?: string @@ -37,14 +39,14 @@ const postDiscordMessage = async (incident: Incident) => { `**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`, incident.summary, "", - "@inference", + `<@&${DISCORD_INCIDENT_ROLE_ID}>`, "", incident.permalink, ] .filter((line) => line !== undefined) .join("\n"), allowed_mentions: { - parse: ["everyone"], + roles: [DISCORD_INCIDENT_ROLE_ID], }, flags: 4, }), From 63a175b50de63c52f34cd3fb662f528ceea74b01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 6 May 2026 11:02:08 -0400 Subject: [PATCH 020/812] fix(cli): avoid AppRuntime re-entry for network options (#26052) --- packages/opencode/src/cli/cmd/acp.ts | 2 +- packages/opencode/src/cli/cmd/serve.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 4 ++-- packages/opencode/src/cli/network.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index e24262307c..b3b7df486b 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -22,7 +22,7 @@ export const AcpCommand = effectCmd({ }, handler: Effect.fn("Cli.acp")(function* (args) { process.env.OPENCODE_CLIENT = "acp" - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) const sdk = createOpencodeClient({ diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index a8a7234d9a..76f6276af5 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -15,7 +15,7 @@ export const ServeCommand = effectCmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index f20381a014..384290c6ac 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -40,7 +40,7 @@ export const WebCommand = effectCmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) @@ -72,7 +72,7 @@ export const WebCommand = effectCmd({ } // Open localhost in browser - open(localhostUrl.toString()).catch(() => {}) + open(localhostUrl).catch(() => {}) } else { const displayUrl = server.url.toString() UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index a6cecdfacd..41f8184ef5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,6 +1,6 @@ import type { Argv, InferredOptionTypes } from "yargs" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" const options = { port: { @@ -36,10 +36,10 @@ export type NetworkOptions = InferredOptionTypes export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } -export async function resolveNetworkOptions(args: NetworkOptions) { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) +export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) { + const config = yield* Config.Service.use((cfg) => cfg.getGlobal()) return resolveNetworkOptionsNoConfig(args, config) -} +}) export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Config.Info) { const portExplicitlySet = process.argv.includes("--port") From d9c18381a67da445189d74e230728e737ef161c5 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 6 May 2026 11:12:23 -0400 Subject: [PATCH 021/812] feat(config): support well-known remote_config (#26054) --- packages/opencode/src/config/config.ts | 57 ++++++++++++++- packages/opencode/test/config/config.test.ts | 77 ++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3a933f81e9..6b43b18968 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -70,6 +70,40 @@ function normalizeLoadedConfig(data: unknown, source: string) { return copy } +async function substituteWellKnownRemoteConfig(input: { + value: unknown + dir: string + source: string +}) { + if (!isRecord(input.value) || typeof input.value.url !== "string") return + + const url = await ConfigVariable.substitute({ + text: input.value.url, + type: "virtual", + dir: input.dir, + source: input.source, + }) + const headers = isRecord(input.value.headers) + ? Object.fromEntries( + await Promise.all( + Object.entries(input.value.headers) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(async ([key, value]) => [ + key, + await ConfigVariable.substitute({ + text: value, + type: "virtual", + dir: input.dir, + source: input.source, + }), + ]), + ), + ) + : undefined + + return { url, headers } +} + async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -494,8 +528,27 @@ export const layer = Layer.effect( if (!response.ok) { throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) } - const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record } - const remoteConfig = wellknown.config ?? {} + const wellknown = (yield* Effect.promise(() => response.json())) as { + config?: Record + remote_config?: unknown + } + const remote = yield* Effect.promise(() => + substituteWellKnownRemoteConfig({ + value: wellknown.remote_config, + dir: url, + source: `${url}/.well-known/opencode`, + }), + ) + const fetchedConfig = remote + ? ((yield* Effect.promise(async () => { + log.debug("fetching remote config", { url: remote.url }) + const response = await fetch(remote.url, { headers: remote.headers }) + if (!response.ok) throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) + const data = await response.json() + return isRecord(data) && isRecord(data.config) ? data.config : data + })) as Record) + : {} + const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = `${url}/.well-known/opencode` const next = yield* loadConfig(JSON.stringify(remoteConfig), { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 0a522b0850..bbe585237b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1972,6 +1972,83 @@ test("wellknown URL with trailing slash is normalized", async () => { } }) +test("wellknown remote_config supports templated env vars in headers", async () => { + const originalFetch = globalThis.fetch + const originalToken = process.env.TEST_TOKEN + let wellknownFetchedUrl: string | undefined + let remoteFetchedUrl: string | undefined + let remoteHeaders: HeadersInit | undefined + globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { + const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url + if (urlStr.includes(".well-known/opencode")) { + wellknownFetchedUrl = urlStr + return Promise.resolve( + new Response( + JSON.stringify({ + remote_config: { + url: "https://config.example.com/opencode.json", + headers: { + Authorization: "Bearer {env:TEST_TOKEN}", + }, + }, + }), + { status: 200 }, + ), + ) + } + if (urlStr.includes("config.example.com")) { + remoteFetchedUrl = urlStr + remoteHeaders = init?.headers + return Promise.resolve( + new Response( + JSON.stringify({ + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }), + { status: 200 }, + ), + ) + } + return originalFetch(url, init) + }) as unknown as typeof fetch + + const fakeAuth = Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), + }) + + const layer = Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(fakeAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + ) + + try { + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode") + expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json") + expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" }) + expect(config.mcp?.confluence?.enabled).toBe(true) + }), + ), + { git: true }, + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) + } finally { + globalThis.fetch = originalFetch + if (originalToken === undefined) delete process.env.TEST_TOKEN + else process.env.TEST_TOKEN = originalToken + } +}) + describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() From b9b854bf9f206e5c1c85cfd15d128bb3d0966e58 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 6 May 2026 15:13:34 +0000 Subject: [PATCH 022/812] chore: generate --- packages/opencode/src/config/config.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b43b18968..fcdb4e7b1c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -70,11 +70,7 @@ function normalizeLoadedConfig(data: unknown, source: string) { return copy } -async function substituteWellKnownRemoteConfig(input: { - value: unknown - dir: string - source: string -}) { +async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) { if (!isRecord(input.value) || typeof input.value.url !== "string") return const url = await ConfigVariable.substitute({ @@ -543,7 +539,8 @@ export const layer = Layer.effect( ? ((yield* Effect.promise(async () => { log.debug("fetching remote config", { url: remote.url }) const response = await fetch(remote.url, { headers: remote.headers }) - if (!response.ok) throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) + if (!response.ok) + throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) const data = await response.json() return isRecord(data) && isRecord(data.config) ? data.config : data })) as Record) From 344ccc647b93a71af7a2486f94a6458112e9250f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 6 May 2026 11:45:11 -0500 Subject: [PATCH 023/812] ignore: vimtor to team members list --- .github/TEAM_MEMBERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index e5f8f000e0..a662c7c063 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -13,3 +13,4 @@ R44VC0RP rekram1-node thdxr simonklee +vimtor From 38b0cdc1493930082b9fcc8e855e2985f58bf26e Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 13:10:46 -0400 Subject: [PATCH 024/812] go: deprecate old models --- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 2 -- .../routes/workspace/[id]/go/lite-section.tsx | 2 -- .../app/src/routes/zen/util/keyRateLimiter.ts | 2 +- packages/web/src/content/docs/ar/go.mdx | 16 ++++------------ packages/web/src/content/docs/bs/go.mdx | 16 ++++------------ packages/web/src/content/docs/da/go.mdx | 16 ++++------------ packages/web/src/content/docs/de/go.mdx | 16 ++++------------ packages/web/src/content/docs/es/go.mdx | 16 ++++------------ packages/web/src/content/docs/fr/go.mdx | 16 ++++------------ packages/web/src/content/docs/go.mdx | 16 ++++------------ packages/web/src/content/docs/it/go.mdx | 16 ++++------------ packages/web/src/content/docs/ja/go.mdx | 16 ++++------------ packages/web/src/content/docs/ko/go.mdx | 16 ++++------------ packages/web/src/content/docs/nb/go.mdx | 16 ++++------------ packages/web/src/content/docs/pl/go.mdx | 16 ++++------------ packages/web/src/content/docs/pt-br/go.mdx | 16 ++++------------ packages/web/src/content/docs/ru/go.mdx | 16 ++++------------ packages/web/src/content/docs/th/go.mdx | 16 ++++------------ packages/web/src/content/docs/tr/go.mdx | 16 ++++------------ packages/web/src/content/docs/zh-cn/go.mdx | 16 ++++------------ packages/web/src/content/docs/zh-tw/go.mdx | 16 ++++------------ 38 files changed, 141 insertions(+), 289 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 42258db866..12ec7f1fbd 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -249,7 +249,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2-Pro وMiMo-V2-Omni وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2-Pro وMiMo-V2-Omni وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2-Pro وMiMo-V2-Omni وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2-Pro وMiMo-V2-Omni وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index a848ba38da..0a6d8f153e 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index c54aca32e1..15e7151b67 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 6e14778de8..0efcce78bf 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 0d0869da53..f2cf3c14a4 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -296,7 +296,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index fd13a54de6..5614a8c7ad 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 3762915abf..390025d275 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +353,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 04d0e2451c..3737186996 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 71404c91eb..66f3c4a89d 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 6a7d52bbd5..04482d35f6 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -247,7 +247,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -297,7 +297,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +344,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 629e690c64..31200d3edd 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 0f465df9d9..50d904bc56 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 90019dbe54..651309fc95 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 9f210ada49..42c9e455fd 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 3d2f8f39de..64380db375 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index fdcb7d37a0..3b104cca6d 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2-Pro, MiMo-V2-Omni, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index bfbfcf7e81..a4d5512da4 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2-Pro、MiMo-V2-Omni、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 1ec83b25fe..71102c7227 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -27,8 +27,6 @@ const models = [ { name: "GLM-5", provider: "DeepInfra, Fireworks AI, Z.ai" }, { name: "Kimi K2.5", provider: "Moonshot AI" }, { name: "Kimi K2.6", provider: "Moonshot AI" }, - { name: "MiMo-V2-Pro", provider: "Xiaomi MiMo" }, - { name: "MiMo-V2-Omni", provider: "Xiaomi MiMo" }, { name: "MiMo-V2.5-Pro", provider: "Xiaomi MiMo" }, { name: "MiMo-V2.5", provider: "Xiaomi MiMo" }, { name: "Qwen3.5 Plus", provider: "Alibaba Cloud Model Studio" }, diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 0df181ae16..eba52b0e17 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -289,8 +289,6 @@ export function LiteSection() {
  • Kimi K2.6
  • GLM-5
  • GLM-5.1
  • -
  • MiMo-V2-Pro
  • -
  • MiMo-V2-Omni
  • MiMo-V2.5-Pro
  • MiMo-V2.5
  • MiniMax M2.5
  • diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index e3e0fb18f2..2472776caa 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -8,7 +8,7 @@ export function createRateLimiter(modelId: string, zenApiKey: string | undefined if (!zenApiKey) return const dict = i18n(localeFromRequest(request)) - const LIMIT = 100 + const LIMIT = 300 const yyyyMMddHHmm = new Date(Date.now()) .toISOString() .replace(/[^0-9]/g, "") diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index 35c52d9695..81f885335c 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -57,10 +57,8 @@ OpenCode Go حاليًا في المرحلة التجريبية. - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Go حاليًا في المرحلة التجريبية. | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | @@ -110,10 +106,8 @@ OpenCode Go حاليًا في المرحلة التجريبية. - MiniMax M2.7/M2.5 — ‏300 input، و55,000 cached، و125 output tokens لكل طلب - Qwen3.5 Plus — ‏410 input، و47,000 cached، و140 output tokens لكل طلب - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب -- MiMo-V2-Pro — ‏350 input، و41,000 cached، و250 output tokens لكل طلب -- MiMo-V2-Omni — ‏1000 input، و60,000 cached، و140 output tokens لكل طلب -- MiMo-V2.5-Pro — ‏350 input، و41,000 cached، و250 output tokens لكل طلب - MiMo-V2.5 — ‏1000 input، و60,000 cached، و140 output tokens لكل طلب +- MiMo-V2.5-Pro — ‏350 input، و41,000 cached، و250 output tokens لكل طلب يمكنك تتبّع استخدامك الحالي في **console**. @@ -143,10 +137,8 @@ OpenCode Go حاليًا في المرحلة التجريبية. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index a895a20941..d2df6aaad8 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -67,10 +67,8 @@ Trenutna lista modela uključuje: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: - MiniMax M2.7/M2.5 — 300 ulaznih, 55,000 keširanih, 125 izlaznih tokena po zahtjevu - Qwen3.5 Plus — 410 ulaznih, 47,000 keširanih, 140 izlaznih tokena po zahtjevu - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu -- MiMo-V2-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu -- MiMo-V2-Omni — 1000 ulaznih, 60,000 keširanih, 140 izlaznih tokena po zahtjevu -- MiMo-V2.5-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu - MiMo-V2.5 — 1000 ulaznih, 60,000 keširanih, 140 izlaznih tokena po zahtjevu +- MiMo-V2.5-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu Svoju trenutnu potrošnju možete pratiti u **konzoli**. @@ -155,10 +149,8 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index db61689a28..6891e6d579 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -67,10 +67,8 @@ Den nuværende liste over modeller inkluderer: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: - MiniMax M2.7/M2.5 — 300 input, 55.000 cachelagrede, 125 output-tokens pr. anmodning - Qwen3.5 Plus — 410 input, 47.000 cachelagrede, 140 output-tokens pr. anmodning - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning -- MiMo-V2-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning -- MiMo-V2-Omni — 1000 input, 60.000 cachelagrede, 140 output-tokens pr. anmodning -- MiMo-V2.5-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning - MiMo-V2.5 — 1000 input, 60.000 cachelagrede, 140 output-tokens pr. anmodning +- MiMo-V2.5-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning Du kan spore dit nuværende forbrug i **konsollen**. @@ -155,10 +149,8 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index a8da54728d..917ea340ef 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -59,10 +59,8 @@ Die aktuelle Liste der Modelle umfasst: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -92,10 +90,8 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -112,10 +108,8 @@ Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: - MiniMax M2.7/M2.5 — 300 Input-, 55.000 Cached-, 125 Output-Tokens pro Anfrage - Qwen3.5 Plus — 410 Input-, 47.000 Cached-, 140 Output-Tokens pro Anfrage - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage -- MiMo-V2-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage -- MiMo-V2-Omni — 1.000 Input-, 60.000 Cached-, 140 Output-Tokens pro Anfrage -- MiMo-V2.5-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage - MiMo-V2.5 — 1.000 Input-, 60.000 Cached-, 140 Output-Tokens pro Anfrage +- MiMo-V2.5-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage Du kannst deine aktuelle Nutzung in der **Console** verfolgen. @@ -145,10 +139,8 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index becff7ac04..0be23b3fa4 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -67,10 +67,8 @@ La lista actual de modelos incluye: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ Las estimaciones se basan en los patrones de peticiones promedio observados: - MiniMax M2.7/M2.5 — 300 tokens de entrada, 55,000 en caché, 125 tokens de salida por petición - Qwen3.5 Plus — 410 tokens de entrada, 47,000 en caché, 140 tokens de salida por petición - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición -- MiMo-V2-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición -- MiMo-V2-Omni — 1000 tokens de entrada, 60,000 en caché, 140 tokens de salida por petición -- MiMo-V2.5-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición - MiMo-V2.5 — 1000 tokens de entrada, 60,000 en caché, 140 tokens de salida por petición +- MiMo-V2.5-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición Puedes realizar un seguimiento de tu uso actual en la **consola**. @@ -155,10 +149,8 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 97280fa372..3dd9c25f32 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -57,10 +57,8 @@ La liste actuelle des modèles comprend : - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -110,10 +106,8 @@ Les estimations sont basées sur les modèles de requêtes moyens observés : - MiniMax M2.7/M2.5 — 300 tokens en entrée, 55,000 en cache, 125 tokens en sortie par requête - Qwen3.5 Plus — 410 tokens en entrée, 47,000 en cache, 140 tokens en sortie par requête - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête -- MiMo-V2-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête -- MiMo-V2-Omni — 1000 tokens en entrée, 60,000 en cache, 140 tokens en sortie par requête -- MiMo-V2.5-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête - MiMo-V2.5 — 1000 tokens en entrée, 60,000 en cache, 140 tokens en sortie par requête +- MiMo-V2.5-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête Vous pouvez suivre votre utilisation actuelle dans la **console**. @@ -143,10 +137,8 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index cddb6d491b..237d1c4b84 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -67,10 +67,8 @@ The current list of models includes: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **MiniMax M2.7** - **Qwen3.5 Plus** @@ -100,10 +98,8 @@ The table below provides an estimated request count based on typical Go usage pa | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -118,10 +114,8 @@ Estimates are based on observed average request patterns: - DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens per request - DeepSeek V4 Flash — 790 input, 68,000 cached, 280 output tokens per request - MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens per request -- MiMo-V2-Pro — 350 input, 41,000 cached, 250 output tokens per request -- MiMo-V2-Omni — 1000 input, 60,000 cached, 140 output tokens per request -- MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens per request - MiMo-V2.5 — 1000 input, 60,000 cached, 140 output tokens per request +- MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens per request - Qwen3.5 Plus — 410 input, 47,000 cached, 140 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request @@ -155,10 +149,8 @@ You can also access Go models through the following API endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 28f8c5fbf8..df4f6dd1ca 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -65,10 +65,8 @@ L'elenco attuale dei modelli include: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -98,10 +96,8 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -118,10 +114,8 @@ Le stime si basano sui pattern medi di richieste osservati: - MiniMax M2.7/M2.5 — 300 di input, 55.000 in cache, 125 token di output per richiesta - Qwen3.5 Plus — 410 di input, 47.000 in cache, 140 token di output per richiesta - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta -- MiMo-V2-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta -- MiMo-V2-Omni — 1000 di input, 60.000 in cache, 140 token di output per richiesta -- MiMo-V2.5-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta - MiMo-V2.5 — 1000 di input, 60.000 in cache, 140 token di output per richiesta +- MiMo-V2.5-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta Puoi monitorare il tuo utilizzo attuale nella **console**. @@ -153,10 +147,8 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 5f4fcfbc39..0cb294754f 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -57,10 +57,8 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Goには以下の制限が含まれています: | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -110,10 +106,8 @@ OpenCode Goには以下の制限が含まれています: - MiniMax M2.7/M2.5 — リクエストあたり 入力 300トークン、キャッシュ 55,000トークン、出力 125トークン - Qwen3.5 Plus — リクエストあたり 入力 410トークン、キャッシュ 47,000トークン、出力 140トークン - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン -- MiMo-V2-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン -- MiMo-V2-Omni — リクエストあたり 入力 1000トークン、キャッシュ 60,000トークン、出力 140トークン -- MiMo-V2.5-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン - MiMo-V2.5 — リクエストあたり 入力 1000トークン、キャッシュ 60,000トークン、出力 140トークン +- MiMo-V2.5-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン 現在の利用状況は**コンソール**で追跡できます。 @@ -143,10 +137,8 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance* | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index ef05b01c49..d0a3b9d0d1 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -57,10 +57,8 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -110,10 +106,8 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - MiniMax M2.7/M2.5 — 요청당 입력 300, 캐시 55,000, 출력 토큰 125 - Qwen3.5 Plus — 요청당 입력 410, 캐시 47,000, 출력 토큰 140 - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 -- MiMo-V2-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250 -- MiMo-V2-Omni — 요청당 입력 1000, 캐시 60,000, 출력 토큰 140 -- MiMo-V2.5-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250 - MiMo-V2.5 — 요청당 입력 1000, 캐시 60,000, 출력 토큰 140 +- MiMo-V2.5-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250 현재 사용량은 **console**에서 확인할 수 있습니다. @@ -143,10 +137,8 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 02a2ba9e0b..e19b6ccce1 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -67,10 +67,8 @@ Den nåværende listen over modeller inkluderer: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: - MiniMax M2.7/M2.5 — 300 input, 55 000 bufret, 125 output-tokens per forespørsel - Qwen3.5 Plus — 410 input, 47 000 bufret, 140 output-tokens per forespørsel - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel -- MiMo-V2-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel -- MiMo-V2-Omni — 1000 input, 60 000 bufret, 140 output-tokens per forespørsel -- MiMo-V2.5-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel - MiMo-V2.5 — 1000 input, 60 000 bufret, 140 output-tokens per forespørsel +- MiMo-V2.5-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel Du kan spore din nåværende bruk i **konsollen**. @@ -155,10 +149,8 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 224671a19a..00f76a103f 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -61,10 +61,8 @@ Obecna lista modeli obejmuje: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -94,10 +92,8 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -114,10 +110,8 @@ Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: - MiniMax M2.7/M2.5 — 300 tokenów wejściowych, 55 000 w pamięci podręcznej, 125 tokenów wyjściowych na żądanie - Qwen3.5 Plus — 410 tokenów wejściowych, 47 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie -- MiMo-V2-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie -- MiMo-V2-Omni — 1000 tokenów wejściowych, 60 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie -- MiMo-V2.5-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie - MiMo-V2.5 — 1000 tokenów wejściowych, 60 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie +- MiMo-V2.5-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie Możesz śledzić swoje bieżące zużycie w **konsoli**. @@ -147,10 +141,8 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index e50f7d3962..44c5092a00 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -67,10 +67,8 @@ A lista atual de modelos inclui: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ As estimativas baseiam-se nos padrões médios de requisições observados: - MiniMax M2.7/M2.5 — 300 tokens de entrada, 55.000 em cache, 125 tokens de saída por requisição - Qwen3.5 Plus — 410 tokens de entrada, 47.000 em cache, 140 tokens de saída por requisição - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição -- MiMo-V2-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição -- MiMo-V2-Omni — 1000 tokens de entrada, 60.000 em cache, 140 tokens de saída por requisição -- MiMo-V2.5-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição - MiMo-V2.5 — 1000 tokens de entrada, 60.000 em cache, 140 tokens de saída por requisição +- MiMo-V2.5-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição Você pode acompanhar o seu uso atual no **console**. @@ -155,10 +149,8 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 4f11204e1a..66e929c5f4 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -67,10 +67,8 @@ OpenCode Go работает так же, как и любой другой пр - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -100,10 +98,8 @@ OpenCode Go включает следующие лимиты: | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -120,10 +116,8 @@ OpenCode Go включает следующие лимиты: - MiniMax M2.7/M2.5 — 300 входных, 55,000 кешированных, 125 выходных токенов на запрос - Qwen3.5 Plus — 410 входных, 47,000 кешированных, 140 выходных токенов на запрос - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос -- MiMo-V2-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос -- MiMo-V2-Omni — 1000 входных, 60,000 кешированных, 140 выходных токенов на запрос -- MiMo-V2.5-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос - MiMo-V2.5 — 1000 входных, 60,000 кешированных, 140 выходных токенов на запрос +- MiMo-V2.5-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос Вы можете отслеживать текущее использование в **консоли**. @@ -155,10 +149,8 @@ OpenCode Go включает следующие лимиты: | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 2a4c90a840..1fa0f8cc2a 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -57,10 +57,8 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -110,10 +106,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - MiniMax M2.7/M2.5 — 300 input, 55,000 cached, 125 output tokens ต่อ request - Qwen3.5 Plus — 410 input, 47,000 cached, 140 output tokens ต่อ request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request -- MiMo-V2-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request -- MiMo-V2-Omni — 1000 input, 60,000 cached, 140 output tokens ต่อ request -- MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request - MiMo-V2.5 — 1000 input, 60,000 cached, 140 output tokens ต่อ request +- MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** @@ -143,10 +137,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index b3995e8a57..367be5a750 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -57,10 +57,8 @@ Mevcut model listesi şunları içerir: - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | @@ -110,10 +106,8 @@ Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: - MiniMax M2.7/M2.5 — İstek başına 300 girdi, 55.000 önbelleğe alınmış, 125 çıktı token'ı - Qwen3.5 Plus — İstek başına 410 girdi, 47.000 önbelleğe alınmış, 140 çıktı token'ı - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı -- MiMo-V2-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı -- MiMo-V2-Omni — İstek başına 1000 girdi, 60.000 önbelleğe alınmış, 140 çıktı token'ı -- MiMo-V2.5-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı - MiMo-V2.5 — İstek başına 1000 girdi, 60.000 önbelleğe alınmış, 140 çıktı token'ı +- MiMo-V2.5-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. @@ -143,10 +137,8 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 8bd90d5fbf..17934ee2a0 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -57,10 +57,8 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Go 包含以下限制: | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | @@ -107,10 +103,8 @@ OpenCode Go 包含以下限制: - Kimi K2.5/K2.6 — 每次请求 870 个输入 token,55,000 个缓存 token,200 个输出 token - DeepSeek V4 Pro — 每次请求 750 个输入 token,82,000 个缓存 token,290 个输出 token - DeepSeek V4 Flash — 每次请求 790 个输入 token,68,000 个缓存 token,280 个输出 token -- MiMo-V2-Pro — 每次请求 350 个输入 token,41,000 个缓存 token,250 个输出 token -- MiMo-V2-Omni — 每次请求 1000 个输入 token,60,000 个缓存 token,140 个输出 token -- MiMo-V2.5-Pro — 每次请求 350 个输入 token,41,000 个缓存 token,250 个输出 token - MiMo-V2.5 — 每次请求 1000 个输入 token,60,000 个缓存 token,140 个输出 token +- MiMo-V2.5-Pro — 每次请求 350 个输入 token,41,000 个缓存 token,250 个输出 token - MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token - Qwen3.5 Plus — 每次请求 410 个输入 token,47,000 个缓存 token,140 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token @@ -143,10 +137,8 @@ OpenCode Go 包含以下限制: | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 3bf4618bc5..c4589716f2 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -57,10 +57,8 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **GLM-5.1** - **Kimi K2.5** - **Kimi K2.6** -- **MiMo-V2-Pro** -- **MiMo-V2-Omni** -- **MiMo-V2.5-Pro** - **MiMo-V2.5** +- **MiMo-V2.5-Pro** - **MiniMax M2.5** - **Qwen3.5 Plus** - **Qwen3.6 Plus** @@ -90,10 +88,8 @@ OpenCode Go 包含以下限制: | GLM-5 | 1,150 | 2,880 | 5,750 | | Kimi K2.5 | 1,850 | 4,630 | 9,250 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | -| MiMo-V2-Pro | 1,290 | 3,225 | 6,450 | -| MiMo-V2-Omni | 2,150 | 5,450 | 10,900 | -| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | MiMo-V2.5 (≤ 256K) | 2,150 | 5,450 | 10,900 | +| MiMo-V2.5-Pro | 1,290 | 3,225 | 6,450 | | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | MiniMax M2.7 | 3,400 | 8,500 | 17,000 | | MiniMax M2.5 | 6,300 | 15,900 | 31,800 | @@ -110,10 +106,8 @@ OpenCode Go 包含以下限制: - MiniMax M2.7/M2.5 — 每次請求 300 個輸入 token、55,000 個快取 token、125 個輸出 token - Qwen3.5 Plus — 每次請求 410 個輸入 token、47,000 個快取 token、140 個輸出 token - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token -- MiMo-V2-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token -- MiMo-V2-Omni — 每次請求 1000 個輸入 token、60,000 個快取 token、140 個輸出 token -- MiMo-V2.5-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token - MiMo-V2.5 — 每次請求 1000 個輸入 token、60,000 個快取 token、140 個輸出 token +- MiMo-V2.5-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token 您可以在 **console** 中追蹤您目前的使用量。 @@ -143,10 +137,8 @@ OpenCode Go 包含以下限制: | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Pro | mimo-v2-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2-Omni | mimo-v2-omni | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | From bf979413f9f5f0f420ab43ac6c55341236438285 Mon Sep 17 00:00:00 2001 From: vimtor Date: Wed, 6 May 2026 19:26:53 +0200 Subject: [PATCH 025/812] chore: change alert type for honeycomb triggers --- infra/monitoring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 85d68a7c5f..4fb7183a2f 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -296,7 +296,7 @@ for (const model of models) { name: spec.title, description: spec.description, queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json, - alertType: "on_true", + alertType: "on_change", frequency: 300, thresholds: [{ ...spec.threshold, exceededLimit: 1 }], recipients: [ From e41843eaf7772985ebbf37c4c1419ed30f4c16c6 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 15:20:36 -0400 Subject: [PATCH 026/812] sync --- .../console/app/src/routes/workspace/[id]/model-section.tsx | 1 + packages/console/app/src/routes/zen/v1/models.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index b9cdf3bc3a..35ea2cf878 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -45,6 +45,7 @@ const getModelsInfo = query(async (workspaceID: string) => { all: Object.entries(ZenData.list("full").models) .filter(([id, _model]) => !["claude-3-5-haiku"].includes(id)) .filter(([id, _model]) => !id.startsWith("alpha-")) + .filter(([id, _model]) => !id.endsWith(":global")) .sort(([idA, modelA], [idB, modelB]) => { const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"] const getPriority = (id: string) => { diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index 794f85029a..68c3cac694 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -28,7 +28,9 @@ export async function GET(input: APIEvent) { ) })() - const models = Object.keys(ZenData.list("full").models).filter((id) => !disabledModels.includes(id)) + const models = Object.keys(ZenData.list("full").models) + .filter((id) => !id.endsWith(":global")) + .filter((id) => !disabledModels.includes(id)) return buildModelsResponse(models) } From a4ab1408ebee6f4fc1e7d5439fd41e1719c33265 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 15:32:08 -0400 Subject: [PATCH 027/812] zen: update rate limiter --- packages/console/app/src/routes/zen/util/handler.ts | 2 +- .../console/app/src/routes/zen/util/keyRateLimiter.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 7f36246ee5..16f9174325 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -116,7 +116,7 @@ export async function handler( const trialProviders = await trialLimiter?.check() const rateLimiter = modelInfo.allowAnonymous ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request) - : createKeyRateLimiter(modelInfo.id, zenApiKey, input.request) + : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) await rateLimiter?.check() const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyProvider = await stickyTracker?.get() diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index 2472776caa..0bf495f7db 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -4,11 +4,16 @@ import { RateLimitError } from "./error" import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" -export function createRateLimiter(modelId: string, zenApiKey: string | undefined, request: Request) { +export function createRateLimiter( + modelId: string, + rateLimit: number | undefined, + zenApiKey: string | undefined, + request: Request, +) { if (!zenApiKey) return const dict = i18n(localeFromRequest(request)) - const LIMIT = 300 + const LIMIT = rateLimit ?? 300 const yyyyMMddHHmm = new Date(Date.now()) .toISOString() .replace(/[^0-9]/g, "") From 2dffdfff4aa02d5c4df128035d0bfce2fd309ebd Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 7 May 2026 08:55:09 +1000 Subject: [PATCH 028/812] fix(server): apply cors before legacy auth (#26092) --- packages/opencode/src/server/server.ts | 6 +++--- packages/opencode/test/server/httpapi-cors.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ca86599955..bc09667c29 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -107,10 +107,10 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) - .use(AuthMiddleware) - .use(LoggerMiddleware(backendAttributes)) - .use(CompressionMiddleware) .use(CorsMiddleware(opts)) + .use(LoggerMiddleware(backendAttributes)) + .use(AuthMiddleware) + .use(CompressionMiddleware) .route("/global", GlobalRoutes()) const runtime = adapter.create(app) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 72265ad9bd..8d7e95dfbf 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -63,6 +63,19 @@ describe("HttpApi CORS", () => { }), ) + it.live("adds CORS headers to legacy unauthorized responses", () => + Effect.gen(function* () { + const response = yield* Effect.promise(async () => + Server.Legacy().app.request("/global/config", { + headers: { origin: "https://app.opencode.ai" }, + }), + ) + + expect(response.status).toBe(401) + expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + }), + ) + it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease( From 233fc5b91017b119cca046b892d6dc39c233c0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Thu, 7 May 2026 00:57:56 +0100 Subject: [PATCH 029/812] fix(provider): preserve assistant message content when reasoning blocks present (#21370) Co-authored-by: Omer Koren <54630488+omer-koren@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/session/message-v2.ts | 22 +++- .../opencode/test/session/message-v2.test.ts | 102 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 237fb527c0..ed09262d0e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -854,13 +854,31 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + // Anthropic adaptive thinking can persist assistant turns like: + // step-start, reasoning(signature), text(""), step-start, + // reasoning(signature). The empty text part is a structural separator, + // but it does not carry the signature metadata itself. Dropping it shifts + // signed thinking positions after step-start splitting/provider regrouping; + // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. + // It is unclear whether this shape originates in our stream processing, + // a proxy, or a lower-level library, but preserving a non-empty separator + // here is the only safe replay point we have. + // Use a single space so the separator survives replay without changing + // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores + // the same signature under the bedrock metadata namespace. + const hasSignedReasoning = msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + }) for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text") { + const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", - text: part.text, + text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) + } if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a7853be0b8..999b61b48e 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1098,6 +1098,108 @@ describe("session.message-v2.toModelMessage", () => { }, ]) }) + + test("substitutes space for empty text between signed reasoning blocks", async () => { + // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] + const assistantID = "m-assistant" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "step-start" }, + { + ...basePart(assistantID, "p2"), + type: "reasoning", + text: "thinking-one", + metadata: { anthropic: { signature: "sig1" } }, + }, + { ...basePart(assistantID, "p3"), type: "text", text: "" }, + { ...basePart(assistantID, "p4"), type: "step-start" }, + { + ...basePart(assistantID, "p5"), + type: "reasoning", + text: "thinking-two", + metadata: { anthropic: { signature: "sig2" } }, + }, + { ...basePart(assistantID, "p6"), type: "text", text: "the answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later + expect(result).toHaveLength(2) + expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ") + expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") + }) + + test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { + // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + const assistantID = "m-assistant-bedrock" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "thinking-bedrock", + metadata: { bedrock: { signature: "bedrock-sig" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + }) + + test("leaves empty text alone when reasoning has no Anthropic signature", async () => { + // Non-Anthropic providers' reasoning doesn't position-validate, so empty text + // should be filtered normally rather than substituted. + const assistantID = "m-assistant-unsigned" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" }, + { ...basePart(assistantID, "p2"), type: "text", text: "" }, + { ...basePart(assistantID, "p3"), type: "text", text: "answer" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) + }) + + test("leaves empty text alone in assistant messages without reasoning", async () => { + const assistantID = "m-assistant-no-reasoning" + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { ...basePart(assistantID, "p1"), type: "text", text: "" }, + { ...basePart(assistantID, "p2"), type: "text", text: "hello" }, + ] as MessageV2.Part[], + }, + ] + + const result = await MessageV2.toModelMessages(input, model) + + expect(result).toHaveLength(1) + const texts = (result[0].content as any[]).filter((p) => p.type === "text") + expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"]) + }) }) describe("session.message-v2.fromError", () => { From b2e3dc87ead239049b190973f7de05d0262e3eed Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 6 May 2026 19:33:52 -0500 Subject: [PATCH 030/812] feat: Update ACP support, modernize and fix misc issues (#25663) --- bun.lock | 4 +- packages/opencode/package.json | 2 +- packages/opencode/src/acp/agent.ts | 255 +++++++++++++----- packages/opencode/src/acp/session.ts | 6 + .../opencode/test/acp/agent-interface.test.ts | 5 +- 5 files changed, 197 insertions(+), 75 deletions(-) diff --git a/bun.lock b/bun.lock index 77ad4d982f..bcf1405a9f 100644 --- a/bun.lock +++ b/bun.lock @@ -335,7 +335,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.16.1", + "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", @@ -728,7 +728,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index db42557616..3126804ae0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -80,7 +80,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.16.1", + "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.96", "@ai-sdk/anthropic": "3.0.71", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d66c1b2583..ad930680d1 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -5,6 +5,8 @@ import { type AuthenticateRequest, type AuthMethod, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type ForkSessionRequest, type ForkSessionResponse, type InitializeRequest, @@ -565,6 +567,7 @@ export class Agent implements ACPAgent { image: true, }, sessionCapabilities: { + close: {}, fork: {}, list: {}, resume: {}, @@ -627,6 +630,9 @@ export class Agent implements ACPAgent { // Store ACP session state await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) const result = await this.loadSessionMode({ @@ -635,39 +641,6 @@ export class Agent implements ACPAgent { sessionId, }) - // Replay session history - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { - result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { - result.modes.currentModeId = lastUser.agent - this.sessionManager.setMode(sessionId, lastUser.agent) - } - result.configOptions = buildConfigOptions({ - currentModelId: result.models.currentModelId, - availableModels: result.models.availableModels, - modes: result.modes, - }) - } - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -756,6 +729,9 @@ export class Agent implements ACPAgent { const sessionId = forked.id await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) const mode = await this.loadSessionMode({ @@ -764,20 +740,6 @@ export class Agent implements ACPAgent { sessionId, }) - const messages = await this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined - }) - for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) @@ -797,7 +759,7 @@ export class Agent implements ACPAgent { } } - async unstable_resumeSession(params: ResumeSessionRequest): Promise { + async resumeSession(params: ResumeSessionRequest): Promise { const directory = params.cwd const sessionId = params.sessionId const mcpServers = params.mcpServers ?? [] @@ -806,6 +768,9 @@ export class Agent implements ACPAgent { const model = await defaultModel(this.config, directory) await this.sessionManager.load(sessionId, directory, mcpServers, model) + const messages = await this.loadSessionMessages(directory, sessionId, 20) + this.restoreSessionStateFromMessages(sessionId, messages) + log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) const result = await this.loadSessionMode({ @@ -828,6 +793,27 @@ export class Agent implements ACPAgent { } } + async closeSession(params: CloseSessionRequest): Promise { + const session = this.sessionManager.remove(params.sessionId) + if (!session) return {} + + await this.sdk.session + .abort( + { + sessionID: params.sessionId, + directory: session.cwd, + }, + { throwOnError: true }, + ) + .catch((error) => { + log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) + }) + + this.permissionQueues.delete(params.sessionId) + log.info("close_session", { sessionId: params.sessionId }) + return {} + } + private async processMessage(message: SessionMessageResponse) { log.debug("process message", message) if (message.info.role !== "assistant" && message.info.role !== "user") return @@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent { sessionId: string, ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { const availableModes = await this.loadAvailableModes(directory) - const currentModeId = - this.sessionManager.get(sessionId).modeId || - (await (async () => { - if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })()) + const storedModeId = this.sessionManager.get(sessionId).modeId + if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { + return { availableModes, currentModeId: storedModeId } + } + + const currentModeId = await (async () => { + if (!availableModes.length) return undefined + const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + this.sessionManager.setMode(sessionId, resolvedModeId) + return resolvedModeId + })() return { availableModes, currentModeId } } private async loadSessionMode(params: LoadSessionRequest) { const directory = params.cwd - const model = await defaultModel(this.config, directory) const sessionId = params.sessionId + const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) const entries = sortProvidersByName(providers) @@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent { if (currentVariant && !availableVariants.includes(currentVariant)) { this.sessionManager.setVariant(sessionId, undefined) } - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(directory, sessionId) const currentModeId = modeState.currentModeId const modes = currentModeId @@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent { return { sessionId, models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, }, modes, configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true), + currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), availableModels, + currentVariant, + availableVariants, modes, }), _meta: buildVariantMeta({ @@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent { const entries = sortProvidersByName(providers) const availableVariants = modelVariantsFromProviders(entries, selection.model) + const modeState = await this.resolveModeState(session.cwd, session.id) + const modes = modeState.currentModeId + ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } + : undefined + + await this.connection.sessionUpdate({ + sessionId: session.id, + update: { + sessionUpdate: "config_option_update", + configOptions: buildConfigOptions({ + currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), + availableModels: buildAvailableModels(entries), + currentVariant: selection.variant, + availableVariants, + modes, + }), + }, + }) return { _meta: buildVariantMeta({ @@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent { const selection = parseModelSelection(params.value, providers) this.sessionManager.setModel(session.id, selection.model) this.sessionManager.setVariant(session.id, selection.variant) + } else if (params.configId === "effort") { + if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") + const current = session.model ?? (await defaultModel(this.config, session.cwd)) + const availableVariants = modelVariantsFromProviders(entries, current) + if (!availableVariants.includes(params.value)) { + throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) + } + this.sessionManager.setVariant(session.id, params.value) } else if (params.configId === "mode") { if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") const availableModes = await this.loadAvailableModes(session.cwd) @@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent { const updatedSession = this.sessionManager.get(session.id) const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true) - const availableModels = buildAvailableModels(entries, { includeVariants: true }) + const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) + const availableModels = buildAvailableModels(entries) const modeState = await this.resolveModeState(session.cwd, session.id) const modes = modeState.currentModeId ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } : undefined return { - configOptions: buildConfigOptions({ currentModelId, availableModels, modes }), + configOptions: buildConfigOptions({ + currentModelId, + availableModels, + currentVariant: updatedSession.variant, + availableVariants, + modes, + }), } } @@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent { { throwOnError: true }, ) } + + private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { + return this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + limit, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + } + + private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { + const lastUser = messages?.findLast((message) => message.info.role === "user")?.info + if (lastUser?.role !== "user") return + + this.sessionManager.setModel(sessionId, { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + }) + this.sessionManager.setVariant(sessionId, lastUser.model.variant) + if (lastUser.agent) { + this.sessionManager.setMode(sessionId, lastUser.agent) + } + } } function toToolKind(toolName: string): ToolKind { @@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider if (specified && !providers.length) return specified + const lastUsed = await lastUsedModel(sdk, directory, providers) + if (lastUsed) return lastUsed + const opencodeProvider = providers.find((p) => p.id === "opencode") if (opencodeProvider) { - if (opencodeProvider.models["big-pickle"]) { - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } - } const [best] = Provider.sort(Object.values(opencodeProvider.models)) if (best) { return { @@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider } if (specified) return specified + throw new Error("No models available") +} - return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } +async function lastUsedModel( + sdk: OpencodeClient, + directory: string, + providers: Array<{ id: string; models: Record }>, +): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { + const session = await sdk.session + .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) + .then((x) => x.data?.[0]) + .catch((error) => { + log.error("failed to list sessions for default model", { error }) + return undefined + }) + if (!session) return + + const lastUser = await sdk.session + .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) + .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) + .catch((error) => { + log.error("failed to load session messages for default model", { error, sessionID: session.id }) + return undefined + }) + if (lastUser?.role !== "user") return + + const provider = providers.find((entry) => entry.id === lastUser.model.providerID) + if (!provider?.models[lastUser.model.modelID]) return + return { + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), + } } function parseUri( @@ -1757,8 +1841,14 @@ function formatModelIdWithVariant( includeVariant: boolean, ) { const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || !variant || !availableVariants.includes(variant)) return base - return `${base}/${variant}` + if (!includeVariant || availableVariants.length === 0) return base + const selectedVariant = + variant && availableVariants.includes(variant) + ? variant + : availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : availableVariants[0] + return `${base}/${selectedVariant}` } function buildVariantMeta(input: { @@ -1810,6 +1900,8 @@ function parseModelSelection( function buildConfigOptions(input: { currentModelId: string availableModels: ModelOption[] + currentVariant?: string + availableVariants?: string[] modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined }): SessionConfigOption[] { const options: SessionConfigOption[] = [ @@ -1822,6 +1914,22 @@ function buildConfigOptions(input: { options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), }, ] + if (input.availableVariants?.length) { + options.push({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "thought_level", + type: "select", + currentValue: + input.currentVariant && input.availableVariants.includes(input.currentVariant) + ? input.currentVariant + : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) + ? DEFAULT_VARIANT_VALUE + : input.availableVariants[0], + options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), + }) + } if (input.modes) { options.push({ id: "mode", @@ -1839,4 +1947,11 @@ function buildConfigOptions(input: { return options } +function formatVariantName(variant: string) { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" ") +} + export * as ACP from "./agent" diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index d932b65701..cc1ed0be30 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -113,4 +113,10 @@ export class ACPSessionManager { this.sessions.set(sessionId, session) return session } + + remove(sessionId: string): ACPSessionState | undefined { + const session = this.sessions.get(sessionId) + this.sessions.delete(sessionId) + return session + } } diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts index 9fa67de829..7c4633d7d8 100644 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ b/packages/opencode/test/acp/agent-interface.test.ts @@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => { "loadSession", "setSessionMode", "authenticate", - // Unstable - SDK checks these with unstable_ prefix + // Capability-gated methods checked by the SDK router "listSessions", + "resumeSession", + "closeSession", "unstable_forkSession", - "unstable_resumeSession", "unstable_setSessionModel", ] From dcfe4b0d5184cb93dd2232f1461641d6530e1abb Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 7 May 2026 00:34:09 +0000 Subject: [PATCH 031/812] sync release versions for v1.14.40 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index bcf1405a9f..d481de8e83 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.39", + "version": "1.14.40", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -312,7 +312,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -328,7 +328,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.39", + "version": "1.14.40", "bin": { "opencode": "./bin/opencode", }, @@ -470,7 +470,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -505,7 +505,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "cross-spawn": "catalog:", }, @@ -520,7 +520,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -555,7 +555,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -604,7 +604,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index def3f65fc2..45908e45b8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.39", + "version": "1.14.40", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 78a4a1fd44..71d37d1553 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index bdfc576fb9..c1acfab6e0 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.39", + "version": "1.14.40", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index dc56d8bc29..9c0ce79d74 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.39", + "version": "1.14.40", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1600bb877d..d9648b3243 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.39", + "version": "1.14.40", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 88136cb51a..9d92e96e1d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.39", + "version": "1.14.40", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 60ccd6cfb6..431de79bc5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 49509aa075..867d2155da 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.39", + "version": "1.14.40", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8102023128..666198d55e 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.39" +version = "1.14.40" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 84219c5510..f5bd20d0be 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.39", + "version": "1.14.40", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3126804ae0..245bb86621 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.39", + "version": "1.14.40", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9bcf2a6f1f..fa9e4214e8 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 4ec95155c3..8029d2c9ae 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index f70692d76f..6d2cd71e30 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index f16dfdf134..3e875f7524 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.39", + "version": "1.14.40", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 295ac2ad10..59390274d5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.39", + "version": "1.14.40", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index c78e2a1486..4052393c0d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.39", + "version": "1.14.40", "publisher": "sst-dev", "repository": { "type": "git", From 3480cef52e4bb8fd5d155069786d5207f967ad3f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 00:46:33 +0000 Subject: [PATCH 032/812] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 3792b80503..a765e803d2 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=", - "aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=", - "aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=", - "x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg=" + "x86_64-linux": "sha256-cgqwEUyOYcOnh07Wz20qkPIrDeBaCBmKiis6HO1EAIU=", + "aarch64-linux": "sha256-AVy0RQuuXiseIwJV9f9API8OEo1jcy84dVidkEXgnX8=", + "aarch64-darwin": "sha256-RIS3/SuSXaMV9WcTXxOJWGDw96LcCFT6E8Ktc28/544=", + "x86_64-darwin": "sha256-GBwXIZQxy5F7tH9TJyWAonX5aETbZ/veAjeznDtsYmk=" } } From 0b702704ae199fc7952f6a81d3816b09a0ff4645 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 6 May 2026 23:01:14 -0400 Subject: [PATCH 033/812] zen: nano not used for title gen --- packages/web/src/content/docs/ar/zen.mdx | 2 +- packages/web/src/content/docs/bs/zen.mdx | 2 +- packages/web/src/content/docs/da/zen.mdx | 2 +- packages/web/src/content/docs/de/zen.mdx | 2 +- packages/web/src/content/docs/es/zen.mdx | 2 +- packages/web/src/content/docs/fr/zen.mdx | 2 +- packages/web/src/content/docs/it/zen.mdx | 2 +- packages/web/src/content/docs/ja/zen.mdx | 2 +- packages/web/src/content/docs/ko/zen.mdx | 2 +- packages/web/src/content/docs/nb/zen.mdx | 2 +- packages/web/src/content/docs/pl/zen.mdx | 2 +- packages/web/src/content/docs/pt-br/zen.mdx | 2 +- packages/web/src/content/docs/ru/zen.mdx | 2 +- packages/web/src/content/docs/th/zen.mdx | 2 +- packages/web/src/content/docs/tr/zen.mdx | 2 +- packages/web/src/content/docs/zen.mdx | 2 +- packages/web/src/content/docs/zh-cn/zen.mdx | 2 +- packages/web/src/content/docs/zh-tw/zen.mdx | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index a2e2aacfe9..33fd9493ba 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -165,7 +165,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | قد تلاحظ _Claude Haiku 3.5_ في سجل الاستخدام. هذا [نموذج منخفض التكلفة](/docs/config/#models) يُستخدم لتوليد عناوين جلساتك. diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 89527763ca..3723cbaa3c 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -172,7 +172,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Možda ćete primijetiti _Claude Haiku 3.5_ u historiji korištenja. To je [low cost model](/docs/config/#models) koji se koristi za generisanje naslova vaših sesija. diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index 009ad42023..d45f785a59 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -172,7 +172,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Du vil måske bemærke _Claude Haiku 3.5_ i din brugshistorik. Det er en [lavprismodel](/docs/config/#models), som bruges til at generere titlerne på dine sessioner. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 11550f61c3..5e6c8eee80 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -161,7 +161,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Möglicherweise siehst du _Claude Haiku 3.5_ in deinem Nutzungsverlauf. Das ist ein [kostengünstiges Modell](/docs/config/#models), das verwendet wird, um die Titel deiner Sessions zu generieren. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index f1a08c7ba5..15436226a5 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -172,7 +172,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Puede que notes _Claude Haiku 3.5_ en tu historial de uso. Este es un [modelo de bajo costo](/docs/config/#models) que se usa para generar los títulos de tus sesiones. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 7710da2259..fdf14e8fb0 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -161,7 +161,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Vous remarquerez peut-être _Claude Haiku 3.5_ dans votre historique d'utilisation. Il s'agit d'un [modèle à faible coût](/docs/config/#models) utilisé pour générer les titres de vos sessions. diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index a3b8725535..a53d6a2ba1 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -172,7 +172,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Potresti notare _Claude Haiku 3.5_ nella cronologia di utilizzo. È un [modello a basso costo](/docs/config/#models) usato per generare i titoli delle tue sessioni. diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 8fcdc6d46b..64427a72ec 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 使用履歴に _Claude Haiku 3.5_ が表示されることがあります。これはセッションのタイトル生成に使われる [low cost model](/docs/config/#models) です。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index eb99c29fe6..e80a5e8710 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 사용 기록에서 *Claude Haiku 3.5*를 볼 수 있습니다. 이는 세션 제목을 생성할 때 사용되는 [저비용 모델](/docs/config/#models)입니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 8ab1762e1f..4bd1e6115e 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -172,7 +172,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Du vil kanskje legge merke til _Claude Haiku 3.5_ i brukshistorikken din. Dette er en [lavprismodell](/docs/config/#models) som brukes til å generere titlene på øktene dine. diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 52906036c0..ebd16d7856 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -172,7 +172,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Możesz zauważyć _Claude Haiku 3.5_ w historii użycia. To [niedrogi model](/docs/config/#models), który służy do generowania tytułów Twoich sesji. diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index b35cfdbde5..1dcc98c5d5 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -161,7 +161,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Você pode notar _Claude Haiku 3.5_ no seu histórico de uso. Este é um [low cost model](/docs/config/#models) usado para gerar os títulos das suas sessões. diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 919026447f..10c55fc4dd 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -172,7 +172,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Вы можете заметить _Claude Haiku 3.5_ в истории использования. Это [недорогая модель](/docs/config/#models), которая используется для генерации заголовков ваших сессий. diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index b3914b73c2..cb2556ef63 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -163,7 +163,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | คุณอาจสังเกตเห็น _Claude Haiku 3.5_ ในประวัติการใช้งานของคุณ นี่คือ [low cost model](/docs/config/#models) ที่ใช้สร้างชื่อ session ของคุณ diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 3e53ba40d4..36c1bfc66e 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -161,7 +161,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | Kullanım geçmişinizde _Claude Haiku 3.5_ görebilirsiniz. Bu, oturum başlıklarınızı oluşturmak için kullanılan [düşük maliyetli bir modeldir](/docs/config/#models). diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 58baceb258..333e74434b 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -172,7 +172,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions. diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 03124a34f4..9ad7e6b53d 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -161,7 +161,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 你可能会在使用记录中看到 _Claude Haiku 3.5_。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index ebd48dea8f..9511bd9e24 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -166,7 +166,7 @@ https://opencode.ai/zen/v1/models | GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - | | GPT 5 | $1.07 | $8.50 | $0.107 | - | | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | -| GPT 5 Nano | Free | Free | Free | - | +| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | 你可能會在使用紀錄中看到 _Claude Haiku 3.5_。這是一個[低成本模型](/docs/config/#models), 會用來產生工作階段的標題。 From 72ec05d0be201514ca506741567e57ecec0e72ee Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 00:32:33 -0400 Subject: [PATCH 034/812] go: rate limit metadata --- .../console/app/src/routes/zen/util/error.ts | 11 ++++++- .../app/src/routes/zen/util/handler.ts | 30 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index b2a1d30d03..216b6564e7 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -13,4 +13,13 @@ class LimitError extends Error { } export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} -export class SubscriptionUsageLimitError extends LimitError {} + +class SubscriptionUsageLimitError extends LimitError { + workspace: string + constructor(message: string, workspace: string, retryAfter?: number) { + super(message, retryAfter) + this.workspace = workspace + } +} +export class GoUsageLimitError extends SubscriptionUsageLimitError {} +export class BlackUsageLimitError extends SubscriptionUsageLimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 16f9174325..c12129ff1d 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -23,7 +23,8 @@ import { ModelError, RateLimitError, FreeUsageLimitError, - SubscriptionUsageLimitError, + GoUsageLimitError, + BlackUsageLimitError, } from "./error" import { buildCostChunk, @@ -395,7 +396,8 @@ export async function handler( if ( error instanceof RateLimitError || error instanceof FreeUsageLimitError || - error instanceof SubscriptionUsageLimitError + error instanceof GoUsageLimitError || + error instanceof BlackUsageLimitError ) { const headers = new Headers() if (error.retryAfter) { @@ -404,7 +406,14 @@ export async function handler( return new Response( JSON.stringify({ type: "error", - error: { type: error.constructor.name, message: error.message }, + error: { + type: error.constructor.name, + message: error.message, + }, + metadata: + error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError + ? { workspace: error.workspace } + : {}, }), { status: 429, headers }, ) @@ -693,10 +702,11 @@ export async function handler( timeUpdated: sub.timeFixedUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new BlackUsageLimitError( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), + authInfo.workspaceID, result.resetInSec, ) } @@ -711,10 +721,11 @@ export async function handler( timeUpdated: sub.timeRollingUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new BlackUsageLimitError( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), + authInfo.workspaceID, result.resetInSec, ) } @@ -739,8 +750,9 @@ export async function handler( timeUpdated: sub.timeWeeklyUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } @@ -754,8 +766,9 @@ export async function handler( timeSubscribed: sub.timeCreated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } @@ -769,8 +782,9 @@ export async function handler( timeUpdated: sub.timeRollingUpdated, }) if (result.status === "rate-limited") - throw new SubscriptionUsageLimitError( + throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + authInfo.workspaceID, result.resetInSec, ) } From ba1ec62caf7c114ffe3d422a51c90c1e572f15e4 Mon Sep 17 00:00:00 2001 From: carmit hershman <78722358+carmithersh@users.noreply.github.com> Date: Thu, 7 May 2026 08:37:14 +0300 Subject: [PATCH 035/812] docs: add opencode-jfrog-plugin to ecosystem list for JFrog integration (#26019) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 055daf1419..55f0bcdaac 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -52,6 +52,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | | [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring | | [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI | +| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform | --- From 9b30ee2db217925b31064e71a65a2ee57c130611 Mon Sep 17 00:00:00 2001 From: Jesse <82005785+jessedi0n@users.noreply.github.com> Date: Thu, 7 May 2026 07:39:14 +0200 Subject: [PATCH 036/812] fix(desktop): add macOS settings menu entry (#26081) Co-authored-by: jesse.mahnken --- packages/desktop/src/main/menu.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 0d9a697fa9..2d5a900f39 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -23,6 +23,11 @@ export function createMenu(deps: Deps) { enabled: UPDATER_ENABLED, click: () => deps.checkForUpdates(), }, + { + label: "Settings", + accelerator: "Cmd+,", + click: () => deps.trigger("settings.open"), + }, { label: "Reload Webview", click: () => deps.reload(), From 54a78c92246de620234200af3649f8392b3f6761 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 7 May 2026 13:48:56 +0800 Subject: [PATCH 037/812] feat(desktop): move server to utilityProcess (#25962) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> --- packages/desktop/electron.vite.config.ts | 2 +- packages/desktop/src/main/apps.ts | 55 +++--- packages/desktop/src/main/env.d.ts | 1 + packages/desktop/src/main/index.ts | 79 +++++---- packages/desktop/src/main/ipc.ts | 2 +- packages/desktop/src/main/server.ts | 217 ++++++++++++++++++++--- packages/desktop/src/main/sidecar.ts | 178 +++++++++++++++++++ 7 files changed, 441 insertions(+), 93 deletions(-) create mode 100644 packages/desktop/src/main/sidecar.ts diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index a352e03fdd..52aa699ff6 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -37,7 +37,7 @@ export default defineConfig({ }, build: { rollupOptions: { - input: { index: "src/main/index.ts" }, + input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, externalizeDeps: { include: [nodePtyPkg] }, }, diff --git a/packages/desktop/src/main/apps.ts b/packages/desktop/src/main/apps.ts index 174da94a5d..bf25417b83 100644 --- a/packages/desktop/src/main/apps.ts +++ b/packages/desktop/src/main/apps.ts @@ -1,14 +1,22 @@ -import { execFileSync } from "node:child_process" -import { existsSync, readFileSync, readdirSync } from "node:fs" +import { execFile, execFileSync } from "node:child_process" +import { access, readFile, readdir } from "node:fs/promises" import { dirname, extname, join } from "node:path" +import util from "node:util" -export function checkAppExists(appName: string): boolean { +const execFilePromise = util.promisify(execFile) + +const exists = (path: string) => + access(path) + .then(() => true) + .catch(() => false) + +export function checkAppExists(appName: string) { if (process.platform === "win32") return true if (process.platform === "linux") return true return checkMacosApp(appName) } -export function resolveAppPath(appName: string): string | null { +export function resolveAppPath(appName: string) { if (process.platform !== "win32") return appName return resolveWindowsAppPath(appName) } @@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string } } -function checkMacosApp(appName: string) { +async function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] const home = process.env.HOME if (home) locations.push(`${home}/Applications/${appName}.app`) - if (locations.some((location) => existsSync(location))) return true - - try { - execFileSync("which", [appName]) - return true - } catch { - return false + for (const location of locations) { + if (await exists(location)) return true } + + return execFilePromise("which", [appName]) + .then(() => true) + .catch(() => false) } -function resolveWindowsAppPath(appName: string): string | null { +async function resolveWindowsAppPath(appName: string): Promise { let output: string try { - output = execFileSync("where", [appName]).toString() + output = execFilePromise("where", [appName]).toString() } catch { return null } @@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null { const exe = paths.find((path) => hasExt(path, "exe")) if (exe) return exe - const resolveCmd = (path: string) => { - const content = readFileSync(path, "utf8") + const resolveCmd = async (path: string) => { + const content = await readFile(path, "utf8") for (const token of content.split('"').map((value: string) => value.trim())) { const lower = token.toLowerCase() if (!lower.includes(".exe")) continue @@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null { return join(current, part) }, base) - if (existsSync(resolved)) return resolved + if (await exists(resolved)) return resolved } - if (existsSync(token)) return token + if (await exists(token)) return token } return null @@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null { for (const path of paths) { if (hasExt(path, "cmd") || hasExt(path, "bat")) { - const resolved = resolveCmd(path) + const resolved = await resolveCmd(path) if (resolved) return resolved } if (!extname(path)) { const cmd = `${path}.cmd` - if (existsSync(cmd)) { - const resolved = resolveCmd(cmd) + if (await exists(cmd)) { + const resolved = await resolveCmd(cmd) if (resolved) return resolved } const bat = `${path}.bat` - if (existsSync(bat)) { - const resolved = resolveCmd(bat) + if (await exists(bat)) { + const resolved = await resolveCmd(bat) if (resolved) return resolved } } @@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null { const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))] for (const dir of dirs) { try { - for (const entry of readdirSync(dir)) { + for (const entry of await readdir(dir)) { const candidate = join(dir, entry) if (!hasExt(candidate, "exe")) continue const stem = entry.replace(/\.exe$/i, "") diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index 1de56e1c90..eee21e48cb 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv } + declare module "virtual:opencode-server" { export namespace Server { export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index d3c8fcc04e..f75cd719a2 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -47,7 +47,15 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { + getDefaultServerUrl, + getWslConfig, + preferAppEnv, + setDefaultServerUrl, + setWslConfig, + spawnLocalServer, + type SidecarListener, +} from "./server" import { createLoadingWindow, createMainWindow, @@ -55,15 +63,13 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" import { migrate } from "./migrate" const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null +let server: SidecarListener | null = null const loadingComplete = defer() const pendingDeepLinks: string[] = [] @@ -107,6 +113,8 @@ function setupApp() { return } + preferAppEnv(app.getPath("userData")) + app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) if (urls.length) { @@ -123,17 +131,16 @@ function setupApp() { }) app.on("before-quit", () => { - killSidecar() + void killSidecar() }) app.on("will-quit", () => { - killSidecar() + void killSidecar() }) for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - killSidecar() - app.exit(0) + void killSidecar().finally(() => app.exit(0)) }) } @@ -184,7 +191,6 @@ function setInitStep(step: InitStep) { async function initialize() { const needsMigration = !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined let overlay: BrowserWindow | null = null const port = await getSidecarPort() @@ -199,31 +205,26 @@ async function initialize() { setInitStep({ phase: "sqlite_waiting" }) if (overlay) sendSqliteMigrationProgress(overlay, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() }) - if (needsMigration) { - const { Database, JsonMigration } = await import("virtual:opencode-server") - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 - initEmitter.emit("sqlite", { type: "InProgress", value: percent }) - }, - }) - initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() - } - - if (needsMigration) { - await sqliteDone?.promise - } - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password, () => { - ensureLoopbackNoProxy() - useEnvProxy() - }) + const { listener, health } = await spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ) server = listener serverReady.resolve({ url, @@ -273,9 +274,10 @@ function wireMenu() { }, reload: () => mainWindow?.reload(), relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) }, }) } @@ -304,7 +306,7 @@ registerIpcHandlers({ getDisplayBackend: async () => null, setDisplayBackend: async () => undefined, parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: async (appName) => checkAppExists(appName), + checkAppExists: (appName) => checkAppExists(appName), wslPath: async (path, mode) => wslPath(path, mode), resolveAppPath: async (appName) => resolveAppPath(appName), loadingWindowComplete: () => loadingComplete.resolve(), @@ -314,10 +316,11 @@ registerIpcHandlers({ setBackgroundColor: (color) => setBackgroundColor(color), }) -function killSidecar() { +async function killSidecar() { if (!server) return - server.stop() + const current = server server = null + await current.stop() } function ensureLoopbackNoProxy() { @@ -440,7 +443,7 @@ async function installUpdate() { logger.log("installing downloaded update", { version: downloadedUpdateVersion, }) - killSidecar() + await killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1c4af0eb60..dbcd4239dc 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => { } type Deps = { - killSidecar: () => void + killSidecar: () => Promise | void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise getWindowConfig: () => Promise | WindowConfig consumeInitialDeepLinks: () => Promise | string[] diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 4b8cb04943..635a93578a 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,12 +1,37 @@ -import { app } from "electron" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app, utilityProcess } from "electron" +import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" import { getStore } from "./store" +import type { SqliteMigrationProgress } from "../preload/types" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } +type SidecarMessage = + | { type: "sqlite"; progress: SqliteMigrationProgress } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +export type SidecarListener = { stop: () => Promise } + +const SIDECAR_SERVICE_NAME = "opencode server" +const SIDECAR_START_STALL_TIMEOUT = 60_000 +const SIDECAR_STOP_TIMEOUT = 6_000 + +type SpawnLocalServerOptions = { + needsMigration: boolean + userDataPath: string + onSqliteProgress?: (progress: SqliteMigrationProgress) => void + onStdout?: (message: string) => void + onStderr?: (message: string) => void + onExit?: (code: number) => void +} + export function getDefaultServerUrl(): string | null { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null @@ -30,49 +55,155 @@ export function setWslConfig(config: WslConfig) { getStore().set(WSL_ENABLED_KEY, config.enabled) } -export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) { - prepareServerEnv(password) +export function preferAppEnv(userDataPath: string) { + const shell = process.platform === "win32" ? null : getUserShell() + Object.assign( + process.env, + mergeShellEnv(shell ? loadShellEnv(shell) : null, { + ...process.env, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }), + ) +} + +export async function spawnLocalServer( + hostname: string, + port: number, + password: string, + configureEnv: () => void, + options: SpawnLocalServerOptions, +) { configureEnv?.() - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) - const listener = await Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], + const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") + const child = utilityProcess.fork(sidecar, [], { + cwd: process.cwd(), + env: createSidecarEnv(), + serviceName: SIDECAR_SERVICE_NAME, + stdio: "pipe", + }) + let exited = false + const exit = defer() + + const onProcessGone = (_event: unknown, details: Details) => { + if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return + options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`) + } + + app.on("child-process-gone", onProcessGone) + child.once("exit", (code) => { + exited = true + app.off("child-process-gone", onProcessGone) + options.onExit?.(code) + exit.resolve(code) + }) + child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`)) + + child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd())) + child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd())) + + await new Promise((resolve, reject) => { + let done = false + let timeout: NodeJS.Timeout + + const fail = (error: Error) => { + if (done) return + done = true + cleanup() + reject(error) + } + + const refreshTimeout = () => { + clearTimeout(timeout) + timeout = setTimeout(() => { + fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`)) + }, SIDECAR_START_STALL_TIMEOUT) + } + + const onMessage = (message: SidecarMessage) => { + if (message.type === "sqlite") { + refreshTimeout() + options.onSqliteProgress?.(message.progress) + return + } + if (message.type === "ready") { + if (done) return + done = true + cleanup() + resolve() + return + } + if (message.type === "error") { + fail(Object.assign(new Error(message.error.message), { stack: message.error.stack })) + } + } + const onExit = (code: number) => { + fail(new Error(`Sidecar exited before ready with code ${code}`)) + } + const cleanup = () => { + clearTimeout(timeout) + child.off("message", onMessage) + child.off("exit", onExit) + } + + child.on("message", onMessage) + child.on("exit", onExit) + refreshTimeout() + child.postMessage({ + type: "start", + hostname, + port, + password, + userDataPath: options.userDataPath, + needsMigration: options.needsMigration, + }) + }).catch((error) => { + if (!exited) child.kill() + throw error }) const wait = (async () => { const url = `http://${hostname}:${port}` + let healthy = false + const gone = exit.promise.then((code) => { + if (healthy) return + throw new Error(`Sidecar exited before health check passed with code ${code}`) + }) const ready = async () => { while (true) { await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return + if (await checkHealth(url, password)) { + healthy = true + return + } } } - await ready() + await Promise.race([ready(), gone]) })() - return { listener, health: { wait } } -} + let stopping: Promise | undefined -function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"), + return { + listener: { + stop: () => { + if (stopping) return stopping + if (exited) return Promise.resolve() + child.postMessage({ type: "stop" }) + stopping = Promise.race([ + exit.promise.then(() => undefined), + delay(SIDECAR_STOP_TIMEOUT).then(() => { + if (!exited) child.kill() + }), + ]) + return stopping + }, + }, + health: { wait }, } - Object.assign(process.env, env) } export async function checkHealth(url: string, password?: string | null): Promise { @@ -100,3 +231,31 @@ export async function checkHealth(url: string, password?: string | null): Promis return false } } + +function createSidecarEnv(): Record { + const env = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), + ) + delete env.DEBUG + if (process.platform === "linux") delete env.LD_PRELOAD + return env +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function defer() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts new file mode 100644 index 0000000000..e7d652b6e1 --- /dev/null +++ b/packages/desktop/src/main/sidecar.ts @@ -0,0 +1,178 @@ +import { drizzle } from "drizzle-orm/node-sqlite/driver" +import * as http from "node:http" +import * as tls from "node:tls" + +type NodeHttpWithEnvProxy = typeof http & { + setGlobalProxyFromEnv: () => void +} + +type NodeTlsWithSystemCertificates = typeof tls & { + getCACertificates: (type: "default" | "system") => string[] + setDefaultCACertificates: (certificates: string[]) => void +} + +type StartCommand = { + type: "start" + hostname: string + port: number + password: string + userDataPath: string + needsMigration: boolean +} + +type StopCommand = { type: "stop" } +type SidecarCommand = StartCommand | StopCommand + +type SidecarMessage = + | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } } + | { type: "ready" } + | { type: "stopped" } + | { type: "error"; error: { message: string; stack?: string } } + +type ParentPort = { + postMessage(message: SidecarMessage): void + on(event: "message", listener: (event: { data: unknown }) => void): void +} + +type Listener = { + stop(close?: boolean): void | Promise +} + +const parentPort = getParentPort() +let listener: Listener | undefined + +parentPort.on("message", (event) => { + const command = parseCommand(event.data) + if (!command) return + if (command.type === "stop") { + void stop() + return + } + void start(command) +}) + +async function start(command: StartCommand) { + try { + prepareSidecarEnv(command.password, command.userDataPath) + ensureLoopbackNoProxy() + useSystemCertificates() + useEnvProxy() + const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server") + await Log.init({ level: "WARN" }) + + if (command.needsMigration) { + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + parentPort.postMessage({ + type: "sqlite", + progress: { + type: "InProgress", + value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100), + }, + }) + }, + }) + parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } }) + } + + listener = await Server.listen({ + port: command.port, + hostname: command.hostname, + username: "opencode", + password: command.password, + cors: ["oc://renderer"], + }) + parentPort.postMessage({ type: "ready" }) + } catch (error) { + parentPort.postMessage({ type: "error", error: serializeError(error) }) + setImmediate(() => process.exit(1)) + } +} + +async function stop() { + try { + await listener?.stop() + } finally { + listener = undefined + parentPort.postMessage({ type: "stopped" }) + setImmediate(() => process.exit(0)) + } +} + +function prepareSidecarEnv(password: string, userDataPath: string) { + Object.assign(process.env, { + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) +} + +function ensureLoopbackNoProxy() { + const loopback = ["127.0.0.1", "localhost", "::1"] + const upsert = (key: string) => { + const items = (process.env[key] ?? "") + .split(",") + .map((value: string) => value.trim()) + .filter((value: string) => Boolean(value)) + + for (const host of loopback) { + if (items.some((value: string) => value.toLowerCase() === host)) continue + items.push(host) + } + + process.env[key] = items.join(",") + } + + upsert("NO_PROXY") + upsert("no_proxy") +} + +function useSystemCertificates() { + try { + const nodeTls = tls as NodeTlsWithSystemCertificates + nodeTls.setDefaultCACertificates([ + ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]), + ]) + } catch (error) { + console.warn("failed to load system certificates", error) + } +} + +function useEnvProxy() { + try { + ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv() + } catch (error) { + console.warn("failed to load proxy environment", error) + } +} + +function parseCommand(value: unknown): SidecarCommand | undefined { + if (!value || typeof value !== "object") return + const command = value as Partial + if (command.type === "stop") return { type: "stop" } + if (command.type !== "start") return + if (typeof command.hostname !== "string") return + if (typeof command.port !== "number") return + if (typeof command.password !== "string") return + if (typeof command.userDataPath !== "string") return + if (typeof command.needsMigration !== "boolean") return + return { + type: "start", + hostname: command.hostname, + port: command.port, + password: command.password, + userDataPath: command.userDataPath, + needsMigration: command.needsMigration, + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) return { message: error.message, stack: error.stack } + return { message: String(error) } +} + +function getParentPort() { + const port = process.parentPort as ParentPort | undefined + if (!port) throw new Error("Sidecar parent port unavailable") + return port +} From 293bb422fa920a426a0bf98ef95a3f6d77f9c504 Mon Sep 17 00:00:00 2001 From: Bence Ferdinandy Date: Thu, 7 May 2026 07:52:07 +0200 Subject: [PATCH 038/812] fix(format): restore stdout/stderr ignore for formatter processes (#26037) Co-authored-by: Aiden Cline --- packages/opencode/src/format/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 7c122e3501..a61eb7be29 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -91,6 +91,9 @@ export const layer = Layer.effect( cwd: dir, env: item.environment, extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", }), ) .pipe( From f8aa4a3be0cf3fae670b69c8940f42a33963e7b5 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 7 May 2026 09:56:10 +0200 Subject: [PATCH 039/812] chore: simplify honeycomb alerts (#26142) --- bun.lock | 9 - infra/console.ts | 6 +- infra/monitoring.ts | 369 ++++-------------- infra/secret.ts | 7 + packages/console/app/package.json | 1 - .../app/src/routes/honeycomb/webhook.ts | 81 ++++ .../app/src/routes/incident/webhook.ts | 77 ---- packages/console/core/src/util/crypto.ts | 8 + packages/console/core/sst-env.d.ts | 4 +- packages/console/function/sst-env.d.ts | 4 +- packages/console/resource/sst-env.d.ts | 4 +- packages/enterprise/sst-env.d.ts | 4 +- packages/function/sst-env.d.ts | 4 +- sst-env.d.ts | 4 +- sst.config.ts | 13 +- 15 files changed, 186 insertions(+), 409 deletions(-) create mode 100644 packages/console/app/src/routes/honeycomb/webhook.ts delete mode 100644 packages/console/app/src/routes/incident/webhook.ts create mode 100644 packages/console/core/src/util/crypto.ts diff --git a/bun.lock b/bun.lock index d481de8e83..8e3c9b7452 100644 --- a/bun.lock +++ b/bun.lock @@ -107,7 +107,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:", }, @@ -2168,8 +2167,6 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], - "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], @@ -3180,8 +3177,6 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], @@ -4656,8 +4651,6 @@ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], - "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4726,8 +4719,6 @@ "sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="], - "svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="], - "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], diff --git a/infra/console.ts b/infra/console.ts index d92fcaa8e2..ab6502a8f8 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -1,5 +1,6 @@ import { domain } from "./stage" import { EMAILOCTOPUS_API_KEY } from "./app" +import { SECRET } from "./secret" //////////////// // DATABASE @@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { properties: { value: stripeWebhook.secret }, }) -const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET") -const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const gatewayKv = new sst.cloudflare.Kv("GatewayKv") @@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv") const bucket = new sst.cloudflare.Bucket("ZenData") const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") +const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") @@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", { database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, - INCIDENT_WEBHOOK_SIGNING_SECRET, DISCORD_INCIDENT_WEBHOOK_URL, + SECRET.HoneycombWebhookSecret, STRIPE_SECRET_KEY, EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 4fb7183a2f..4e22e3d812 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -1,318 +1,91 @@ -const displayName = (s: string) => - s - .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ") - .replace(/(?<=\d) (?=\d)/g, ".") +import { SECRET } from "./secret" +import { domain } from "./stage" -const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "") - -const varSpec = (label: string, name: string) => - $jsonStringify({ - content: [ - { - content: [ - { - attrs: { - name, - label, - missing: false, - }, - type: "varSpec", - }, - ], - type: "paragraph", - }, - ], - type: "doc", - }) - -const fields = { - model: incident.getAlertAttributeOutput({ name: "Model" }), - product: incident.getAlertAttributeOutput({ name: "Product" }), -} - -const alertSource = new incident.AlertSource("HoneycombAlertSource", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - sourceType: "honeycomb", - template: { - title: { - literal: varSpec("Payload -> Title", "title"), - }, - description: { - literal: varSpec("Payload -> Description", "description"), - }, - attributes: [ - { - alertAttributeId: fields.model.id, - binding: { - value: { - reference: 'expressions["model"]', - }, - mergeStrategy: "first_wins", - }, - }, - { - alertAttributeId: fields.product.id, - binding: { - value: { - reference: 'expressions["product"]', - }, - mergeStrategy: "first_wins", - }, - }, - ], - expressions: [ - { - label: "Model", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.model.type, - }, - source: "$['model']", - }, - }, - ], - reference: "model", - rootReference: "payload", - }, - { - label: "Product", - operations: [ - { - operationType: "parse", - parse: { - returns: { - array: false, - type: fields.product.type, - }, - source: "$['product']", - }, - }, - ], - reference: "product", - rootReference: "payload", - }, - ], - }, -}) - -const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, { - name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`, - url: alertSource.alertEventsUrl, - secret: alertSource.secretToken, +const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { + name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, + url: `https://${domain}/honeycomb/webhook`, + secret: SECRET.HoneycombWebhookSecret.result, templates: [ { type: "trigger", - body: $jsonStringify({ - title: "{{ .Name }}", - description: "{{ .Description }}", - status: "{{ .Alert.Status }}", - deduplication_key: "{{ .Alert.InstanceID }}", - source_url: "{{ .Result.URL }}", - model: "{{ .Vars.model }}", - product: "{{ .Vars.product }}", - }), + body: `{ + "url": {{ .Result.URL | quote }}, + "type": {{ .Vars.type | quote }}, + "name": {{ .Name | quote }}, + "status": {{ .Alert.Status | quote }}, + "isTest": {{ .Alert.IsTest }}, + "groups": {{ .Result.GroupsTriggered | toJson }} + }`, }, ], variables: [ { - name: "model", - }, - { - name: "product", + name: "type", }, ], }) -new incident.AlertRoute("HoneycombAlertRoute", { - name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`, - enabled: true, - isPrivate: false, - alertSources: [ - { - alertSourceId: alertSource.id, - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - }, - ], - conditionGroups: [ - { - conditions: [ - { - subject: "alert.title", - operation: "is_set", - paramBindings: [], - }, - ], - }, - ], - expressions: [], - escalationConfig: { - autoCancelEscalations: true, - escalationTargets: [], - }, - incidentConfig: { - autoDeclineEnabled: true, - enabled: true, - conditionGroups: [], - deferTimeSeconds: 0, - groupingKeys: [ +const modelHttpErrorsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "model", op: "exists" }, + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["model"], + calculatedFields: [ { - reference: $interpolate`alert.attributes.${fields.model.id}`, - }, - { - reference: $interpolate`alert.attributes.${fields.product.id}`, + name: "is_failed_http_status", + expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], - groupingWindowSeconds: 3600, - }, - incidentTemplate: { - name: { - value: { - literal: varSpec("Alert -> Title", "alert.title"), - }, - }, - summary: { - value: { - literal: varSpec("Alert -> Description", "alert.description"), - }, - }, - startInTriage: { - value: { - literal: "true", - }, - }, - severity: { - mergeStrategy: "first-wins", - }, - incidentMode: { - value: { - literal: $app.stage === "production" ? "standard" : "test", - }, - }, - }, -}) - -type Product = "go" | "zen" - -type Trigger = (opts: { model: string; product: Product }) => { - id: string - title: string - description: string - json: honeycomb.GetQuerySpecificationOutputArgs - threshold: { op: ">=" | "<="; value: number } -} - -type Model = { id: string; products: Product[]; triggers: Trigger[] } - -const httpErrors: Trigger = ({ model, product }) => ({ - id: "increased-http-errors", - title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`, - description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`, - json: { calculations: [ - { - op: "COUNT", - name: "TOTAL", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - ], - }, - { - op: "COUNT", - name: "FAILED", - filterCombination: "AND", - filters: [ - { column: "model", op: "=", value: model }, - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, - { column: "status", op: ">=", value: "400" }, - { column: "status", op: "!=", value: "401" }, - ], - }, + { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, + { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 2500), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, - }, - threshold: { op: ">=", value: 0.8 }, + }).json +} + +const description = "Managed by SST (Don't edit in Honeycomb UI)" + +new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { + name: "Increased Model HTTP Errors [Go]", + description, + queryJson: modelHttpErrorsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], }) -const models: Model[] = [ - { id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "glm-5", products: ["go"], triggers: [httpErrors] }, - { id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] }, - { id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] }, - { id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] }, - // { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] }, - { id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] }, - // { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] }, - { id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] }, - { id: "big-pickle", products: ["zen"], triggers: [httpErrors] }, - // { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] }, - // { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] }, -] - -if ($app.stage !== "production") { - models.splice(1) -} - -for (const model of models) { - for (const product of model.products) { - for (const trigger of model.triggers) { - const spec = trigger({ model: model.id, product }) - - new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), { - name: spec.title, - description: spec.description, - queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json, - alertType: "on_change", - frequency: 300, - thresholds: [{ ...spec.threshold, exceededLimit: 1 }], - recipients: [ - { - id: webhookRecipient.id, - notificationDetails: [ - { - variables: [ - { name: "model", value: model.id }, - { name: "product", value: product }, - ], - }, - ], - }, - ], - }) - } - } -} +new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { + name: "Increased Model HTTP Errors [Zen]", + description, + queryJson: modelHttpErrorsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "model_http_errors" }], + // }, + // ], + // }, + ], +}) diff --git a/infra/secret.ts b/infra/secret.ts index 0b1870fa15..d4e8b148fc 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -1,4 +1,11 @@ +sst.Linkable.wrap(random.RandomPassword, (resource) => ({ + properties: { + value: resource.result, + }, +})) + export const SECRET = { R2AccessKey: new sst.Secret("R2AccessKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), + HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 71d37d1553..298ae4a8cf 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -31,7 +31,6 @@ "solid-js": "catalog:", "solid-list": "0.3.0", "solid-stripe": "0.8.1", - "svix": "1.92.2", "vite": "catalog:", "zod": "catalog:" }, diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts new file mode 100644 index 0000000000..b4d5e4bf7e --- /dev/null +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -0,0 +1,81 @@ +import type { APIEvent } from "@solidjs/start/server" +import { z } from "zod" +import { Resource } from "@opencode-ai/console-resource" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" + +const DISCORD_ALERT_ROLE_ID = "1501447160175136838" + +const basePayload = z.object({ + name: z.string().optional(), + status: z.string().optional(), + isTest: z.boolean().optional(), + url: z.string(), +}) + +const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array() + +const honeycombWebhookPayload = z.discriminatedUnion("type", [ + basePayload.extend({ + type: z.literal("model_http_errors"), + groups, + }), + basePayload.extend({ + type: z.literal("provider_http_errors"), + groups, + }), +]) + +const postDiscordMessage = async (payload: z.infer) => { + const group = payload.type === "model_http_errors" ? "model" : "provider" + const names = (payload.groups ?? []).flatMap((item) => item.group.map((g) => g.value)) + + const content = [ + `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, + names.length > 0 ? `Affected ${group}s:` : undefined, + ...names.map((name) => `- ${name}`), + "", + `<@&${DISCORD_ALERT_ROLE_ID}>`, + ] + .filter((line) => line !== undefined) + .join("\n") + + return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] }, + flags: 4, + }), + }) +} + +export async function POST(input: APIEvent) { + const token = input.request.headers.get("X-Honeycomb-Webhook-Token") + if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) { + console.debug("Invalid Honeycomb webhook token") + return Response.json({ message: "invalid token" }, { status: 401 }) + } + + const body = await input.request.json() + console.log(body, JSON.stringify(body, null, 2)) + + const parsed = honeycombWebhookPayload.safeParse(body) + + if (!parsed.success) { + console.error(parsed.error) + return Response.json({ message: "invalid payload" }, { status: 400 }) + } + + if (parsed.data.status !== "TRIGGERED") { + console.debug("Skipping resolved alert Honeycomb webhook") + return Response.json({ message: "ignored" }, { status: 200 }) + } + + const response = await postDiscordMessage(parsed.data) + if (!response.ok) { + return Response.json({ message: "discord webhook failed" }, { status: 502 }) + } + + return Response.json({ message: "sent" }, { status: 200 }) +} diff --git a/packages/console/app/src/routes/incident/webhook.ts b/packages/console/app/src/routes/incident/webhook.ts deleted file mode 100644 index ce7b0a0d9f..0000000000 --- a/packages/console/app/src/routes/incident/webhook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Resource } from "@opencode-ai/console-resource" -import { Webhook } from "svix" - -const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838" - -type Incident = { - mode?: "test" | "standard" - name?: string - permalink?: string - summary?: string -} - -type IncidentWebhookPayload = { - event_type?: string - "public_incident.incident_created_v2"?: Incident -} - -const verifyWebhook = async (request: Request) => { - const body = await request.text() - try { - return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify( - body, - Object.fromEntries(request.headers.entries()), - ) as IncidentWebhookPayload - } catch { - return undefined - } -} - -const postDiscordMessage = async (incident: Incident) => { - return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: [ - `**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`, - incident.summary, - "", - `<@&${DISCORD_INCIDENT_ROLE_ID}>`, - "", - incident.permalink, - ] - .filter((line) => line !== undefined) - .join("\n"), - allowed_mentions: { - roles: [DISCORD_INCIDENT_ROLE_ID], - }, - flags: 4, - }), - }) -} - -export async function POST(input: APIEvent) { - const payload = await verifyWebhook(input.request) - if (!payload) { - return Response.json({ message: "invalid signature" }, { status: 401 }) - } - - if (payload.event_type !== "public_incident.incident_created_v2") { - return Response.json({ message: "ignored event" }, { status: 200 }) - } - - const incident = payload["public_incident.incident_created_v2"] - if (!incident) { - return Response.json({ message: "missing incident" }, { status: 400 }) - } - - const response = await postDiscordMessage(incident) - if (!response.ok) { - return Response.json({ message: "discord webhook failed" }, { status: 502 }) - } - - return Response.json({ message: "sent" }, { status: 200 }) -} diff --git a/packages/console/core/src/util/crypto.ts b/packages/console/core/src/util/crypto.ts new file mode 100644 index 0000000000..46f53ae391 --- /dev/null +++ b/packages/console/core/src/util/crypto.ts @@ -0,0 +1,8 @@ +import { timingSafeEqual } from "node:crypto" + +export function safeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder() + const aBytes = encoder.encode(a) + const bBytes = encoder.encode(b) + return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes) +} diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7c..e75c54d056 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { diff --git a/sst.config.ts b/sst.config.ts index a7e513ca0a..d82c7d18d9 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -11,15 +11,10 @@ export default $config({ stripe: { apiKey: process.env.STRIPE_SECRET_KEY!, }, + random: "4.19.2", planetscale: "0.4.1", - honeycomb: { - version: "0.49.0", - apiKey: process.env.HONEYCOMB_API_KEY!, - }, - incident: { - version: "5.35.0", - apiKey: process.env.INCIDENT_API_KEY!, - }, + honeycomb: "0.49.0", + incident: "5.35.0", }, } }, @@ -27,7 +22,7 @@ export default $config({ await import("./infra/app.js") await import("./infra/console.js") await import("./infra/enterprise.js") - if ($app.stage === "production") { + if ($app.stage === "production" || $app.stage === "vimtor") { await import("./infra/monitoring.js") } }, From 1ea01fdad07a717165e91e1f92eda2197f17e9ce Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 08:08:37 +0000 Subject: [PATCH 040/812] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index a765e803d2..078b600d05 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-cgqwEUyOYcOnh07Wz20qkPIrDeBaCBmKiis6HO1EAIU=", - "aarch64-linux": "sha256-AVy0RQuuXiseIwJV9f9API8OEo1jcy84dVidkEXgnX8=", - "aarch64-darwin": "sha256-RIS3/SuSXaMV9WcTXxOJWGDw96LcCFT6E8Ktc28/544=", - "x86_64-darwin": "sha256-GBwXIZQxy5F7tH9TJyWAonX5aETbZ/veAjeznDtsYmk=" + "x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=", + "aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=", + "aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=", + "x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0=" } } From 0b2e65f16d35b0c21c4206c9249b6550919ec7e5 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:15:40 +0200 Subject: [PATCH 041/812] chore: reactivate alerts --- .github/workflows/deploy.yml | 1 - infra/monitoring.ts | 34 +++++++++++++++++----------------- sst.config.ts | 1 - 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 10b8dc180b..abd8bafdd6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,6 @@ jobs: PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} - INCIDENT_API_KEY: ${{ secrets.INCIDENT_API_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 4e22e3d812..84add2f8e6 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -45,7 +45,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 2500), DIV($FAILED, $TOTAL), 0)" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, }).json } @@ -60,14 +60,14 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "model_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_http_errors" }], + }, + ], + }, ], }) @@ -79,13 +79,13 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "model_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "model_http_errors" }], + }, + ], + }, ], }) diff --git a/sst.config.ts b/sst.config.ts index d82c7d18d9..696a6fa768 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -14,7 +14,6 @@ export default $config({ random: "4.19.2", planetscale: "0.4.1", honeycomb: "0.49.0", - incident: "5.35.0", }, } }, From b2cc40f09c0f558ed698ae450abea6f3f8a9c233 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:30:29 +0200 Subject: [PATCH 042/812] chore: first provider alert version --- infra/monitoring.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 84add2f8e6..1da54fe63d 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -50,6 +50,48 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { }).json } +const providerHttpErrorsQuery = (product: "go" | "zen") => { + const filters = [ + { column: "provider", op: "exists" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, + ] + + return honeycomb.getQuerySpecificationOutput({ + breakdowns: ["provider"], + calculatedFields: [ + { + name: "is_success_http_status", + expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, + }, + { + name: "is_failed_provider_http_status", + expression: `IF(AND(GTE($llm.error.code, "400"), NOT(EQUALS($llm.error.code, "401"))), 1, 0)`, + }, + ], + calculations: [ + { + op: "SUM", + name: "SUCCESS", + column: "is_success_http_status", + filterCombination: "AND", + filters: [...filters, { column: "event_type", op: "=", value: "completions" }], + }, + { + op: "SUM", + name: "FAILED", + column: "is_failed_provider_http_status", + filterCombination: "AND", + filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], + }, + ], + formulas: [ + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + ], + timeRange: 900, + }).json +} + const description = "Managed by SST (Don't edit in Honeycomb UI)" new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { @@ -89,3 +131,41 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { }, ], }) + +new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { + name: "Increased Provider HTTP Errors [Go]", + description, + queryJson: providerHttpErrorsQuery("go"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "provider_http_errors" }], + // }, + // ], + // }, + ], +}) + +new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { + name: "Increased Provider HTTP Errors [Zen]", + description, + queryJson: providerHttpErrorsQuery("zen"), + alertType: "on_change", + frequency: 300, + thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + recipients: [ + // { + // id: webhookRecipient.id, + // notificationDetails: [ + // { + // variables: [{ name: "type", value: "provider_http_errors" }], + // }, + // ], + // }, + ], +}) From 1219691c114c9aec251bc855b3a4b53f7d12ff14 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 7 May 2026 16:31:37 +0800 Subject: [PATCH 043/812] docs(desktop): update README from Tauri to Electron (#26146) --- packages/desktop/README.md | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index ebaf488223..6dd9a202ad 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,32 +1,19 @@ # OpenCode Desktop -Native OpenCode desktop app, built with Tauri v2. +The OpenCode Desktop app, built with Electron. ## Development -From the repo root: - ```bash bun install -bun run --cwd packages/desktop tauri dev -``` - -This starts the Vite dev server on http://localhost:1420 and opens the native window. - -If you only want the web dev server (no native shell): - -```bash -bun run --cwd packages/desktop dev +bun dev ``` ## Build -To create a production `dist/` and build the native app bundle: +Run the `build` script to build the app's JS assets, then `package` to +bundle the assets as an application. The resulting app will be in `dist/`. ```bash -bun run --cwd packages/desktop tauri build +bun run build && bun run package ``` - -## Prerequisites - -Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. From cee04f2924b16718bd7f60b05a1e946c17f8ea7e Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 10:56:37 +0200 Subject: [PATCH 044/812] chore: make provider down queries live --- infra/app.ts | 1 + infra/monitoring.ts | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/infra/app.ts b/infra/app.ts index bb627f51ec..2ede5a1f4a 100644 --- a/infra/app.ts +++ b/infra/app.ts @@ -30,6 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", { transform: { worker: (args) => { args.logpush = true + if ($app.stage === "vimtor") return args.bindings = $resolve(args.bindings).apply((bindings) => [ ...bindings, { diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 1da54fe63d..9956e2ed70 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -88,7 +88,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { formulas: [ { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], - timeRange: 900, + timeRange: 1800, }).json } @@ -140,14 +140,14 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "provider_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "provider_http_errors" }], + }, + ], + }, ], }) @@ -159,13 +159,13 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { frequency: 300, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ - // { - // id: webhookRecipient.id, - // notificationDetails: [ - // { - // variables: [{ name: "type", value: "provider_http_errors" }], - // }, - // ], - // }, + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "provider_http_errors" }], + }, + ], + }, ], }) From 193c169ca51103db79331d53bf1884262beffe7a Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 11:00:43 +0200 Subject: [PATCH 045/812] chore: improve provider down query --- infra/monitoring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 9956e2ed70..b2716bcabb 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -66,7 +66,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, { name: "is_failed_provider_http_status", - expression: `IF(AND(GTE($llm.error.code, "400"), NOT(EQUALS($llm.error.code, "401"))), 1, 0)`, + expression: `IF(GTE($llm.error.code, "400"), 1, 0)`, }, ], calculations: [ @@ -86,7 +86,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 500), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], timeRange: 1800, }).json From 30c4fcb1a596335057888bb76fa168f5039426c1 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 11:10:36 +0200 Subject: [PATCH 046/812] chore: fix honeycomb query frequency --- infra/monitoring.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index b2716bcabb..26ba573a07 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -137,7 +137,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { description, queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", - frequency: 300, + frequency: 600, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ { @@ -156,7 +156,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { description, queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", - frequency: 300, + frequency: 600, thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], recipients: [ { From fea9a0bd4c1c9d9b66af84a43936a84626d723d8 Mon Sep 17 00:00:00 2001 From: YGoetschel <54545214+YGoetschel@users.noreply.github.com> Date: Thu, 7 May 2026 12:55:40 +0200 Subject: [PATCH 047/812] fix: guard undefined contents in diff renderer to fix share viewer SSR crash (#21763) --- packages/ui/src/components/file-ssr.tsx | 8 ++++++-- packages/ui/src/components/message-part.tsx | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index ad05555bdf..6f11ca2433 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -128,8 +128,12 @@ function DiffSSRViewer(props: SSRDiffFileProps) { prerenderedHTML: local.preloadedDiff.prerenderedHTML, } : { - oldFile: local.before, - newFile: local.after, + oldFile: local.before + ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" } + : local.before, + newFile: local.after + ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" } + : local.after, lineAnnotations: annotations, fileContainer: fileDiffRef, containerWrapper: container, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cc046fdfc5..c36a52f81e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1906,11 +1906,11 @@ ToolRegistry.register({ mode="diff" before={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + contents: props.metadata?.filediff?.before || props.input.oldString || "", }} after={{ name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + contents: props.metadata?.filediff?.after || props.input.newString || "", }} />
    From 95280ebec9a8aa851f862fbdb4a48ec1243d93d9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 7 May 2026 17:05:35 +0530 Subject: [PATCH 048/812] fix(tui): restore custom provider in /connect (#26168) --- .../cli/cmd/tui/component/dialog-provider.tsx | 131 +++++++++++++++--- .../test/cli/cmd/tui/provider-options.test.ts | 29 ++++ 2 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/provider-options.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index d6cbda4133..16812fa8ab 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record = { google: 5, } +const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" +const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ + +type ProviderOptionBase = { + title: string + value: string + description?: string + category: string +} + +type ProviderOption = + | (ProviderOptionBase & { + type: "provider" + providerID: string + }) + | (ProviderOptionBase & { + type: "custom" + }) + +export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { + return [ + ...pipe( + list, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => ({ + type: "provider" as const, + title: provider.name, + value: provider.id, + providerID: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", + })), + ), + { + type: "custom", + title: "Other", + value: CUSTOM_PROVIDER_OPTION_VALUE, + description: "Custom provider", + category: "Providers", + }, + ] +} + +export function normalizeCustomProviderID(value: string) { + const providerID = value.trim().replace(/^@ai-sdk\//, "") + if (!CUSTOM_PROVIDER_ID.test(providerID)) return + return providerID +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -32,30 +86,61 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + + async function promptCustomProviderID(): Promise { + const value = await DialogPrompt.show(dialog, "Other", { + placeholder: "Provider id", + description: () => ( + + This only stores a credential. Configure the provider in opencode.json to use it. + + ), + }) + if (value === null) return + + const providerID = normalizeCustomProviderID(value) + if (providerID) return providerID + + toast.show({ + variant: "error", + message: "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + }) + return promptCustomProviderID() + } + const options = createMemo(() => { return pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + providerOptions(sync.data.provider_next.all), map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) + if (provider.type === "custom") { + return { + title: provider.title, + value: provider.value, + description: provider.description, + category: provider.category, + async onSelect() { + const providerID = await promptCustomProviderID() + if (!providerID) return + return dialog.replace(() => ) + }, + } + } + + const providerID = provider.providerID + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) + const connected = sync.data.provider_next.connected.includes(providerID) return { - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", - }[provider.id], + title: provider.title, + value: provider.value, + description: provider.description, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + category: provider.category, gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - const methods = sync.data.provider_auth[provider.id] ?? [ + const methods = sync.data.provider_auth[providerID] ?? [ { type: "api", label: "API key", @@ -93,7 +178,7 @@ export function createDialogProviderOptions() { } const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, + providerID, method: index, inputs, }) @@ -108,7 +193,7 @@ export function createDialogProviderOptions() { if (result.data?.method === "code") { dialog.replace(() => ( ( ( - + )) } }, @@ -256,11 +341,13 @@ interface ApiMethodProps { providerID: string title: string metadata?: Record + custom?: boolean } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() + const toast = useToast() const { theme } = useTheme() return ( @@ -305,6 +392,14 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() + if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) { + toast.show({ + variant: "info", + message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`, + }) + dialog.clear() + return + } dialog.replace(() => ) }} /> diff --git a/packages/opencode/test/cli/cmd/tui/provider-options.test.ts b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts new file mode 100644 index 0000000000..39d6398379 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider" + +describe("providerOptions", () => { + test("includes a synthetic Other option for custom providers", () => { + expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({ + title: "Other", + description: "Custom provider", + category: "Providers", + }) + }) + + test("does not use Other as the generic provider category", () => { + expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers") + }) + + test("does not collide with a configured provider named other", () => { + const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value) + expect(new Set(values).size).toBe(values.length) + }) + + test("normalizes and validates custom provider ids", () => { + expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider") + expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider") + expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider") + expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined() + expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined() + }) +}) From fbb7b5b1bf031e16f4bbe6db34038ccf501a7f3e Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:32:32 +0200 Subject: [PATCH 049/812] chore: add free tier usage alert --- infra/monitoring.ts | 35 +++++++++++++++++++ .../app/src/routes/honeycomb/webhook.ts | 10 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 26ba573a07..908078ba19 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -169,3 +169,38 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { }, ], }) + +new honeycomb.Trigger("IncreasedFreeTierRequests", { + disabled: true, + name: "Increased Free Tier Requests", + description, + queryJson: honeycomb.getQuerySpecificationOutput({ + calculations: [ + { + op: "COUNT", + name: "REQUESTS", + filterCombination: "AND", + filters: [ + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isFreeTier", op: "=", value: "true" }, + ], + }, + ], + timeRange: 14400, + }).json, + alertType: "on_change", + frequency: 3600, + thresholds: [{ op: ">=", value: 50, exceededLimit: 2 }], + baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], + recipients: [ + { + id: webhookRecipient.id, + notificationDetails: [ + { + variables: [{ name: "type", value: "custom" }], + }, + ], + }, + ], +}) diff --git a/packages/console/app/src/routes/honeycomb/webhook.ts b/packages/console/app/src/routes/honeycomb/webhook.ts index b4d5e4bf7e..367a93aeb0 100644 --- a/packages/console/app/src/routes/honeycomb/webhook.ts +++ b/packages/console/app/src/routes/honeycomb/webhook.ts @@ -23,15 +23,19 @@ const honeycombWebhookPayload = z.discriminatedUnion("type", [ type: z.literal("provider_http_errors"), groups, }), + basePayload.extend({ + type: z.literal("custom"), + }), ]) const postDiscordMessage = async (payload: z.infer) => { - const group = payload.type === "model_http_errors" ? "model" : "provider" - const names = (payload.groups ?? []).flatMap((item) => item.group.map((g) => g.value)) + const group = + payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined + const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value)) const content = [ `[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`, - names.length > 0 ? `Affected ${group}s:` : undefined, + group && names.length > 0 ? `Affected ${group}s:` : undefined, ...names.map((name) => `- ${name}`), "", `<@&${DISCORD_ALERT_ROLE_ID}>`, From 844fb719382decad09ba55d1f8e49d811ce550be Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 11:37:34 +0000 Subject: [PATCH 050/812] chore: generate --- .../cli/cmd/tui/component/dialog-provider.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 16812fa8ab..e12492a2d0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -103,7 +103,8 @@ export function createDialogProviderOptions() { toast.show({ variant: "error", - message: "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + message: + "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", }) return promptCustomProviderID() } @@ -192,22 +193,12 @@ export function createDialogProviderOptions() { } if (result.data?.method === "code") { dialog.replace(() => ( - + )) } if (result.data?.method === "auto") { dialog.replace(() => ( - + )) } } From d6e06c8950cc58a376c70b27667a1746fd282539 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:40:29 +0200 Subject: [PATCH 051/812] chore: fix free tier query --- infra/monitoring.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 908078ba19..e976044707 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -178,7 +178,6 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { calculations: [ { op: "COUNT", - name: "REQUESTS", filterCombination: "AND", filters: [ { column: "event_type", op: "=", value: "completions" }, From 9c9bc09f526d46c095c9d82b6ae7761c64281d4c Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 13:58:41 +0200 Subject: [PATCH 052/812] chore: fix free tier query --- infra/monitoring.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index e976044707..baf1f5d68b 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -175,22 +175,17 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { name: "Increased Free Tier Requests", description, queryJson: honeycomb.getQuerySpecificationOutput({ - calculations: [ - { - op: "COUNT", - filterCombination: "AND", - filters: [ - { column: "event_type", op: "=", value: "completions" }, - { column: "user_agent", op: "contains", value: "opencode" }, - { column: "isFreeTier", op: "=", value: "true" }, - ], - }, + calculations: [{ op: "COUNT" }], + filters: [ + { column: "event_type", op: "=", value: "completions" }, + { column: "user_agent", op: "contains", value: "opencode" }, + { column: "isFreeTier", op: "=", value: "true" }, ], - timeRange: 14400, + timeRange: 3600, }).json, alertType: "on_change", - frequency: 3600, - thresholds: [{ op: ">=", value: 50, exceededLimit: 2 }], + frequency: 900, + thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From b6ff1b18c739c307c8e55aa9ab64e5ef2040f919 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 14:10:47 +0200 Subject: [PATCH 053/812] chore: activate free tier requests query --- infra/monitoring.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index baf1f5d68b..aad090aa80 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -171,7 +171,6 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { }) new honeycomb.Trigger("IncreasedFreeTierRequests", { - disabled: true, name: "Increased Free Tier Requests", description, queryJson: honeycomb.getQuerySpecificationOutput({ @@ -185,7 +184,7 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { }).json, alertType: "on_change", frequency: 900, - thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From 3c4b4d5faf226b22fbb277bd7699b81484d49684 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 7 May 2026 10:24:17 -0400 Subject: [PATCH 054/812] feat(core): copy file changes when warping (#26190) --- .../cmd/tui/component/dialog-session-list.tsx | 8 +- .../tui/component/dialog-workspace-create.tsx | 39 ++++- .../dialog-workspace-file-changes.tsx | 138 ++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../opencode/src/control-plane/workspace.ts | 100 ++++++++++++- packages/opencode/src/git/index.ts | 17 ++- packages/opencode/src/project/vcs.ts | 85 ++++++++++- .../src/server/routes/control/workspace.ts | 33 ++++- .../instance/httpapi/groups/instance.ts | 48 +++++- .../instance/httpapi/groups/workspace.ts | 14 +- .../instance/httpapi/handlers/instance.ts | 27 ++++ .../instance/httpapi/handlers/workspace.ts | 25 +++- .../src/server/routes/instance/index.ts | 98 ++++++++++++- packages/opencode/src/util/locale.ts | 5 + .../cmd/tui/dialog-workspace-create.test.ts | 25 ++++ .../test/control-plane/workspace.test.ts | 58 +++++++- .../test/plugin/workspace-adapter.test.ts | 8 +- .../server/httpapi-instance-context.test.ts | 9 +- .../test/server/httpapi-session.test.ts | 10 +- .../server/httpapi-workspace-routing.test.ts | 9 +- .../test/server/httpapi-workspace.test.ts | 8 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 110 ++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 97 +++++++++++- 23 files changed, 955 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 09d952ef81..a521e07b1d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -12,7 +12,11 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" +import { + openWorkspaceSelect, + type WorkspaceSelection, + warpWorkspaceSession, +} from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" @@ -70,8 +74,10 @@ export function DialogSessionList() { sync, project, toast, + sourceWorkspaceID: session.workspaceID, workspaceID, sessionID: session.id, + copyChanges: false, done: list, }) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 157ca20582..31955dcf31 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -3,10 +3,13 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" +import { useRoute } from "@tui/context/route" import { createMemo, createSignal, onMount } from "solid-js" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +import { DialogAlert } from "../ui/dialog-alert" +import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = { type: string @@ -38,6 +41,7 @@ export function recentConnectedWorkspaces( get: (workspaceID: string) => WorkspaceInfo | undefined status: (workspaceID: string) => string | undefined limit?: number + omitWorkspaceID?: string }) { const workspaces = input.sessions .toSorted((a, b) => b.time.updated - a.time.updated) @@ -45,6 +49,7 @@ export function recentConnectedWorkspaces( const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] }) + .filter((workspace) => workspace.id !== input.omitWorkspaceID) .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) const recent = workspaces.slice(0, input.limit ?? 3) @@ -93,17 +98,29 @@ export async function warpWorkspaceSession(input: { sync: ReturnType project: ReturnType toast: ReturnType + sourceWorkspaceID?: string workspaceID: string | null sessionID: string + copyChanges: boolean done?: () => void }): Promise { const result = await input.sdk.client.experimental.workspace .warp({ id: input.workspaceID, sessionID: input.sessionID, + copyChanges: input.copyChanges, }) .catch(() => undefined) if (!result?.data) { + if (result?.error?.name === "VcsApplyError") { + await DialogAlert.show( + input.dialog, + "Unable to Warp Session", + "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.", + ) + return false + } + input.toast.show({ message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", @@ -143,16 +160,29 @@ export async function warpWorkspaceSession(input: { return true } +export async function confirmWorkspaceFileChanges(input: { + dialog: ReturnType + sdk: ReturnType + sourceWorkspaceID?: string +}) { + const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) + const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no" + if (!fileChangeChoice) return + return fileChangeChoice === "yes" +} + export function DialogWorkspaceSelect(props: { adapters?: Adapter[] onSelect: (selection: WorkspaceSelection) => Promise | void }) { const dialog = useDialog() const project = useProject() + const route = useRoute() const sync = useSync() const sdk = useSDK() const toast = useToast() const [adapters, setAdapters] = createSignal(props.adapters) + const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) onMount(() => { dialog.setSize("medium") @@ -171,6 +201,7 @@ export function DialogWorkspaceSelect(props: { sessions: sync.data.session, get: project.workspace.get, status: project.workspace.status, + omitWorkspaceID: omittedWorkspaceID(), }) return [ ...list.map((adapter) => ({ @@ -231,19 +262,23 @@ export function DialogWorkspaceSelect(props: { return } - dialog.replace(() => ) + dialog.replace(() => ) }} /> ) } -function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { +function DialogExistingWorkspaceSelect(props: { + omitWorkspaceID?: string + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const project = useProject() const options = createMemo[]>(() => project.workspace .list() .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .filter((workspace) => workspace.id !== props.omitWorkspaceID) .map((workspace: Workspace) => ({ title: workspace.name, description: `(${workspace.type})`, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx new file mode 100644 index 0000000000..b2cb20630c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx @@ -0,0 +1,138 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import type { VcsFileStatus } from "@opencode-ai/sdk/v2" +import { createMemo, For } from "solid-js" +import { createStore } from "solid-js/store" +import { Locale } from "@/util/locale" +import { useTheme } from "../context/theme" +import { useTuiConfig } from "../context/tui-config" +import { useDialog, type DialogContext } from "../ui/dialog" +import { getScrollAcceleration } from "../util/scroll" + +const options = ["no", "yes"] as const + +export type WorkspaceFileChangesChoice = (typeof options)[number] + +function statusLabel(status: VcsFileStatus["status"]) { + if (status === "added") return "A" + if (status === "deleted") return "D" + return "M" +} + +function changeCountWidth(file: VcsFileStatus) { + // The "plus 2" is for spaces + return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2 +} + +export function DialogWorkspaceFileChanges(props: { + files: VcsFileStatus[] + onSelect: (choice: WorkspaceFileChangesChoice) => void +}) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) + const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice }) + const height = createMemo(() => Math.min(props.files.length, 8)) + const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0)) + + function confirm() { + props.onSelect(store.active) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + confirm() + return + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.max(index - 1, 0)]) + return + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + const index = options.indexOf(store.active) + setStore("active", options[Math.min(index + 1, options.length - 1)]) + } + }) + + return ( + + + + File Changes Found + + dialog.clear()}> + esc + + + + + {(item) => ( + + + + {statusLabel(item.status)} + + + {Locale.truncateLeft(item.file, fileNameWidth())} + + + + + {" "} + {item.additions ? +{item.additions} : null} + {item.deletions ? -{item.deletions} : null} + + + + )} + + + + + Do you want to apply these changes after warping? + + + + + {(item) => ( + { + setStore("active", item) + props.onSelect(item) + dialog.clear() + }} + > + {item} + + )} + + + + ) +} + +DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => { + return new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(undefined), + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 41e32539ee..73ef5477e9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -42,7 +42,12 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" +import { + confirmWorkspaceFileChanges, + openWorkspaceSelect, + warpWorkspaceSession, + type WorkspaceSelection, +} from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" @@ -230,6 +235,9 @@ export function Prompt(props: PromptProps) { if (selection.type === "new") void createWorkspace(selection) return } + const sourceWorkspaceID = project.workspace.current() + const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) + if (copyChanges === undefined) return selectWorkspace(selection) dialog.clear() @@ -247,8 +255,10 @@ export function Prompt(props: PromptProps) { sync, project, toast, + sourceWorkspaceID, workspaceID: workspace.id, sessionID: props.sessionID, + copyChanges, }) if (warped) showWarpNotice(workspace.name) } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 24ca0e61bf..f9bab469b7 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -18,7 +18,7 @@ import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdapter } from "./adapters" -import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" +import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" import { SessionPrompt } from "@/session/prompt" @@ -31,6 +31,9 @@ import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" import { withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" +import { Vcs } from "@/project/vcs" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" export const Info = WorkspaceInfoSchema export type Info = WorkspaceInfo @@ -86,6 +89,7 @@ export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, + copyChanges: Schema.optional(Schema.Boolean), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type SessionWarpInput = Schema.Schema.Type @@ -137,6 +141,7 @@ type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError | SessionWarpHttpError + | Vcs.PatchApplyError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError @@ -167,6 +172,7 @@ export const layer = Layer.effect( const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service + const vcs = yield* Vcs.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -255,6 +261,66 @@ export const layer = Layer.effect( ) }) + const runInWorkspace = (input: { + workspaceID?: WorkspaceID + local: () => Effect.Effect + remote: (input: { + workspace: Info + target: Extract + }) => HttpClientRequest.HttpClientRequest + fallback: A + response?: "json" | "text" + }) => + Effect.gen(function* () { + if (!input.workspaceID) return yield* input.local() + + const workspace = yield* get(input.workspaceID) + if (!workspace) return input.fallback + + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(workspace)) + + if (target.type === "local") { + const store = yield* InstanceStore.Service + return yield* store.provide({ directory: target.directory }, input.local()) + } + + const response = yield* http.execute(input.remote({ workspace, target })).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target request failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + }), + ), + ) + if (!response) return input.fallback + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + log.warn("workspace target request failed", { + workspaceID: workspace.id, + status: response.status, + body, + }) + return input.fallback + } + + const body = input.response === "text" ? response.text : response.json + return yield* body.pipe( + Effect.map((result) => result as A), + Effect.catch((error) => + Effect.sync(() => { + log.warn("workspace target response decode failed", { + workspaceID: workspace.id, + error: errorData(error), + }) + return input.fallback + }), + ), + ) + }) + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( space: Info, url: URL | string, @@ -557,6 +623,36 @@ export const layer = Layer.effect( } } + const sourcePatch = + input.copyChanges && current?.workspaceID + ? yield* runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { + headers: new Headers(target.headers), + }), + fallback: "", + response: "text", + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + : "" + + if (sourcePatch) { + // Attempt to apply the file changes to the new workspace. + // We intentionally do first so if it fails we don't warp + // the session. + yield* runInWorkspace({ + workspaceID: input.workspaceID ?? undefined, + local: () => vcs.apply({ patch: sourcePatch }), + remote: ({ target }) => + HttpClientRequest.post(route(target.url, "/vcs/apply"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ patch: sourcePatch }), + }), + fallback: { applied: false }, + }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + } + if (input.workspaceID === null) { yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { @@ -866,6 +962,8 @@ export const defaultLayer = layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(Vcs.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index fff1d70b2a..349bbad466 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -68,6 +68,7 @@ export interface Options { readonly cwd: string readonly env?: Record readonly maxOutputBytes?: number + readonly stdin?: ChildProcess.CommandInput } export interface Interface { @@ -85,6 +86,7 @@ export interface Interface { readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -101,6 +103,8 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const encoder = new TextEncoder() + const stdin = (text: string) => Stream.make(encoder.encode(text)) const run = Effect.fn("Git.run")( function* (args: string[], opts: Options) { @@ -108,7 +112,7 @@ export const layer = Layer.effect( cwd: opts.cwd, env: opts.env, extendEnv: true, - stdin: "ignore", + stdin: opts.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", }) @@ -316,9 +320,13 @@ export const layer = Layer.effect( cwd, maxOutputBytes: 4096, }) + if (result.truncated) return - const parts = result.text().split("\t") + const text = result.text() + + const parts = text.split("\t") if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) return { @@ -328,6 +336,10 @@ export const layer = Layer.effect( } satisfies Stat }) + const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { + return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + }) + return Service.of({ run, branch, @@ -343,6 +355,7 @@ export const layer = Layer.effect( patchAll, patchUntracked, statUntracked, + applyPatch, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 8b3bedbf5b..02173453db 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,7 +6,7 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" +import { zod, zodObject } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -239,11 +239,39 @@ export const FileDiff = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = Schema.Schema.Type +export const FileStatus = Schema.Struct({ + file: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}) + .annotate({ identifier: "VcsFileStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileStatus = Schema.Schema.Type + +export const ApplyInput = Schema.Struct({ + patch: Schema.String, +}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +export type ApplyInput = Schema.Schema.Type + +export const ApplyResult = Schema.Struct({ + applied: Schema.Boolean, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ApplyResult = Schema.Schema.Type + +export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), +}) {} + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect readonly defaultBranch: () => Effect.Effect + readonly status: () => Effect.Effect readonly diff: (mode: Mode) => Effect.Effect + readonly diffRaw: () => Effect.Effect + readonly apply: (input: ApplyInput) => Effect.Effect } interface State { @@ -304,6 +332,31 @@ export const layer: Layer.Layer = Lay defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { return yield* InstanceState.use(state, (x) => x.root?.name) }), + status: Effect.fn("Vcs.status")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined + const [list, stats] = yield* Effect.all( + [git.status(ctx.directory), ref ? git.stats(ctx.directory, ref) : Effect.succeed([])], + { concurrency: 2 }, + ) + const map = nums(stats) + return yield* Effect.forEach( + list.toSorted((a, b) => a.file.localeCompare(b.file)), + (item) => + Effect.gen(function* () { + const stat = + map.get(item.file) ?? + (item.status === "added" ? yield* git.statUntracked(ctx.worktree, item.file) : undefined) + return { + file: item.file, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + } satisfies FileStatus + }), + ) + }), diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { const value = yield* InstanceState.get(state) const ctx = yield* InstanceState.context @@ -318,6 +371,36 @@ export const layer: Layer.Layer = Lay if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref) }), + diffRaw: Effect.fn("Vcs.diffRaw")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return "" + const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { + concurrency: 2, + }) + const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), + ) + return [tracked, ...untracked].filter(Boolean).join("\n") + }), + apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new PatchApplyError({ + message: "Patch can't be applied because the project is not git-based", + reason: "non-git", + }) + } + const applied = yield* git.applyPatch(ctx.directory, input.patch) + if (applied.exitCode !== 0) { + return yield* new PatchApplyError({ + message: "Patch can't be applied", + reason: "not-clean", + }) + } + return { applied: true } + }), }) }), ) diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 788aef3176..0c1bf252ed 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" +import { Vcs } from "@/project/vcs" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -164,19 +165,47 @@ export const WorkspaceRoutes = lazy(() => z.object({ id: zodObject(Workspace.Info).shape.id.nullable(), sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + copyChanges: z.boolean().optional(), }), ), async (c) => { const body = c.req.valid("json") - await AppRuntime.runPromise( + return AppRuntime.runPromise( Workspace.Service.use((workspace) => workspace.sessionWarp({ workspaceID: body.id, sessionID: body.sessionID, + copyChanges: body.copyChanges, + }), + ).pipe( + Effect.match({ + onFailure: (error) => { + if (error instanceof Vcs.PatchApplyError) { + return c.json( + { + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }, + 400, + ) + } + return c.json( + { + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }, + 400, + ) + }, + onSuccess: () => c.body(null, 204), }), ), ) - return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 463ea1ae4c..f2b0504a05 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -5,7 +5,7 @@ import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -23,11 +23,25 @@ export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) +export class ApiVcsApplyError extends Schema.ErrorClass("VcsApplyError")( + { + name: Schema.Literal("VcsApplyError"), + data: Schema.Struct({ + message: Schema.String, + reason: Schema.Literals(["non-git", "not-clean"]), + }), + }, + { httpApiStatus: 400 }, +) {} + export const InstancePaths = { dispose: "/instance/dispose", path: "/path", vcs: "/vcs", + vcsStatus: "/vcs/status", vcsDiff: "/vcs/diff", + vcsDiffRaw: "/vcs/diff/raw", + vcsApply: "/vcs/apply", command: "/command", agent: "/agent", skill: "/skill", @@ -68,6 +82,15 @@ export const InstanceApi = HttpApi.make("instance") "Retrieve version control system (VCS) information for the current project, such as git branch.", }), ), + HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + success: described(Schema.Array(Vcs.FileStatus), "VCS status"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.status", + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + }), + ), HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { query: VcsDiffQuery, success: described(Schema.Array(Vcs.FileDiff), "VCS diff"), @@ -78,6 +101,29 @@ export const InstanceApi = HttpApi.make("instance") description: "Retrieve the current git diff for the working tree or against the default branch.", }), ), + HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + success: described( + Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), + "Raw VCS diff", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diff.raw", + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + }), + ), + HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + payload: Vcs.ApplyInput, + success: described(Vcs.ApplyResult, "VCS patch applied"), + error: ApiVcsApplyError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.apply", + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index f197ab9765..66422c13b6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -2,6 +2,7 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -12,8 +13,19 @@ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fie export const WarpPayload = Schema.Struct({ id: Schema.NullOr(Workspace.Info.fields.id), sessionID: Workspace.SessionWarpInput.fields.sessionID, + copyChanges: Workspace.SessionWarpInput.fields.copyChanges, }) +export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( + { + name: Schema.Literal("WorkspaceWarpError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + export const WorkspacePaths = { adapters: `${root}/adapter`, list: root, @@ -78,7 +90,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiEndpoint.post("warp", WorkspacePaths.warp, { payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), - error: HttpApiError.BadRequest, + error: [ApiWorkspaceWarpError, ApiVcsApplyError], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.workspace.warp", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index c2a4503b48..50a7fecfa7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -9,6 +9,7 @@ import { Skill } from "@/skill" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" +import { ApiVcsApplyError } from "../groups/instance" import { markInstanceForDisposal } from "../lifecycle" export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => @@ -41,10 +42,33 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" return { branch, default_branch } }) + const getVcsStatus = Effect.fn("InstanceHttpApi.vcsStatus")(function* () { + return yield* vcs.status() + }) + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { return yield* vcs.diff(ctx.query.mode) }) + const getVcsDiffRaw = Effect.fn("InstanceHttpApi.vcsDiffRaw")(function* () { + return yield* vcs.diffRaw() + }) + + const applyVcs = Effect.fn("InstanceHttpApi.vcsApply")(function* (ctx: { payload: Vcs.ApplyInput }) { + return yield* vcs.apply(ctx.payload).pipe( + Effect.mapError( + (error) => + new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }), + ), + ) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -69,7 +93,10 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("dispose", dispose) .handle("path", getPath) .handle("vcs", getVcs) + .handle("vcsStatus", getVcsStatus) .handle("vcsDiff", getVcsDiff) + .handle("vcsDiffRaw", getVcsDiffRaw) + .handle("vcsApply", applyVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index b415943a62..d908eda9d1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,10 +1,12 @@ import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Vcs } from "@/project/vcs" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, WarpPayload } from "../groups/workspace" +import { ApiVcsApplyError } from "../groups/instance" +import { ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -44,8 +46,27 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .sessionWarp({ workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, + copyChanges: ctx.payload.copyChanges, }) - .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + .pipe( + Effect.mapError((error) => { + if (error instanceof Vcs.PatchApplyError) { + return new ApiVcsApplyError({ + name: "VcsApplyError", + data: { + message: error.message, + reason: error.reason, + }, + }) + } + return new ApiWorkspaceWarpError({ + name: "WorkspaceWarpError", + data: { + message: error.message, + }, + }) + }), + ) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 71662dea90..b6bf8baa74 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -27,7 +27,7 @@ import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { InstanceMiddleware } from "./middleware" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" import { ExperimentalHttpApiServer } from "./httpapi/server" import { EventPaths } from "./httpapi/event" import { ExperimentalPaths } from "./httpapi/groups/experimental" @@ -40,6 +40,7 @@ import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" import type { CorsOptions } from "@/server/cors" +import { errors } from "@/server/error" export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() @@ -86,7 +87,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) @@ -288,6 +292,98 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H return yield* vcs.diff(c.req.valid("query").mode) }), ) + .get( + "/vcs/status", + describeRoute({ + summary: "Get VCS status", + description: "Retrieve changed files in the current working tree without patches.", + operationId: "vcs.status", + responses: { + 200: { + description: "VCS status", + content: { + "application/json": { + schema: resolver(Vcs.FileStatus.zod.array()), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("InstanceRoutes.vcs.status", c, function* () { + const vcs = yield* Vcs.Service + return yield* vcs.status() + }), + ) + .get( + "/vcs/diff/raw", + describeRoute({ + summary: "Get raw VCS diff", + description: "Retrieve a raw patch for current uncommitted changes.", + operationId: "vcs.diff.raw", + responses: { + 200: { + description: "Raw VCS diff", + content: { + "text/x-diff": { + schema: resolver(z.string()), + }, + }, + }, + }, + }), + async (c) => { + const patch = await runRequest( + "InstanceRoutes.vcs.diffRaw", + c, + Vcs.Service.use((vcs) => vcs.diffRaw()), + ) + return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) + }, + ) + .post( + "/vcs/apply", + describeRoute({ + summary: "Apply VCS patch", + description: "Apply a raw patch to the current working tree.", + operationId: "vcs.apply", + responses: { + 200: { + description: "VCS patch applied", + content: { + "application/json": { + schema: resolver(Vcs.ApplyResult.zod), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Vcs.ApplyInput.zodObject), + async (c) => { + const result = await runRequest( + "InstanceRoutes.vcs.apply", + c, + Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (value) => ({ ok: true as const, value }), + }), + ), + ) + if (result.ok) return c.json(result.value) + return c.json( + { + name: "VcsApplyError", + data: { + message: result.error.message, + reason: result.error.reason, + }, + }, + 400, + ) + }, + ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 49f60e9311..ec900b4416 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -63,6 +63,11 @@ export function truncate(str: string, len: number): string { return str.slice(0, len - 1) + "…" } +export function truncateLeft(str: string, len: number): string { + if (str.length <= len) return str + return "…" + str.slice(-(len - 1)) +} + export function truncateMiddle(str: string, maxLength: number = 35): string { if (str.length <= maxLength) return str diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts index 7d051923f6..a32dc61125 100644 --- a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -35,4 +35,29 @@ describe("recentConnectedWorkspaces", () => { expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) }) + + test("omits the active workspace before limiting recent workspaces", () => { + const workspaces = [ + { id: "wrk_a", name: "alpha" }, + { id: "wrk_b", name: "beta" }, + { id: "wrk_c", name: "gamma" }, + { id: "wrk_d", name: "delta" }, + ] + + const { recent, hasMore } = recentConnectedWorkspaces({ + sessions: [ + { workspaceID: "wrk_a", time: { updated: 400 } }, + { workspaceID: "wrk_b", time: { updated: 300 } }, + { workspaceID: "wrk_c", time: { updated: 200 } }, + { workspaceID: "wrk_d", time: { updated: 100 } }, + ], + get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + status: () => "connected", + limit: 3, + omitWorkspaceID: "wrk_a", + }) + + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) + expect(hasMore).toBe(false) + }) }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 769e78fe9a..0eba431e1a 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { $ } from "bun" import fs from "node:fs/promises" import Http from "node:http" import path from "node:path" @@ -29,12 +30,17 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer, + WorkspaceOld.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), + ), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -107,6 +113,18 @@ async function withInstance(fn: (dir: string) => T | Promise) { }) } +async function initGitRepo(dir: string) { + await fs.mkdir(dir, { recursive: true }) + await $`git init`.cwd(dir).quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email "test@opencode.test"`.cwd(dir).quiet() + await $`git config user.name "Test"`.cwd(dir).quiet() + await fs.writeFile(path.join(dir, "tracked.txt"), "base\n") + await $`git add tracked.txt`.cwd(dir).quiet() + await $`git commit -m "base"`.cwd(dir).quiet() +} + const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) @@ -644,6 +662,33 @@ describe("workspace-old CRUD", () => { }) }) + test("sessionWarp applies source workspace patch to local target workspace", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-patch-prev-local") + const targetType = unique("warp-patch-target-local") + const previousDir = path.join(dir, "warp-patch-prev-local") + const targetDir = path.join(dir, "warp-patch-target-local") + await initGitRepo(previousDir) + await initGitRepo(targetDir) + await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n") + await fs.writeFile(path.join(previousDir, "new.txt"), "new\n") + + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + + expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n") + expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n") + }) + }) + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { await withInstance(async (dir) => { const previousType = unique("warp-detach-local") @@ -696,10 +741,12 @@ describe("workspace-old CRUD", () => { }, ]) } + if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch") if (call.url.pathname === "/warp-target/sync/replay") return yield* HttpServerResponse.json({ sessionID: "ok" }) if (call.url.pathname === "/warp-target/sync/steal") return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true }) return HttpServerResponse.text("unexpected", { status: 500 }) }), ) @@ -722,15 +769,18 @@ describe("workspace-old CRUD", () => { historySessionID = session.id historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 - yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ "POST /warp-source/sync/history", + "GET /warp-source/vcs/diff/raw", + "POST /warp-target/vcs/apply", "POST /warp-target/sync/replay", "POST /warp-target/sync/steal", ]) expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) - expect(calls[1].json).toMatchObject({ + expect(calls[2].json).toEqual({ patch: "remote patch" }) + expect(calls[3].json).toMatchObject({ directory: "remote-target-dir", events: [ { @@ -745,7 +795,7 @@ describe("workspace-old CRUD", () => { }, ], }) - expect(calls[2].json).toEqual({ sessionID: session.id }) + expect(calls[4].json).toEqual({ sessionID: session.id }) expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") expect(sessionSequenceOwner(session.id)).toBe(target.id) }), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 249087808d..9199a85a61 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -12,8 +12,14 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") +const { InstanceBootstrap } = await import("../../src/project/bootstrap") const { Instance } = await import("../../src/project/instance") -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const { InstanceStore } = await import("../../src/project/instance-store") +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 410dbe7426..5e00d77708 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,8 +10,10 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -36,6 +38,11 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, @@ -43,7 +50,7 @@ const it = testEffect( NodeServices.layer, InstanceLayer.layer, Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, ), ) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c45aacce75..c1d82446b9 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -9,6 +9,8 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { WithInstance } from "../../src/project/with-instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -30,6 +32,10 @@ void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) function app(experimental = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental @@ -106,7 +112,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri extra: null, projectID: input.projectID, }), - ).pipe(Effect.provide(Workspace.defaultLayer)) + ).pipe(Effect.provide(workspaceLayer)) }) function request(path: string, init?: RequestInit) { diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index b0b276841d..379b71a91e 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -20,6 +20,8 @@ import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { @@ -45,13 +47,18 @@ const testStateLayer = Layer.effectDiscard( }), ) +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + const it = testEffect( Layer.mergeAll( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer, - Workspace.defaultLayer, + workspaceLayer, Socket.layerWebSocketConstructorGlobal, ), ) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 21bf4120c9..9b38cb44a2 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -14,6 +14,8 @@ import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { WorkspaceRef } from "../../src/effect/instance-ref" @@ -23,9 +25,11 @@ void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const it = testEffect( - Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), ) +const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 803d9ed16e..ebedb1dd6b 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -202,8 +202,12 @@ import type { V2SessionMessagesResponses, V2SessionPromptResponses, V2SessionWaitResponses, + VcsApplyErrors, + VcsApplyResponses, + VcsDiffRawResponses, VcsDiffResponses, VcsGetResponses, + VcsStatusResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, @@ -1022,6 +1026,7 @@ export class Workspace extends HeyApiClient { workspace?: string id?: string | null sessionID?: string + copyChanges?: boolean }, options?: Options, ) { @@ -1034,6 +1039,7 @@ export class Workspace extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "id" }, { in: "body", key: "sessionID" }, + { in: "body", key: "copyChanges" }, ], }, ], @@ -1555,6 +1561,38 @@ export class Path extends HeyApiClient { } } +export class Diff extends HeyApiClient { + /** + * Get raw VCS diff + * + * Retrieve a raw patch for current uncommitted changes. + */ + public raw( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff/raw", + ...options, + ...params, + }) + } +} + export class Vcs extends HeyApiClient { /** * Get VCS info @@ -1586,6 +1624,36 @@ export class Vcs extends HeyApiClient { }) } + /** + * Get VCS status + * + * Retrieve changed files in the current working tree without patches. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/status", + ...options, + ...params, + }) + } + /** * Get VCS diff * @@ -1617,6 +1685,48 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Apply VCS patch + * + * Apply a raw patch to the current working tree. + */ + public apply( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/apply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _diff?: Diff + get diff2(): Diff { + return (this._diff ??= new Diff({ client: this.client })) + } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b58f6cfc2b..175fe69e66 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1474,6 +1474,13 @@ export type VcsInfo = { default_branch?: string } +export type VcsFileStatus = { + file: string + additions: number + deletions: number + status: "added" | "deleted" | "modified" +} + export type VcsFileDiff = { file: string patch: string @@ -1482,6 +1489,14 @@ export type VcsFileDiff = { status?: "added" | "deleted" | "modified" } +export type VcsApplyError = { + name: "VcsApplyError" + data: { + message: string + reason: "non-git" | "not-clean" + } +} + export type Command = { name: string description?: string @@ -1736,6 +1751,13 @@ export type Workspace = { projectID: string } +export type WorkspaceWarpError = { + name: "WorkspaceWarpError" + data: { + message: string + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -4020,6 +4042,25 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/status" +} + +export type VcsStatusResponses = { + /** + * VCS status + */ + 200: Array +} + +export type VcsStatusResponse = VcsStatusResponses[keyof VcsStatusResponses] + export type VcsDiffData = { body?: never path?: never @@ -4040,6 +4081,57 @@ export type VcsDiffResponses = { export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] +export type VcsDiffRawData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/diff/raw" +} + +export type VcsDiffRawResponses = { + /** + * Raw VCS diff + */ + 200: string +} + +export type VcsDiffRawResponse = VcsDiffRawResponses[keyof VcsDiffRawResponses] + +export type VcsApplyData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/apply" +} + +export type VcsApplyErrors = { + /** + * VcsApplyError + */ + 400: VcsApplyError +} + +export type VcsApplyError2 = VcsApplyErrors[keyof VcsApplyErrors] + +export type VcsApplyResponses = { + /** + * VCS patch applied + */ + 200: { + applied: boolean + } +} + +export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] + export type CommandListData = { body?: never path?: never @@ -6667,6 +6759,7 @@ export type ExperimentalWorkspaceWarpData = { body?: { id: string | null sessionID: string + copyChanges?: boolean } path?: never query?: { @@ -6678,9 +6771,9 @@ export type ExperimentalWorkspaceWarpData = { export type ExperimentalWorkspaceWarpErrors = { /** - * Bad request + * WorkspaceWarpError | VcsApplyError */ - 400: BadRequestError + 400: WorkspaceWarpError | VcsApplyError } export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] From 98e091796b6a293cf20b4187e8fc6a949e0295e4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 14:25:30 +0000 Subject: [PATCH 055/812] chore: generate --- .../cmd/tui/component/dialog-session-list.tsx | 6 +- .../tui/component/dialog-workspace-create.tsx | 8 +- packages/sdk/openapi.json | 252 +++++++++++++++++- 3 files changed, 257 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index a521e07b1d..e8dbaee394 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -12,11 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { - openWorkspaceSelect, - type WorkspaceSelection, - warpWorkspaceSession, -} from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 31955dcf31..d7e212ab15 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -166,7 +166,9 @@ export async function confirmWorkspaceFileChanges(input: { sourceWorkspaceID?: string }) { const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) - const fileChangeChoice = status?.data?.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no" + const fileChangeChoice = status?.data?.length + ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) + : "no" if (!fileChangeChoice) return return fileChangeChoice === "yes" } @@ -262,7 +264,9 @@ export function DialogWorkspaceSelect(props: { return } - dialog.replace(() => ) + dialog.replace(() => ( + + )) }} /> ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 477145f017..04c34e2dc1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1897,6 +1897,54 @@ ] } }, + "/vcs/status": { + "get": { + "tags": ["instance"], + "operationId": "vcs.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileStatus" + }, + "description": "VCS status" + } + } + } + } + }, + "description": "Retrieve changed files in the current working tree without patches.", + "summary": "Get VCS status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.status({\n ...\n})" + } + ] + } + }, "/vcs/diff": { "get": { "tags": ["instance"], @@ -1954,6 +2002,128 @@ ] } }, + "/vcs/diff/raw": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff.raw", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Raw VCS diff", + "content": { + "text/x-diff; charset=utf-8": { + "schema": { + "type": "string" + } + } + } + } + }, + "description": "Retrieve a raw patch for current uncommitted changes.", + "summary": "Get raw VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff.raw({\n ...\n})" + } + ] + } + }, + "/vcs/apply": { + "post": { + "tags": ["instance"], + "operationId": "vcs.apply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS patch applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "boolean" + } + }, + "required": ["applied"], + "additionalProperties": false, + "description": "VCS patch applied" + } + } + } + }, + "400": { + "description": "VcsApplyError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsApplyError" + } + } + } + } + }, + "description": "Apply a raw patch to the current working tree.", + "summary": "Apply VCS patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "patch": { + "type": "string" + } + }, + "required": ["patch"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.apply({\n ...\n})" + } + ] + } + }, "/command": { "get": { "tags": ["instance"], @@ -8396,11 +8566,18 @@ "description": "Session warped" }, "400": { - "description": "Bad request", + "description": "WorkspaceWarpError | VcsApplyError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceWarpError" + }, + { + "$ref": "#/components/schemas/VcsApplyError" + } + ] } } } @@ -8426,6 +8603,9 @@ }, "sessionID": { "type": "string" + }, + "copyChanges": { + "type": "boolean" } }, "required": ["id", "sessionID"], @@ -12665,6 +12845,28 @@ }, "additionalProperties": false }, + "VcsFileStatus": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] + } + }, + "required": ["file", "additions", "deletions", "status"], + "additionalProperties": false + }, "VcsFileDiff": { "type": "object", "properties": { @@ -12690,6 +12892,31 @@ "required": ["file", "patch", "additions", "deletions"], "additionalProperties": false }, + "VcsApplyError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["VcsApplyError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["non-git", "not-clean"] + } + }, + "required": ["message", "reason"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "Command": { "type": "object", "properties": { @@ -13431,6 +13658,27 @@ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], "additionalProperties": false }, + "WorkspaceWarpError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["WorkspaceWarpError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "SyncEventMessageUpdated": { "type": "object", "properties": { From fe594693a447fb4f456327888ae2fe5ffc4b6f3d Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 7 May 2026 14:52:09 +0000 Subject: [PATCH 056/812] sync release versions for v1.14.41 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 8e3c9b7452..4e70576306 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.40", + "version": "1.14.41", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -282,7 +282,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -311,7 +311,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.40", + "version": "1.14.41", "bin": { "opencode": "./bin/opencode", }, @@ -469,7 +469,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -504,7 +504,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "cross-spawn": "catalog:", }, @@ -519,7 +519,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -554,7 +554,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -603,7 +603,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 45908e45b8..600c011b6b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.40", + "version": "1.14.41", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 298ae4a8cf..f2471d2926 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c1acfab6e0..4ca29eb4c7 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.40", + "version": "1.14.41", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 9c0ce79d74..7e1d77d7dc 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.40", + "version": "1.14.41", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d9648b3243..34ddd073f0 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.40", + "version": "1.14.41", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 9d92e96e1d..995ab18ee5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.40", + "version": "1.14.41", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 431de79bc5..49e35c5db8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 867d2155da..beccdb6991 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.40", + "version": "1.14.41", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 666198d55e..8b4850c885 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.40" +version = "1.14.41" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.40/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f5bd20d0be..70812ab10a 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.40", + "version": "1.14.41", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 245bb86621..985e2c747f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.40", + "version": "1.14.41", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fa9e4214e8..861208770c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8029d2c9ae..2959cba2dd 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 6d2cd71e30..34175d66a2 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3e875f7524..fc065be9ef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.40", + "version": "1.14.41", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 59390274d5..252c81a295 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.40", + "version": "1.14.41", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4052393c0d..3eaca42fb7 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.40", + "version": "1.14.41", "publisher": "sst-dev", "repository": { "type": "git", From a300a6cc7a6e7e0ec99897d86b978fea7bb091c7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 7 May 2026 12:38:29 -0400 Subject: [PATCH 057/812] rebase migrations properly --- .opencode/opencode.jsonc | 2 +- .../snapshot.json | 56 +++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 82ab6d1b35..dab531d337 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -3,7 +3,7 @@ "provider": {}, "permission": { "edit": { - "packages/opencode/migration/*": "deny", + "packages/opencode/migration/*": "ask", }, }, "mcp": {}, diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json index 4f6ebe00c0..7a0d10337d 100644 --- a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -2,7 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "27114226-085b-421a-9a40-29b88747e29a", - "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "prevIds": ["2ec89846-dcf1-4977-ab5e-244ddc9e3d67"], "ddl": [ { "name": "account_state", @@ -37,7 +37,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +598,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +608,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +618,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +628,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +638,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +648,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -810,6 +810,26 @@ "entityType": "columns", "table": "session" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, { "type": "integer", "notNull": true, @@ -1122,9 +1142,9 @@ "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { "columns": ["project_id"], @@ -1236,8 +1256,8 @@ { "columns": ["id"], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { @@ -1332,9 +1352,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1350,9 +1370,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1364,9 +1384,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ From 626a488fb89208cd148aa97aae546d4b08b37f55 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Thu, 7 May 2026 19:38:04 +0200 Subject: [PATCH 058/812] chore: track model variant in honeycomb (#26188) --- packages/console/app/src/routes/zen/go/v1/chat/completions.ts | 1 + packages/console/app/src/routes/zen/go/v1/messages.ts | 1 + packages/console/app/src/routes/zen/util/handler.ts | 3 +++ packages/console/app/src/routes/zen/v1/chat/completions.ts | 1 + packages/console/app/src/routes/zen/v1/messages.ts | 1 + packages/console/app/src/routes/zen/v1/models/[model].ts | 1 + packages/console/app/src/routes/zen/v1/responses.ts | 1 + packages/console/function/src/log-processor.ts | 2 +- 8 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts index 9a57e893fb..a5cf41f8f0 100644 --- a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts index ee401e6aa2..e66f3658c6 100644 --- a/packages/console/app/src/routes/zen/go/v1/messages.ts +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index c12129ff1d..eb56ed829c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -71,6 +71,7 @@ export async function handler( modelList: "lite" | "full" parseApiKey: (headers: Headers) => string | undefined parseModel: (url: string, body: any) => string + parseVariant: (url: string, body: any) => string | undefined parseIsStream: (url: string, body: any) => boolean }, ) { @@ -93,6 +94,7 @@ export async function handler( const url = input.request.url const body = await input.request.json() const model = opts.parseModel(url, body) + const variant = opts.parseVariant(url, body) const isStream = opts.parseIsStream(url, body) const rawIp = input.request.headers.get("x-real-ip") ?? "" const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp @@ -109,6 +111,7 @@ export async function handler( request: requestId, client: ocClient, user_agent: userAgent, + "model.variant": variant, }) const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index e9e05197e2..a55d743565 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 9c09315a6e..f023d4f9ac 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index bc1168eb0c..bfe6e86540 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", + parseVariant: (url: string, body: any) => body.thinkingLevel, parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index cae625cf6f..539b2fdad0 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -7,6 +7,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, + parseVariant: (url: string, body: any) => body.reasoning?.effort, parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index f8b2cf5270..2bb741b7aa 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -19,7 +19,7 @@ export default { url.pathname !== "/zen/go/v1/responses" && !url.pathname.startsWith("/zen/go/v1/models/") ) - return + continue let data = { "cf.continent": event.event.request.cf?.continent, From 474e311f6f8aa4cef394bb57ef30b70dd745c0ff Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 14:23:21 -0400 Subject: [PATCH 059/812] sync --- packages/console/app/src/routes/zen/util/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index eb56ed829c..7cee86b47e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -165,6 +165,7 @@ export async function handler( if (typeof v === "string") { if (v === "$ip") return [[k, ip]] if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v === "$session") return sessionId ? [[k, sessionId]] : [] if (v.startsWith("$header.")) { const headerValue = input.request.headers.get(v.slice(8)) return headerValue ? [[k, headerValue]] : [] From 98f5e6e71334c3b600ad325441a022b7fcb4098a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 7 May 2026 20:35:31 +0200 Subject: [PATCH 060/812] introduce opentui keymap as sole key/cmd engine (#26053) --- .opencode/plugins/tui-smoke.tsx | 650 ++++++++------ .opencode/tui.json | 19 +- bun.lock | 31 +- package.json | 6 +- packages/opencode/package.json | 2 +- packages/opencode/script/schema.ts | 2 +- packages/opencode/specs/tui-plugins.md | 61 +- packages/opencode/specs/v2/keymappings.md | 26 + packages/opencode/src/cli/cmd/tui/app.tsx | 818 +++++++++--------- .../cli/cmd/tui/component/dialog-command.tsx | 172 ---- .../cmd/tui/component/dialog-go-upsell.tsx | 37 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 7 +- .../cli/cmd/tui/component/dialog-model.tsx | 11 +- .../cli/cmd/tui/component/dialog-provider.tsx | 23 +- .../dialog-session-delete-failed.tsx | 24 +- .../cmd/tui/component/dialog-session-list.tsx | 12 +- .../cli/cmd/tui/component/dialog-stash.tsx | 10 +- .../dialog-workspace-unavailable.tsx | 28 +- .../cmd/tui/component/prompt/autocomplete.tsx | 118 ++- .../cli/cmd/tui/component/prompt/index.tsx | 647 ++++++++------ .../cli/cmd/tui/component/prompt/traits.ts | 9 +- .../cmd/tui/component/textarea-keybindings.ts | 73 -- .../cmd/tui/config/legacy-keymap-transform.ts | 177 ++++ .../src/cli/cmd/tui/config/tui-schema.ts | 310 ++++++- .../opencode/src/cli/cmd/tui/config/tui.ts | 60 +- .../cli/cmd/tui/context/command-palette.tsx | 163 ++++ .../src/cli/cmd/tui/context/keybind.tsx | 105 --- .../cli/cmd/tui/context/plugin-keybinds.ts | 41 - .../src/cli/cmd/tui/context/tui-config.tsx | 2 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 37 +- .../tui/feature-plugins/system/plugins.tsx | 65 +- .../tui/feature-plugins/system/session-v2.tsx | 50 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 91 ++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 37 +- .../src/cli/cmd/tui/plugin/runtime.ts | 85 +- .../src/cli/cmd/tui/routes/session/index.tsx | 159 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 140 +-- .../cli/cmd/tui/routes/session/question.tsx | 234 +++-- .../tui/routes/session/subagent-footer.tsx | 22 +- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 21 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 41 +- .../cli/cmd/tui/ui/dialog-export-options.tsx | 70 +- .../src/cli/cmd/tui/ui/dialog-help.tsx | 20 +- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 16 - .../src/cli/cmd/tui/ui/dialog-select.tsx | 164 +++- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 55 +- .../opencode/src/cli/cmd/tui/util/scroll.ts | 4 +- .../src/cli/cmd/tui/util/selection.ts | 42 +- packages/opencode/src/config/keybinds.ts | 18 +- packages/opencode/src/util/keybind.ts | 103 --- .../test/cli/tui/keybind-plugin.test.ts | 90 -- .../opencode/test/cli/tui/plugin-add.test.ts | 11 +- .../test/cli/tui/plugin-install.test.ts | 6 +- .../cli/tui/plugin-loader-entrypoint.test.ts | 33 +- .../test/cli/tui/plugin-loader-pure.test.ts | 5 +- .../test/cli/tui/plugin-loader.test.ts | 308 ++++++- .../test/cli/tui/plugin-toggle.test.ts | 9 +- packages/opencode/test/config/tui.test.ts | 161 ++++ packages/opencode/test/fixture/tui-plugin.ts | 67 +- packages/opencode/test/fixture/tui-runtime.ts | 43 +- packages/opencode/test/keybind.test.ts | 421 --------- packages/plugin/package.json | 9 +- packages/plugin/src/tui.ts | 87 +- packages/web/src/content/docs/config.mdx | 16 +- packages/web/src/content/docs/keybinds.mdx | 403 ++++++--- packages/web/src/content/docs/tui.mdx | 17 +- .../script => script}/upgrade-opentui.ts | 31 +- 67 files changed, 3858 insertions(+), 2977 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts create mode 100644 packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts create mode 100644 packages/opencode/src/cli/cmd/tui/context/command-palette.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/keybind.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts create mode 100644 packages/opencode/src/cli/cmd/tui/keymap.tsx delete mode 100644 packages/opencode/src/util/keybind.ts delete mode 100644 packages/opencode/test/cli/tui/keybind-plugin.test.ts delete mode 100644 packages/opencode/test/keybind.test.ts rename {packages/opencode/script => script}/upgrade-opentui.ts (63%) diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 63f9f331e0..fc890537ec 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -1,37 +1,89 @@ /** @jsxImportSource @opentui/solid */ -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" -import { RGBA, VignetteEffect } from "@opentui/core" -import type { - TuiKeybindSet, - TuiPlugin, - TuiPluginApi, - TuiPluginMeta, - TuiPluginModule, - TuiSlotPlugin, -} from "@opencode-ai/plugin/tui" +import { useTerminalDimensions, type JSX } from "@opentui/solid" +import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" +import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" +import type { Binding } from "@opentui/keymap" +import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] -const bind = { - modal: "ctrl+shift+m", - screen: "ctrl+shift+o", - home: "escape,ctrl+h", - left: "left,h", - right: "right,l", - up: "up,k", - down: "down,j", - alert: "a", - confirm: "c", - prompt: "p", - select: "s", - modal_accept: "enter,return", - modal_close: "escape", - dialog_close: "escape", - local: "x", - local_push: "enter,return", - local_close: "q,backspace", - host: "z", +const command = { + modal: "plugin.smoke.modal", + screen: "plugin.smoke.screen", + alert: "plugin.smoke.alert", + confirm: "plugin.smoke.confirm", + prompt: "plugin.smoke.prompt", + select: "plugin.smoke.select", + host: "plugin.smoke.host", + home: "plugin.smoke.home", + toast: "plugin.smoke.toast", + dialog_close: "plugin.smoke.dialog.close", + local_push: "plugin.smoke.local.push", + local_pop: "plugin.smoke.local.pop", + screen_home: "plugin.smoke.screen.home", + screen_left: "plugin.smoke.screen.left", + screen_right: "plugin.smoke.screen.right", + screen_up: "plugin.smoke.screen.up", + screen_down: "plugin.smoke.screen.down", + screen_modal: "plugin.smoke.screen.modal", + screen_local: "plugin.smoke.screen.local", + screen_host: "plugin.smoke.screen.host", + screen_alert: "plugin.smoke.screen.alert", + screen_confirm: "plugin.smoke.screen.confirm", + screen_prompt: "plugin.smoke.screen.prompt", + screen_select: "plugin.smoke.screen.select", + modal_accept: "plugin.smoke.modal.accept", + modal_close: "plugin.smoke.modal.close", +} as const + +const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const +type SectionName = (typeof sectionNames)[number] +type SectionConfig = Record> +type ResolvedSections = Record[]> +type SmokeKeymap = { + sections?: Partial> } +type SmokeOptions = { + enabled?: boolean + label?: unknown + route?: unknown + vignette?: unknown + keymap?: SmokeKeymap +} + +const defaultKeymap = { + global: { + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + }, + dialog: { + [command.dialog_close]: "escape", + }, + local: { + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + }, + screen: { + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + }, + modal: { + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", + }, +} satisfies Record + const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback if (!value.trim()) return fallback @@ -43,16 +95,11 @@ const num = (value: unknown, fallback: number) => { return value } -const rec = (value: unknown) => { - if (!value || typeof value !== "object" || Array.isArray(value)) return - return Object.fromEntries(Object.entries(value)) -} - type Cfg = { label: string route: string vignette: number - keybinds: Record | undefined + keymap: SmokeKeymap | undefined } type Route = { @@ -69,12 +116,12 @@ type State = { local: number } -const cfg = (options: Record | undefined) => { +const cfg = (options: SmokeOptions | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keybinds: rec(options?.keybinds), + keymap: options?.keymap, } } @@ -85,7 +132,25 @@ const names = (input: Cfg) => { } } -type Keys = TuiKeybindSet +function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { + const sections = resolveBindingSections( + { + global: { ...defaultKeymap.global, ...input?.sections?.global }, + dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog }, + local: { ...defaultKeymap.local, ...input?.sections?.local }, + screen: { ...defaultKeymap.screen, ...input?.sections?.screen }, + modal: { ...defaultKeymap.modal, ...input?.sections?.modal }, + } satisfies BindingSectionsConfig, + { sections: sectionNames }, + ).sections + + return { + sections, + } +} + +type Keys = ReturnType + const ui = { panel: "#1d1d1d", border: "#4a4a4a", @@ -292,125 +357,161 @@ const Screen = (props: { } const pop = (base?: State) => { const next = base ?? current(props.api, props.route) - const local = Math.max(0, next.local - 1) - set(local, next) + set(Math.max(0, next.local - 1), next) } const show = () => { setTimeout(() => { open() }, 0) } - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.screen) return - const next = current(props.api, props.route) - if (props.api.ui.dialog.open) { - if (props.keys.match("dialog_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.ui.dialog.clear() - return - } - return - } + const screenActive = () => props.api.route.current.name === props.route.screen - if (next.local > 0) { - if (evt.name === "escape" || props.keys.match("local_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - pop(next) - return - } + useBindings(() => ({ + enabled: () => screenActive() && props.api.ui.dialog.open, + commands: [ + { + name: command.dialog_close, + run() { + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.keys.sections.dialog, + })) - if (props.keys.match("local_push", evt)) { - evt.preventDefault() - evt.stopPropagation() - push(next) - return - } - return - } + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0, + commands: [ + { + name: command.local_push, + run() { + push(current(props.api, props.route)) + }, + }, + { + name: command.local_pop, + run() { + pop(current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.local, + })) - if (props.keys.match("home", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") - return - } + useBindings(() => ({ + enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0, + commands: [ + { + name: command.screen_home, + run() { + props.api.route.navigate("home") + }, + }, + { + name: command.screen_left, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) + }, + }, + { + name: command.screen_right, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) + }, + }, + { + name: command.screen_up, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) + }, + }, + { + name: command.screen_down, + run() { + const next = current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) + }, + }, + { + name: command.screen_modal, + run() { + props.api.route.navigate(props.route.modal, current(props.api, props.route)) + }, + }, + { + name: command.screen_local, + run() { + open() + }, + }, + { + name: command.screen_host, + run() { + host(props.api, props.input, skin) + }, + }, + { + name: command.screen_alert, + run() { + warn(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_confirm, + run() { + check(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_prompt, + run() { + entry(props.api, props.route, current(props.api, props.route)) + }, + }, + { + name: command.screen_select, + run() { + picker(props.api, props.route, current(props.api, props.route)) + }, + }, + ], + bindings: props.keys.sections.screen, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [ + command.screen_home, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + command.screen_local, + command.screen_host, + command.local_push, + command.local_pop, + ], + }) - if (props.keys.match("left", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) - return - } - - if (props.keys.match("right", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) - return - } - - if (props.keys.match("up", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 }) - return - } - - if (props.keys.match("down", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 }) - return - } - - if (props.keys.match("modal", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.modal, next) - return - } - - if (props.keys.match("local", evt)) { - evt.preventDefault() - evt.stopPropagation() - open() - return - } - - if (props.keys.match("host", evt)) { - evt.preventDefault() - evt.stopPropagation() - host(props.api, props.input, skin) - return - } - - if (props.keys.match("alert", evt)) { - evt.preventDefault() - evt.stopPropagation() - warn(props.api, props.route, next) - return - } - - if (props.keys.match("confirm", evt)) { - evt.preventDefault() - evt.stopPropagation() - check(props.api, props.route, next) - return - } - - if (props.keys.match("prompt", evt)) { - evt.preventDefault() - evt.stopPropagation() - entry(props.api, props.route, next) - return - } - - if (props.keys.match("select", evt)) { - evt.preventDefault() - evt.stopPropagation() - picker(props.api, props.route, next) + return { + screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "", + screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "", + screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "", + screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "", + screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "", + screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "", + screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "", + screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "", + screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "", + screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "", + local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "", + local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "", } }) @@ -430,7 +531,7 @@ const Screen = (props: { {props.input.label} screen plugin route - {props.keys.print("home")} home + {shortcuts().screen_home} home @@ -477,7 +578,7 @@ const Screen = (props: { Counter: {value.count} - {props.keys.print("up")} / {props.keys.print("down")} change value + {shortcuts().screen_up} / {shortcuts().screen_down} change value ) : null} @@ -485,17 +586,16 @@ const Screen = (props: { {value.tab === 2 ? ( - {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "} - confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select + {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "} + confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select - {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack - local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} - close + local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close - {props.keys.print("home")} returns home + {shortcuts().screen_home} returns home ) : null} @@ -548,7 +648,7 @@ const Screen = (props: { Plugin-owned stack depth: {value.local} - {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close @@ -571,20 +671,35 @@ const Modal = (props: { const value = parse(props.params) const skin = tone(props.api) - useKeyboard((evt) => { - if (props.api.route.current.name !== props.route.modal) return + useBindings(() => ({ + enabled: () => props.api.route.current.name === props.route.modal, + commands: [ + { + name: command.modal_accept, + run() { + props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" }) + }, + }, + { + name: command.modal_close, + run() { + props.api.route.navigate("home") + }, + }, + ], + bindings: props.keys.sections.modal, + })) + const shortcuts = useKeymapSelector((keymap) => { + const bindings = keymap.getCommandBindings({ + visibility: "registered", + commands: [command.modal, command.screen, command.modal_accept, command.modal_close], + }) - if (props.keys.match("modal_accept", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate(props.route.screen, { ...value, source: "modal" }) - return - } - - if (props.keys.match("modal_close", evt)) { - evt.preventDefault() - evt.stopPropagation() - props.api.route.navigate("home") + return { + modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "", + screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "", + modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "", + modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "", } }) @@ -595,10 +710,10 @@ const Modal = (props: { {props.input.label} modal - {props.keys.print("modal")} modal command - {props.keys.print("screen")} screen command + {shortcuts().modal} modal command + {shortcuts().screen} screen command - {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes + {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes [ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { const route = names(input) - api.command.register(() => [ - { - title: `${input.label} modal`, - value: "plugin.smoke.modal", - keybind: keys.get("modal"), - category: "Plugin", - slash: { - name: "smoke", + api.keymap.registerLayer({ + commands: [ + { + name: command.modal, + title: `${input.label} modal`, + category: "Plugin", + namespace: "palette", + slashName: "smoke", + run() { + api.route.navigate(route.modal, { source: "command" }) + }, }, - onSelect: () => { - api.route.navigate(route.modal, { source: "command" }) + { + name: command.screen, + title: `${input.label} screen`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-screen", + run() { + api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + }, }, - }, - { - title: `${input.label} screen`, - value: "plugin.smoke.screen", - keybind: keys.get("screen"), - category: "Plugin", - slash: { - name: "smoke-screen", + { + name: command.alert, + title: `${input.label} alert dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-alert", + run() { + warn(api, route, current(api, route)) + }, }, - onSelect: () => { - api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) + { + name: command.confirm, + title: `${input.label} confirm dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-confirm", + run() { + check(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} alert dialog`, - value: "plugin.smoke.alert", - category: "Plugin", - slash: { - name: "smoke-alert", + { + name: command.prompt, + title: `${input.label} prompt dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-prompt", + run() { + entry(api, route, current(api, route)) + }, }, - onSelect: () => { - warn(api, route, current(api, route)) + { + name: command.select, + title: `${input.label} select dialog`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-select", + run() { + picker(api, route, current(api, route)) + }, }, - }, - { - title: `${input.label} confirm dialog`, - value: "plugin.smoke.confirm", - category: "Plugin", - slash: { - name: "smoke-confirm", + { + name: command.host, + title: `${input.label} host overlay`, + category: "Plugin", + namespace: "palette", + slashName: "smoke-host", + run() { + host(api, input, tone(api)) + }, }, - onSelect: () => { - check(api, route, current(api, route)) + { + name: command.home, + title: `${input.label} go home`, + category: "Plugin", + namespace: "palette", + enabled: () => api.route.current.name !== "home", + run() { + api.route.navigate("home") + }, }, - }, - { - title: `${input.label} prompt dialog`, - value: "plugin.smoke.prompt", - category: "Plugin", - slash: { - name: "smoke-prompt", + { + name: command.toast, + title: `${input.label} toast`, + category: "Plugin", + namespace: "palette", + run() { + api.ui.toast({ + variant: "info", + title: "Smoke", + message: "Plugin toast works", + duration: 2000, + }) + }, }, - onSelect: () => { - entry(api, route, current(api, route)) - }, - }, - { - title: `${input.label} select dialog`, - value: "plugin.smoke.select", - category: "Plugin", - slash: { - name: "smoke-select", - }, - onSelect: () => { - picker(api, route, current(api, route)) - }, - }, - { - title: `${input.label} host overlay`, - value: "plugin.smoke.host", - category: "Plugin", - slash: { - name: "smoke-host", - }, - onSelect: () => { - host(api, input, tone(api)) - }, - }, - { - title: `${input.label} go home`, - value: "plugin.smoke.home", - category: "Plugin", - enabled: api.route.current.name !== "home", - onSelect: () => { - api.route.navigate("home") - }, - }, - { - title: `${input.label} toast`, - value: "plugin.smoke.toast", - category: "Plugin", - onSelect: () => { - api.ui.toast({ - variant: "info", - title: "Smoke", - message: "Plugin toast works", - duration: 2000, - }) - }, - }, - ]) + ], + bindings: keys.sections.global, + }) } const tui: TuiPlugin = async (api, options, meta) => { - if (options?.enabled === false) return + const input = options as SmokeOptions | undefined + if (input?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(options ?? undefined) + const value = cfg(input) const route = names(value) - const keys = api.keybind.create(bind, value.keybinds) + const keys = createKeys(value.keymap) const fx = new VignetteEffect(value.vignette) const post = fx.apply.bind(fx) api.renderer.addPostProcessFn(post) diff --git a/.opencode/tui.json b/.opencode/tui.json index 1eee01b302..e795209d9c 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,11 +6,20 @@ { "enabled": false, "label": "workspace", - "keybinds": { - "modal": "ctrl+alt+m", - "screen": "ctrl+alt+o", - "home": "escape,ctrl+shift+h", - "dialog_close": "escape,q" + "keymap": { + "sections": { + "global": { + "plugin.smoke.modal": "ctrl+alt+m", + "plugin.smoke.screen": "ctrl+alt+o" + }, + "screen": { + "plugin.smoke.screen.home": "escape,ctrl+shift+h", + "plugin.smoke.screen.modal": "ctrl+alt+m" + }, + "dialog": { + "plugin.smoke.dialog.close": "escape,q" + } + } } } ] diff --git a/bun.lock b/bun.lock index 4e70576306..2f21ed7d54 100644 --- a/bun.lock +++ b/bun.lock @@ -379,6 +379,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", @@ -477,6 +478,7 @@ }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", @@ -484,11 +486,13 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2", + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4", }, "optionalPeers": [ "@opentui/core", + "@opentui/keymap", "@opentui/solid", ], }, @@ -663,8 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1589,21 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], + "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], - "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], + "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + + "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 9d9207c5ea..15d96e131c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", + "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", @@ -34,8 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.2", - "@opentui/solid": "0.2.2", + "@opentui/core": "0.2.4", + "@opentui/keymap": "0.2.4", + "@opentui/solid": "0.2.4", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 985e2c747f..06c1ac7371 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -11,7 +11,6 @@ "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "upgrade-opentui": "bun run script/upgrade-opentui.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "db": "bun drizzle-kit" @@ -125,6 +124,7 @@ "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 7a7cc4a730..b1a587075e 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2)) } diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 943125b79c..1a337a60c8 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -53,13 +53,21 @@ Minimal module shape: import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" const tui: TuiPlugin = async (api, options, meta) => { - api.command.register(() => [ - { - title: "Demo", - value: "demo.open", - onSelect: () => api.route.navigate("demo"), - }, - ]) + api.keymap.registerLayer({ + commands: [ + { + name: "demo.open", + title: "Demo", + category: "Plugin", + namespace: "palette", + slashName: "demo", + run() { + api.route.navigate("demo") + }, + }, + ], + bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }], + }) api.route.register([ { @@ -194,10 +202,10 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` -- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()` +- `api.keys.formatSequence(parts)`, `formatBindings(bindings)` +- `api.keymap` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` - `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog` -- `api.keybind.match`, `print`, `create` - `api.tuiConfig` - `api.kv.get`, `set`, `ready` - `api.state` @@ -209,23 +217,23 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)` - `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)` -### Commands +### Keymap -`api.command.register` returns an unregister function. Command rows support: +- `api.keymap` exposes the raw `Keymap` instance from the host. +- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer. +- Register commands with `api.keymap.registerLayer({ commands: [...] })`. +- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer. +- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap. +- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. +- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. +- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. -- `title`, `value` -- `description`, `category` -- `keybind` -- `suggested`, `hidden`, `enabled` -- `slash: { name, aliases? }` -- `onSelect` +### Keys -Command behavior: - -- Registrations are reactive. -- Later registrations win for duplicate `value` and for keybind handling. -- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`. -- `api.command.show()` opens the host command dialog directly. +- `api.keys` exposes host-formatted shortcut display helpers for plugin UI. +- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. +- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. +- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. ### Routes @@ -252,13 +260,6 @@ Command behavior: - `setSize("medium" | "large" | "xlarge")` - readonly `size`, `depth`, `open` -### Keybinds - -- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer. -- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set. -- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated. -- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`. - ### KV, state, client, events - `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced. diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md index 5b23db7954..30a298eee4 100644 --- a/packages/opencode/specs/v2/keymappings.md +++ b/packages/opencode/specs/v2/keymappings.md @@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `abc`. Comm _Why_ Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. + +## OpenTUI Keymap Migration + +The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`: + +- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names. +- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections. +- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`. +- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema. +- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured. +- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config. + +The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI. + +## Removing Legacy Later + +When switching fully to the new config style, remove legacy support with these exact changes: + +- Delete `packages/opencode/src/config/keybinds.ts`. +- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`. +- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`. +- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`. +- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead. +- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only. +- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`. +- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ea742f6997..a8cc7946a9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,4 +1,5 @@ -import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" @@ -11,6 +12,7 @@ import { ErrorBoundary, createSignal, onMount, + onCleanup, batch, Show, on, @@ -36,11 +38,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" -import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" -import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -60,15 +60,17 @@ 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 } from "@/cli/cmd/tui/plugin/api" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" +import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" import { FormatError, FormatUnknownError } from "@/cli/error" +import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" +import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" -function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { +function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) return { @@ -111,7 +113,7 @@ function errorMessage(error: unknown) { export function tui(input: { url: string args: Args - config: TuiConfig.Info + config: TuiConfig.Resolved onSnapshot?: () => Promise directory?: string fetch?: typeof fetch @@ -130,6 +132,7 @@ export function tui(input: { } const onBeforeExit = async () => { + offKeymap() await TuiPluginRuntime.dispose() } @@ -138,6 +141,9 @@ export function tui(input: { void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + const keymap = createDefaultOpenTuiKeymap(renderer) + const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config) + await render(() => { return ( )} > - - - - - - - - - - - - - + + + + + + + + + + + + + - + @@ -185,22 +191,22 @@ export function tui(input: { - + - - - - - - - - - - - - - + + + + + + + + + + + + + ) }, renderer) @@ -209,14 +215,17 @@ export function tui(input: { function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() const dialog = useDialog() const local = useLocal() const kv = useKV() - const command = useCommandDialog() - const keybind = useKeybind() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() const toast = useToast() @@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise }) { } const api = createTuiApi({ - command, tuiConfig, dialog, - keybind, + keymap, kv, route, routes, @@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise }) { setReady(true) }) - useKeyboard((evt) => { - if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return - const sel = renderer.getSelection() - if (!sel) return - - // Windows Terminal-like behavior: - // - Ctrl+C copies and dismisses selection - // - Esc dismisses selection - // - Most other key input dismisses selection and is passed through - if (evt.ctrl && evt.name === "c") { - if (!Selection.copy(renderer, toast)) { - renderer.clearSelection() - return - } - - evt.preventDefault() - evt.stopPropagation() - return - } - - if (evt.name === "escape") { - renderer.clearSelection() - evt.preventDefault() - evt.stopPropagation() - return - } - - const focus = renderer.currentFocusedRenderable - if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) { - return - } - - renderer.clearSelection() - }) + // Let selection copy/dismiss win ahead of normal bindings when the feature flag is on. + const offSelectionKeys = keymap.intercept( + "key", + ({ event }) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.handleSelectionKey(renderer, toast, event) + }, + { priority: 1 }, + ) + onCleanup(offSelectionKeys) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -410,379 +394,365 @@ function App(props: { onSnapshot?: () => Promise }) { ) const connected = useConnected() - command.register(() => [ - { - title: "Switch session", - value: "session.list", - keybind: "session_list", - category: "Session", - suggested: sync.data.session.length > 0, - slash: { - name: "sessions", - aliases: ["resume", "continue"], + const appCommands = createMemo(() => + [ + { + name: "command.palette.show", + title: "Show command palette", + hidden: true, + run: () => { + command.show() + }, }, - onSelect: () => { - dialog.replace(() => ) + { + name: "session.list", + title: "Switch session", + category: "Session", + suggested: sync.data.session.length > 0, + slashName: "sessions", + slashAliases: ["resume", "continue"], + run: () => { + dialog.replace(() => ) + }, }, - }, - { - title: "New session", - suggested: route.data.type === "session", - value: "session.new", - keybind: "session_new", - category: "Session", - slash: { - name: "new", - aliases: ["clear"], + { + name: "session.new", + title: "New session", + suggested: route.data.type === "session", + category: "Session", + slashName: "new", + slashAliases: ["clear"], + run: () => { + route.navigate({ + type: "home", + }) + dialog.clear() + }, }, - onSelect: () => { - route.navigate({ - type: "home", - }) - dialog.clear() + { + name: "model.list", + title: "Switch model", + suggested: true, + category: "Agent", + slashName: "models", + run: () => { + dialog.replace(() => ) + }, }, - }, - { - title: "Switch model", - value: "model.list", - keybind: "model_list", - suggested: true, - category: "Agent", - slash: { - name: "models", + { + name: "model.cycle_recent", + title: "Model cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(1) + }, }, - onSelect: () => { - dialog.replace(() => ) + { + name: "model.cycle_recent_reverse", + title: "Model cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(-1) + }, }, - }, - { - title: "Model cycle", - value: "model.cycle_recent", - keybind: "model_cycle_recent", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(1) + { + name: "model.cycle_favorite", + title: "Favorite cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(1) + }, }, - }, - { - title: "Model cycle reverse", - value: "model.cycle_recent_reverse", - keybind: "model_cycle_recent_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycle(-1) + { + name: "model.cycle_favorite_reverse", + title: "Favorite cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(-1) + }, }, - }, - { - title: "Favorite cycle", - value: "model.cycle_favorite", - keybind: "model_cycle_favorite", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(1) + { + name: "agent.list", + title: "Switch agent", + category: "Agent", + slashName: "agents", + run: () => { + dialog.replace(() => ) + }, }, - }, - { - title: "Favorite cycle reverse", - value: "model.cycle_favorite_reverse", - keybind: "model_cycle_favorite_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.model.cycleFavorite(-1) + { + name: "mcp.list", + title: "Toggle MCPs", + category: "Agent", + slashName: "mcps", + run: () => { + dialog.replace(() => ) + }, }, - }, - { - title: "Switch agent", - value: "agent.list", - keybind: "agent_list", - category: "Agent", - slash: { - name: "agents", + { + name: "agent.cycle", + title: "Agent cycle", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(1) + }, }, - onSelect: () => { - dialog.replace(() => ) + { + name: "variant.cycle", + title: "Variant cycle", + category: "Agent", + run: () => { + local.model.variant.cycle() + }, }, - }, - { - title: "Toggle MCPs", - value: "mcp.list", - category: "Agent", - slash: { - name: "mcps", + { + name: "variant.list", + title: "Switch model variant", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slashName: "variants", + run: () => { + dialog.replace(() => ) + }, }, - onSelect: () => { - dialog.replace(() => ) + { + name: "agent.cycle.reverse", + title: "Agent cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(-1) + }, }, - }, - { - title: "Agent cycle", - value: "agent.cycle", - keybind: "agent_cycle", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(1) + { + name: "provider.connect", + title: "Connect provider", + suggested: !connected(), + slashName: "connect", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", }, - }, - { - title: "Variant cycle", - value: "variant.cycle", - keybind: "variant_cycle", - category: "Agent", - onSelect: () => { - local.model.variant.cycle() - }, - }, - { - title: "Switch model variant", - value: "variant.list", - keybind: "variant_list", - category: "Agent", - hidden: local.model.variant.list().length === 0, - slash: { - name: "variants", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - { - title: "Agent cycle reverse", - value: "agent.cycle.reverse", - keybind: "agent_cycle_reverse", - category: "Agent", - hidden: true, - onSelect: () => { - local.agent.move(-1) - }, - }, - { - title: "Connect provider", - value: "provider.connect", - suggested: !connected(), - slash: { - name: "connect", - }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "Provider", - }, - ...(sync.data.console_state.switchableOrgCount > 1 - ? [ - { - title: "Switch org", - value: "console.org.switch", - suggested: Boolean(sync.data.console_state.activeOrgName), - slash: { - name: "org", - aliases: ["orgs", "switch-org"], + ...(sync.data.console_state.switchableOrgCount > 1 + ? [ + { + name: "console.org.switch", + title: "Switch org", + suggested: Boolean(sync.data.console_state.activeOrgName), + slashName: "org", + slashAliases: ["orgs", "switch-org"], + run: () => { + dialog.replace(() => ) + }, + category: "Provider", }, - onSelect: () => { - dialog.replace(() => ) - }, - category: "Provider", - }, - ] - : []), - { - title: "View status", - keybind: "status_view", - value: "opencode.status", - slash: { - name: "status", + ] + : []), + { + name: "opencode.status", + title: "View status", + slashName: "status", + run: () => { + dialog.replace(() => ) + }, + category: "System", }, - onSelect: () => { - dialog.replace(() => ) + { + name: "theme.switch", + title: "Switch theme", + slashName: "themes", + run: () => { + dialog.replace(() => ) + }, + category: "System", }, - category: "System", - }, - { - title: "Switch theme", - value: "theme.switch", - keybind: "theme_list", - slash: { - name: "themes", + { + name: "theme.switch_mode", + title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", + run: () => { + setMode(mode() === "dark" ? "light" : "dark") + dialog.clear() + }, + category: "System", }, - onSelect: () => { - dialog.replace(() => ) + { + name: "theme.mode.lock", + title: locked() ? "Unlock theme mode" : "Lock theme mode", + run: () => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", }, - category: "System", - }, - { - title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", - value: "theme.switch_mode", - onSelect: (dialog) => { - setMode(mode() === "dark" ? "light" : "dark") - dialog.clear() + { + name: "help.show", + title: "Help", + slashName: "help", + run: () => { + dialog.replace(() => ) + }, + category: "System", }, - category: "System", - }, - { - title: locked() ? "Unlock theme mode" : "Lock theme mode", - value: "theme.mode.lock", - onSelect: (dialog) => { - if (locked()) unlock() - else lock() - dialog.clear() + { + name: "docs.open", + title: "Open docs", + run: () => { + open("https://opencode.ai/docs").catch(() => {}) + dialog.clear() + }, + category: "System", }, - category: "System", - }, - { - title: "Help", - value: "help.show", - slash: { - name: "help", + { + name: "app.exit", + title: "Exit the app", + slashName: "exit", + slashAliases: ["quit", "q"], + enabled: () => { + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + run: () => exit(), + category: "System", }, - onSelect: () => { - dialog.replace(() => ) + { + name: "app.debug", + title: "Toggle debug panel", + category: "System", + run: () => { + renderer.toggleDebugOverlay() + dialog.clear() + }, }, - category: "System", - }, - { - title: "Open docs", - value: "docs.open", - onSelect: () => { - open("https://opencode.ai/docs").catch(() => {}) - dialog.clear() + { + name: "app.console", + title: "Toggle console", + category: "System", + run: () => { + renderer.console.toggle() + dialog.clear() + }, }, - category: "System", - }, - { - title: "Exit the app", - value: "app.exit", - slash: { - name: "exit", - aliases: ["quit", "q"], + { + name: "app.heap_snapshot", + title: "Write heap snapshot", + category: "System", + run: async () => { + const files = await props.onSnapshot?.() + toast.show({ + variant: "info", + message: `Heap snapshot written to ${files?.join(", ")}`, + duration: 5000, + }) + dialog.clear() + }, }, - onSelect: () => exit(), - category: "System", - }, - { - title: "Toggle debug panel", - category: "System", - value: "app.debug", - onSelect: (dialog) => { - renderer.toggleDebugOverlay() - dialog.clear() - }, - }, - { - title: "Toggle console", - category: "System", - value: "app.console", - onSelect: (dialog) => { - renderer.console.toggle() - dialog.clear() - }, - }, - { - title: "Write heap snapshot", - category: "System", - value: "app.heap_snapshot", - onSelect: async (dialog) => { - const files = await props.onSnapshot?.() - toast.show({ - variant: "info", - message: `Heap snapshot written to ${files?.join(", ")}`, - duration: 5000, - }) - dialog.clear() - }, - }, - { - title: "Suspend terminal", - value: "terminal.suspend", - keybind: "terminal_suspend", - category: "System", - hidden: true, - enabled: tuiConfig.keybinds?.terminal_suspend !== "none", - onSelect: () => { - process.once("SIGCONT", () => { - renderer.resume() - }) + { + name: "terminal.suspend", + title: "Suspend terminal", + category: "System", + hidden: true, + enabled: process.platform !== "win32", + run: () => { + process.once("SIGCONT", () => { + renderer.resume() + }) - renderer.suspend() - // pid=0 means send the signal to all processes in the process group - process.kill(0, "SIGTSTP") + renderer.suspend() + process.kill(0, "SIGTSTP") + }, }, - }, - { - title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", - value: "terminal.title.toggle", - keybind: "terminal_title_toggle", - category: "System", - onSelect: (dialog) => { - setTerminalTitleEnabled((prev) => { - const next = !prev - kv.set("terminal_title_enabled", next) - if (!next) renderer.setTerminalTitle("") - return next - }) - dialog.clear() + { + name: "terminal.title.toggle", + title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", + category: "System", + run: () => { + setTerminalTitleEnabled((prev) => { + const next = !prev + kv.set("terminal_title_enabled", next) + if (!next) renderer.setTerminalTitle("") + return next + }) + dialog.clear() + }, }, - }, - { - title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", - value: "app.toggle.animations", - category: "System", - onSelect: (dialog) => { - kv.set("animations_enabled", !kv.get("animations_enabled", true)) - dialog.clear() + { + name: "app.toggle.animations", + title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", + category: "System", + run: () => { + kv.set("animations_enabled", !kv.get("animations_enabled", true)) + 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() + { + name: "app.toggle.file_context", + title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", + category: "System", + run: () => { + 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() + { + name: "app.toggle.diffwrap", + title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + category: "System", + run: () => { + const current = kv.get("diff_wrap_mode", "word") + kv.set("diff_wrap_mode", current === "word" ? "none" : "word") + 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() + { + name: "app.toggle.paste_summary", + title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", + category: "System", + run: () => { + setPasteSummaryEnabled((prev) => { + const next = !prev + kv.set("paste_summary_enabled", next) + return next + }) + dialog.clear() + }, }, - }, - { - title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", - value: "app.toggle.diffwrap", - category: "System", - onSelect: (dialog) => { - const current = kv.get("diff_wrap_mode", "word") - kv.set("diff_wrap_mode", current === "word" ? "none" : "word") - dialog.clear() + { + name: "app.toggle.session_directory_filter", + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + category: "System", + run: async () => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, }, - }, - ]) + ].map((command) => ({ + namespace: "palette", + ...command, + })), + ) + + useBindings(() => ({ + commands: appCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.global, + })) event.on(TuiEvent.CommandExecute.type, (evt) => { - command.trigger(evt.properties.command) + command.run(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx deleted file mode 100644 index 49bf42c63e..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" -import { - createContext, - createMemo, - createSignal, - getOwner, - onCleanup, - runWithOwner, - useContext, - type Accessor, - type ParentProps, -} from "solid-js" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" - -type Context = ReturnType -const ctx = createContext() - -export type Slash = { - name: string - aliases?: string[] -} - -export type CommandOption = DialogSelectOption & { - keybind?: string - suggested?: boolean - slash?: Slash - hidden?: boolean - enabled?: boolean -} - -function init() { - const root = getOwner() - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) - const dialog = useDialog() - const keybind = useKeybind() - - const entries = createMemo(() => { - const all = registrations().flatMap((x) => x()) - return all.map((x) => ({ - ...x, - footer: x.keybind ? keybind.print(x.keybind) : undefined, - })) - }) - - const isEnabled = (option: CommandOption) => option.enabled !== false - const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden - - const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) - const suggestedOptions = createMemo(() => - visibleOptions() - .filter((option) => option.suggested) - .map((option) => ({ - ...option, - value: `suggested:${option.value}`, - category: "Suggested", - })), - ) - const suspended = () => suspendCount() > 0 - - useKeyboard((evt) => { - if (suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - for (const option of entries()) { - if (!isEnabled(option)) continue - if (option.keybind && keybind.match(option.keybind, evt)) { - evt.preventDefault() - option.onSelect?.(dialog) - return - } - } - }) - - const result = { - trigger(name: string) { - for (const option of entries()) { - if (option.value === name) { - if (!isEnabled(option)) return - option.onSelect?.(dialog) - return - } - } - }, - slashes() { - return visibleOptions().flatMap((option) => { - const slash = option.slash - if (!slash) return [] - return { - display: "/" + slash.name, - description: option.description ?? option.title, - aliases: slash.aliases?.map((alias) => "/" + alias), - onSelect: () => result.trigger(option.value), - } - }) - }, - keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) - }, - suspended, - show() { - dialog.replace(() => ) - }, - register(cb: () => CommandOption[]) { - const owner = getOwner() ?? root - if (!owner) return () => {} - - let list: Accessor | undefined - - // TUI plugins now register commands via an async store that runs outside an active reactive scope. - // runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly. - runWithOwner(owner, () => { - list = createMemo(cb) - const ref = list - if (!ref) return - setRegistrations((arr) => [ref, ...arr]) - onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== ref)) - }) - }) - - if (!list) return () => {} - let done = false - return () => { - if (done) return - done = true - const ref = list - if (!ref) return - setRegistrations((arr) => arr.filter((x) => x !== ref)) - } - }, - } - return result -} - -export function useCommandDialog() { - const value = useContext(ctx) - if (!value) { - throw new Error("useCommandDialog must be used within a CommandProvider") - } - return value -} - -export function CommandProvider(props: ParentProps) { - const value = init() - const dialog = useDialog() - const keybind = useKeybind() - - useKeyboard((evt) => { - if (value.suspended()) return - if (dialog.stack.length > 0) return - if (evt.defaultPrevented) return - if (keybind.match("command_list", evt)) { - evt.preventDefault() - value.show() - return - } - }) - - return {props.children} -} - -function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { - let ref: DialogSelectRef - const list = () => { - if (ref?.filter) return props.options - return [...props.suggestedOptions, ...props.options] - } - return (ref = r)} title="Commands" options={list()} /> -} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx index b512f9021c..3a1fd97b2c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx @@ -1,5 +1,4 @@ import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import open from "open" import { createSignal, onCleanup, onMount } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { useBindings } from "../keymap" const GO_URL = "https://opencode.ai/go" const PAD_X = 3 @@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) }) - useKeyboard((evt) => { - if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { - setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (selected() === "subscribe") subscribe(props, dialog) - else dismiss(props, dialog) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "left", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "right", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "tab", + cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + }, + { + key: "return", + cmd: () => { + if (selected() === "subscribe") subscribe(props, dialog) + else dismiss(props, dialog) + }, + }, + ], + })) return ( (content = item)}> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 173c5ff60c..faa26dc3a6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -4,7 +4,6 @@ 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/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" @@ -45,9 +44,9 @@ export function DialogMcp() { ) }) - const keybinds = createMemo(() => [ + const actions = createMemo(() => [ { - keybind: Keybind.parse("space")[0], + command: "dialog.action.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress @@ -77,7 +76,7 @@ export function DialogMcp() { ref={setRef} title="MCPs" options={options()} - keybind={keybinds()} + actions={actions()} onSelect={(_option) => { // Don't close on select, only on escape }} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 06723f3c2b..068c6a1e03 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" -import { useKeybind } from "../context/keybind" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" +import { useTuiConfig } from "../context/tui-config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) { return ( [number]["value"]> options={options()} - keybind={[ + actions={[ { - keybind: keybind.all.model_provider_list?.[0], + command: "model.dialog.provider", title: connected() ? "Connect provider" : "View all providers", onTrigger() { dialog.replace(() => ) }, }, { - keybind: keybind.all.model_favorite_toggle?.[0], + command: "model.dialog.favorite", title: "Favorite", disabled: !connected(), onTrigger: (option) => { @@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} + bindings={tuiConfig.keymap.sections.model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index e12492a2d0..db7cf1bb0a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,11 +10,11 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" -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" +import { useBindings } from "../keymap" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) { const sync = useSync() const toast = useToast() - useKeyboard((evt) => { - if (evt.name === "c" && !evt.ctrl && !evt.meta) { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - Clipboard.copy(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - } - }) + useBindings(() => ({ + bindings: [ + { + key: "c", + cmd: () => { + const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + Clipboard.copy(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], + })) onMount(async () => { const result = await sdk.client.provider.oauth.callback({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx index 3d3059d953..cdd50019e4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export function DialogSessionDeleteFailed(props: { session: string @@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: { if (!props.onDone) dialog.clear() } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - } - if (evt.name === "left" || evt.name === "up") { - setStore("active", "delete") - } - if (evt.name === "right" || evt.name === "down") { - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "delete") }, + { key: "up", cmd: () => setStore("active", "delete") }, + { key: "right", cmd: () => setStore("active", "restore") }, + { key: "down", cmd: () => setStore("active", "restore") }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index e8dbaee394..6d3322151a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync" import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" 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 "@opencode-ai/core/flag/flag" @@ -17,18 +16,19 @@ import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { WorkspaceLabel } from "./workspace-label" +import { useCommandShortcut } from "../keymap" export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() const project = useProject() - const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) + const deleteHint = useCommandShortcut("dialog.action.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -156,7 +156,7 @@ export function DialogSessionList() { const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -187,9 +187,9 @@ export function DialogSessionList() { }) dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.session_delete?.[0], + command: "dialog.action.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -237,7 +237,7 @@ export function DialogSessionList() { }, }, { - keybind: keybind.all.session_rename?.[0], + command: "dialog.action.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index e8664f6289..62843c2527 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { createMemo, createSignal } from "solid-js" import { Locale } from "@/util/locale" import { useTheme } from "../context/theme" -import { useKeybind } from "../context/keybind" import { usePromptStash, type StashEntry } from "./prompt/stash" +import { useCommandShortcut } from "../keymap" function getRelativeTime(timestamp: number): string { const now = Date.now() @@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() const { theme } = useTheme() - const keybind = useKeybind() const [toDelete, setToDelete] = createSignal() + const deleteHint = useCommandShortcut("dialog.action.delete") const options = createMemo(() => { const entries = stash.list() @@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const isDeleting = toDelete() === index const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), + title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), @@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { } dialog.clear() }} - keybind={[ + actions={[ { - keybind: keybind.all.stash_delete?.[0], + command: "dialog.action.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx index 7a21798534..0da7394bc4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx @@ -1,9 +1,9 @@ import { TextAttributes } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" import { createStore } from "solid-js/store" import { For } from "solid-js" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" +import { useBindings } from "../keymap" export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise }) { const dialog = useDialog() @@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | if (result === false) return } - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - void confirm() - return - } - if (evt.name === "left") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "cancel") - return - } - if (evt.name === "right") { - evt.preventDefault() - evt.stopPropagation() - setStore("active", "restore") - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => void confirm() }, + { key: "left", cmd: () => setStore("active", "cancel") }, + { key: "right", cmd: () => setStore("active", "restore") }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 47bb162cb4..7a2548704d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,4 +1,4 @@ -import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" +import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import path from "path" @@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" -import { useCommandDialog } from "@tui/component/dialog-command" +import { useCommandPalette } from "../../context/command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { useBindings } from "../../keymap" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -52,7 +53,6 @@ function extractLineRange(input: string) { export type AutocompleteRef = { onInput: (value: string) => void - onKeyDown: (e: KeyEvent) => void visible: false | "@" | "/" } @@ -82,12 +82,14 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() - const command = useCommandDialog() + const command = useCommandPalette() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - + const { + keymap: { sections }, + } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -282,7 +284,7 @@ export function Autocomplete(props: { const { filename, part } = createFilePart(item, lineRange) const index = store.visible === "@" ? store.index : props.input().cursorOffset - command.keybinds(true) + command.suspend(false) setStore("visible", false) setStore("index", index) insertPart(filename, part) @@ -520,8 +522,54 @@ export function Autocomplete(props: { setStore("selected", 0) } + useBindings(() => ({ + target: props.input, + enabled: () => Boolean(store.visible), + commands: [ + { + name: "prompt.autocomplete.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "prompt.autocomplete.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "prompt.autocomplete.hide", + run() { + hide() + }, + }, + { + name: "prompt.autocomplete.select", + run() { + select() + }, + }, + { + name: "prompt.autocomplete.complete", + run() { + const selected = options()[store.selected] + if (selected?.isDirectory) { + expandDirectory() + return + } + + select() + }, + }, + ], + bindings: sections.autocomplete, + })) + function show(mode: "@" | "/") { - command.keybinds(false) + command.suspend(true) setStore({ visible: mode, index: props.input().cursorOffset, @@ -538,7 +586,7 @@ export function Autocomplete(props: { draft.input = props.input().plainText }) } - command.keybinds(true) + command.suspend(false) setStore("visible", false) } @@ -593,60 +641,6 @@ export function Autocomplete(props: { setStore("index", idx) } }, - onKeyDown(e: KeyEvent) { - if (store.visible) { - const name = e.name?.toLowerCase() - const ctrlOnly = e.ctrl && !e.meta && !e.shift - const isNavUp = name === "up" || (ctrlOnly && name === "p") - const isNavDown = name === "down" || (ctrlOnly && name === "n") - - if (isNavUp) { - setStore("input", "keyboard") - move(-1) - e.preventDefault() - return - } - if (isNavDown) { - setStore("input", "keyboard") - move(1) - e.preventDefault() - return - } - if (name === "escape") { - hide() - e.preventDefault() - return - } - if (name === "return") { - select() - e.preventDefault() - return - } - if (name === "tab") { - const selected = options()[store.selected] - if (selected?.isDirectory) { - expandDirectory() - } else { - select() - } - e.preventDefault() - return - } - } - if (!store.visible) { - if (e.name === "@") { - const cursorOffset = props.input().cursorOffset - const charBeforeCursor = - cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset) - const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor) - if (canTrigger) show("@") - } - - if (e.name === "/") { - if (props.input().cursorOffset === 0) show("/") - } - } - }, }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 73ef5477e9..71fb256a7f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,14 @@ -import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" +import { + BoxRenderable, + RGBA, + TextareaRenderable, + MouseEvent, + PasteEvent, + decodePasteBytes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import path from "path" @@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event" 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" import { usePromptHistory, type PromptInfo } from "./history" import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" -import { useCommandDialog } from "../dialog-command" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" @@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" -import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" import { confirmWorkspaceFileChanges, @@ -51,7 +58,15 @@ import { import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" -import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" +import { type WorkspaceStatus } from "../workspace-label" +import { useCommandPalette } from "../../context/command-palette" +import { + useBindings, + useCommandShortcut, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" +import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { sessionID?: string @@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable - let autocomplete: AutocompleteRef + const [inputTarget, setInputTarget] = createSignal() - const keybind = useKeybind() + const leader = useLeaderActive() const local = useLocal() const args = useArgs() const sdk = useSDK() @@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) { const route = useRoute() const project = useProject() const sync = useSync() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() - const command = useCommandDialog() + const command = useCommandPalette() + const keymap = useOpencodeKeymap() + const agentShortcut = useCommandShortcut("agent.cycle") + const paletteShortcut = useCommandShortcut("command.palette.show") const renderer = useRenderer() const dimensions = useTerminalDimensions() const { theme, syntax } = useTheme() @@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) { const [workspaceCreating, setWorkspaceCreating] = createSignal(false) const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) const [warpNotice, setWarpNotice] = createSignal() + const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) @@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) { setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) editor.clearSelection() } - - const textareaKeybindings = useTextareaKeybindings() - const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! @@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) { } }) - command.register(() => { - return [ + const promptCommands = createMemo(() => + [ { title: "Clear prompt", - value: "prompt.clear", + name: "prompt.clear", category: "Prompt", hidden: true, - onSelect: (dialog) => { - input.extmarks.clear() + run: () => { input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) dialog.clear() }, }, { title: "Submit prompt", - value: "prompt.submit", - keybind: "input_submit", + name: "prompt.submit", category: "Prompt", hidden: true, - onSelect: async (dialog) => { + run: async () => { if (!input.focused) return const handled = await submit() if (!handled) return @@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) { }, { title: "Remove editor context", - value: "prompt.editor_context.clear", + name: "prompt.editor_context.clear", category: "Prompt", enabled: Boolean(editorContext()), - onSelect: (dialog) => { + run: () => { dismissEditorContext() dialog.clear() }, }, { title: "Paste", - value: "prompt.paste", - keybind: "input_paste", + name: "prompt.paste", category: "Prompt", hidden: true, - onSelect: async () => { + run: async (ctx: CommandContext) => { + ctx.event.preventDefault() + ctx.event.stopPropagation() const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteAttachment({ @@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) { mime: content.mime, content: content.data, }) + return + } + if (content?.mime === "text/plain") { + await pasteInputText(content.data) } }, }, { title: "Interrupt session", - value: "session.interrupt", - keybind: "session_interrupt", + name: "session.interrupt", category: "Session", hidden: true, enabled: status().type !== "idle", - onSelect: (dialog) => { - if (autocomplete.visible) return + run: () => { + if (auto()?.visible) return if (!input.focused) return // TODO: this should be its own command if (store.mode === "shell") { @@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) { { title: "Open editor", category: "Session", - keybind: "editor_open", - value: "prompt.editor", - slash: { - name: "editor", - }, - onSelect: async (dialog) => { + name: "prompt.editor", + slashName: "editor", + run: async () => { dialog.clear() // replace summarized text parts with the actual text @@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) { }, { title: "Skills", - value: "prompt.skills", + name: "prompt.skills", category: "Prompt", - slash: { - name: "skills", - }, - onSelect: () => { + slashName: "skills", + run: () => { dialog.replace(() => ( { @@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) { }, { title: "Warp", - description: "Change the workspace for the session", - value: "workspace.set", + desc: "Change the workspace for the session", + name: "workspace.set", category: "Session", enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slash: { - name: "warp", - }, - onSelect: (dialog) => { + slashName: "warp", + run: () => { void openWorkspaceSelect({ dialog, sdk, @@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) { }) }, }, - ] - }) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: promptCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: keymapConfig.pick("prompt", [ + "prompt.submit", + "prompt.editor", + "prompt.editor_context.clear", + "prompt.stash", + "prompt.stash.pop", + "prompt.stash.list", + "session.interrupt", + "workspace.set", + ]), + })) const ref: PromptRef = { get focused() { @@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) { if (store.prompt.input) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } + setInputTarget(undefined) props.ref?.(undefined) }) @@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - input.traits = computePromptTraits({ - mode: store.mode, - disabled: !!props.disabled, - autocompleteVisible: !!auto()?.visible, - }) + input.traits = { + ...input.traits, + ...computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }), + } }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) { ) } - command.register(() => [ - { - title: "Stash prompt", - value: "prompt.stash", - category: "Prompt", - enabled: !!store.prompt.input, - onSelect: (dialog) => { - if (!store.prompt.input) return - stash.push({ - input: store.prompt.input, - parts: store.prompt.parts, - }) - input.extmarks.clear() - input.clear() - setStore("prompt", { input: "", parts: [] }) - setStore("extmarkToPartIndex", new Map()) - dialog.clear() + const stashCommands = createMemo(() => + [ + { + title: "Stash prompt", + name: "prompt.stash", + category: "Prompt", + enabled: !!store.prompt.input, + run: () => { + if (!store.prompt.input) return + stash.push({ + input: store.prompt.input, + parts: store.prompt.parts, + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + dialog.clear() + }, }, - }, - { - title: "Stash pop", - value: "prompt.stash.pop", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - const entry = stash.pop() - if (entry) { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - } - dialog.clear() + { + title: "Stash pop", + name: "prompt.stash.pop", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + const entry = stash.pop() + if (entry) { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + } + dialog.clear() + }, }, - }, - { - title: "Stash list", - value: "prompt.stash.list", - category: "Prompt", - enabled: stash.list().length > 0, - onSelect: (dialog) => { - dialog.replace(() => ( - { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) - input.gotoBufferEnd() - }} - /> - )) + { + title: "Stash list", + name: "prompt.stash.list", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + dialog.replace(() => ( + { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + }} + /> + )) + }, }, - }, - ]) + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: stashCommands(), + })) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled, + bindings: keymapConfig.pick("prompt", ["prompt.paste"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + bindings: keymapConfig.pick("prompt", ["prompt.clear"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 + })(), + bindings: [ + { + key: "!", + cmd: () => { + setStore("placeholder", randomIndex(shell().length)) + setStore("mode", "shell") + }, + }, + ], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && store.mode === "shell", + bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 + })(), + bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) + ) + })(), + commands: [ + { + name: "prompt.history.previous", + run() { + if (input.cursorOffset !== 0) { + input.cursorOffset = 0 + return + } + + const item = history.move(-1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = 0 + }, + }, + ], + bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + !auto()?.visible && + input !== undefined && + (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) + ) + })(), + commands: [ + { + name: "prompt.history.next", + run() { + if (input.cursorOffset !== input.plainText.length) { + input.cursorOffset = input.plainText.length + return + } + + const item = history.move(1, input.plainText) + if (!item) return + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }, + }, + ], + bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), + } + }) async function submit() { setWarpNotice(undefined) @@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) { } if (props.disabled) return false if (workspaceCreating()) return false - if (autocomplete?.visible) return false + if (auto()?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() if (!agent) return false @@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) { ) } + async function pasteInputText(text: string) { + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const pastedContent = normalizedText.trim() + const filepath = iife(() => { + const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (process.platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") + }) + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + try { + const mime = await Filesystem.mimeType(filepath) + const filename = path.basename(filepath) + if (mime === "image/svg+xml") { + const content = await Filesystem.readText(filepath).catch(() => {}) + if (content) { + pasteText(content, `[SVG: ${filename ?? "image"}]`) + return + } + } + if (mime.startsWith("image/") || mime === "application/pdf") { + const content = await Filesystem.readArrayBuffer(filepath) + .then((buffer) => Buffer.from(buffer).toString("base64")) + .catch(() => {}) + if (content) { + await pasteAttachment({ + filename, + filepath, + mime, + content, + }) + return + } + } + } catch {} + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(normalizedText) + + setTimeout(() => { + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { const currentOffset = input.visualCursor.offset const extmarkStart = currentOffset @@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) { } const highlight = createMemo(() => { - if (keybind.leader) return theme.border + if (leader()) return theme.border if (store.mode === "shell") return theme.primary const agent = local.agent.current() if (!agent) return theme.border @@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) { return ( <> - { - autocomplete = r - setAuto(() => r) - }} - anchor={() => anchor} - input={() => input} - setPrompt={(cb) => { - setStore("prompt", produce(cb)) - }} - setExtmark={(partIndex, extmarkId) => { - setStore("extmarkToPartIndex", (map: Map) => { - const newMap = new Map(map) - newMap.set(extmarkId, partIndex) - return newMap - }) - }} - value={store.prompt.input} - fileStyleId={fileStyleId} - agentStyleId={agentStyleId} - promptPartTypeId={() => promptPartTypeId} - /> - (anchor = r)} visible={props.visible !== false}> + (anchor = r)} visible={props.visible !== false}> { const value = input.plainText setStore("prompt", "input", value) - autocomplete.onInput(value) + auto()?.onInput(value) syncExtmarksWithPromptParts() + setCursorVersion((value) => value + 1) }} - keyBindings={textareaKeybindings()} - onKeyDown={async (e) => { + onCursorChange={() => setCursorVersion((value) => value + 1)} + onKeyDown={(e: { preventDefault(): void }) => { if (props.disabled) { e.preventDefault() return } - // Check clipboard for images before terminal-handled paste runs. - // This helps terminals that forward Ctrl+V to the app; Windows - // Terminal 1.25+ usually handles Ctrl+V before this path. - if (keybind.match("input_paste", e)) { - const content = await Clipboard.read() - if (content?.mime.startsWith("image/")) { - e.preventDefault() - await pasteAttachment({ - filename: "clipboard", - mime: content.mime, - content: content.data, - }) - return - } - // If no image, let the default paste behavior continue - } - if (keybind.match("input_clear", e) && store.prompt.input !== "") { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - return - } - if (keybind.match("app_exit", e)) { - if (store.prompt.input === "") { - await exit() - // Don't preventDefault - let textarea potentially handle the event - e.preventDefault() - return - } - } - if (e.name === "!" && input.visualCursor.offset === 0) { - setStore("placeholder", randomIndex(shell().length)) - setStore("mode", "shell") - e.preventDefault() - return - } - if (store.mode === "shell") { - if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") { - setStore("mode", "normal") - e.preventDefault() - return - } - } - if (store.mode === "normal") autocomplete.onKeyDown(e) - if (!autocomplete.visible) { - if ( - (keybind.match("history_previous", e) && input.cursorOffset === 0) || - (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) - ) { - const direction = keybind.match("history_previous", e) ? -1 : 1 - const item = history.move(direction, input.plainText) - - if (item) { - input.setText(item.input) - setStore("prompt", item) - setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) - e.preventDefault() - if (direction === -1) input.cursorOffset = 0 - if (direction === 1) input.cursorOffset = input.plainText.length - } - return - } - - if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0 - if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1) - input.cursorOffset = input.plainText.length - } }} onSubmit={() => { // IME: double-defer so the last composed character (e.g. Korean @@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) { // Windows Terminal <1.25 can surface image-only clipboard as an // empty bracketed paste. Windows Terminal 1.25+ does not. if (!pastedContent) { - command.trigger("prompt.paste") + keymap.dispatchCommand("prompt.paste") return } @@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) { // default paste unless we suppress it first and handle insertion ourselves. event.preventDefault() - const filepath = iife(() => { - const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } - if (process.platform === "win32") return raw - return raw.replace(/\\(.)/g, "$1") - }) - const isUrl = /^(https?):\/\//.test(filepath) - if (!isUrl) { - try { - const mime = await Filesystem.mimeType(filepath) - const filename = path.basename(filepath) - // Handle SVG as raw text content, not as base64 image - if (mime === "image/svg+xml") { - const content = await Filesystem.readText(filepath).catch(() => {}) - if (content) { - pasteText(content, `[SVG: ${filename ?? "image"}]`) - return - } - } - if (mime.startsWith("image/") || mime === "application/pdf") { - const content = await Filesystem.readArrayBuffer(filepath) - .then((buffer) => Buffer.from(buffer).toString("base64")) - .catch(() => {}) - if (content) { - await pasteAttachment({ - filename, - filepath, - mime, - content, - }) - return - } - } - } catch {} - } - - const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 - if ( - (lineCount >= 3 || pastedContent.length > 150) && - kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) - ) { - pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) - return - } - - input.insertText(normalizedText) - - // Force layout update and render for the pasted content - setTimeout(() => { - // setTimeout is a workaround and needs to be addressed properly - if (!input || input.isDestroyed) return - input.getLayoutNode().markDirty() - renderer.requestRender() - }, 0) + await pasteInputText(normalizedText) }} ref={(r: TextareaRenderable) => { input = r + setInputTarget(r) if (promptPartTypeId === 0) { promptPartTypeId = input.extmarks.registerType("prompt-part") } @@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) { · {local.model.parsed().model} @@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) { - {keybind.print("agent_cycle")} agents + {agentShortcut()} agents - {keybind.print("command_list")} commands + {paletteShortcut()} commands @@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) { + { + setAuto(() => r) + }} + anchor={() => anchor} + input={() => input} + setPrompt={(cb) => { + setStore("prompt", produce(cb)) + }} + setExtmark={(partIndex, extmarkId) => { + setStore("extmarkToPartIndex", (map: Map) => { + const newMap = new Map(map) + newMap.set(extmarkId, partIndex) + return newMap + }) + }} + value={store.prompt.input} + fileStyleId={fileStyleId} + agentStyleId={agentStyleId} + promptPartTypeId={() => promptPartTypeId} + /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index e47a1aeba5..a701396562 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -8,6 +8,11 @@ export interface PromptTraitsInput { autocompleteVisible: boolean } +export type PromptTraits = EditorTraits & { + owner: "opencode" + role: "prompt" +} + /** * Compute the textarea editor traits for the prompt. * @@ -16,7 +21,7 @@ export interface PromptTraitsInput { * editing mode — only `disabled` should suspend the textarea, otherwise * users can type in shell mode but cannot delete or move the cursor. */ -export function computePromptTraits(input: PromptTraitsInput): EditorTraits { +export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = input.mode === "normal" ? input.autocompleteVisible @@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits { capture, suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, + owner: "opencode", + role: "prompt", } } diff --git a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts deleted file mode 100644 index 36ab03de54..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createMemo } from "solid-js" -import type { KeyBinding } from "@opentui/core" -import { useKeybind } from "../context/keybind" -import { Keybind } from "@/util/keybind" - -const TEXTAREA_ACTIONS = [ - "submit", - "newline", - "move-left", - "move-right", - "move-up", - "move-down", - "select-left", - "select-right", - "select-up", - "select-down", - "line-home", - "line-end", - "select-line-home", - "select-line-end", - "visual-line-home", - "visual-line-end", - "select-visual-line-home", - "select-visual-line-end", - "buffer-home", - "buffer-end", - "select-buffer-home", - "select-buffer-end", - "delete-line", - "delete-to-line-end", - "delete-to-line-start", - "backspace", - "delete", - "undo", - "redo", - "word-forward", - "word-backward", - "select-word-forward", - "select-word-backward", - "delete-word-forward", - "delete-word-backward", -] as const - -function mapTextareaKeybindings( - keybinds: Record, - action: (typeof TEXTAREA_ACTIONS)[number], -): KeyBinding[] { - const configKey = `input_${action.replace(/-/g, "_")}` - const bindings = keybinds[configKey] - if (!bindings) return [] - return bindings.map((binding) => ({ - name: binding.name, - ctrl: binding.ctrl || undefined, - meta: binding.meta || undefined, - shift: binding.shift || undefined, - super: binding.super || undefined, - action, - })) -} - -export function useTextareaKeybindings() { - const keybind = useKeybind() - - return createMemo(() => { - const keybinds = keybind.all - - return [ - { name: "return", action: "submit" }, - { name: "return", meta: true, action: "newline" }, - ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), - ] satisfies KeyBinding[] - }) -} diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts new file mode 100644 index 0000000000..c0c621862e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -0,0 +1,177 @@ +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingValue } from "@opentui/keymap/extras" +import { ConfigKeybinds } from "@/config/keybinds" +import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" + +type LegacyKeybinds = Partial +type SectionsConfig = Record>> + +const inputCommands = { + input_submit: "input.submit", + input_newline: "input.newline", + input_move_left: "input.move.left", + input_move_right: "input.move.right", + input_move_up: "input.move.up", + input_move_down: "input.move.down", + input_select_left: "input.select.left", + input_select_right: "input.select.right", + input_select_up: "input.select.up", + input_select_down: "input.select.down", + input_line_home: "input.line.home", + input_line_end: "input.line.end", + input_select_line_home: "input.select.line.home", + input_select_line_end: "input.select.line.end", + input_visual_line_home: "input.visual.line.home", + input_visual_line_end: "input.visual.line.end", + input_select_visual_line_home: "input.select.visual.line.home", + input_select_visual_line_end: "input.select.visual.line.end", + input_buffer_home: "input.buffer.home", + input_buffer_end: "input.buffer.end", + input_select_buffer_home: "input.select.buffer.home", + input_select_buffer_end: "input.select.buffer.end", + input_delete_line: "input.delete.line", + input_delete_to_line_end: "input.delete.to.line.end", + input_delete_to_line_start: "input.delete.to.line.start", + input_backspace: "input.backspace", + input_delete: "input.delete", + input_undo: "input.undo", + input_redo: "input.redo", + input_word_forward: "input.word.forward", + input_word_backward: "input.word.backward", + input_select_word_forward: "input.select.word.forward", + input_select_word_backward: "input.select.word.backward", + input_delete_word_forward: "input.delete.word.forward", + input_delete_word_backward: "input.delete.word.backward", + input_select_all: "input.select.all", +} as const satisfies Partial> + +function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue | undefined) { + if (binding === undefined) return + config[section] ??= {} + config[section][command] = binding +} + +function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { + if (!key) return undefined + if (key === "none") return "none" + return { ...input, key } +} + +function combineBindings(...keys: (string | undefined)[]) { + const result = Array.from( + new Set( + keys.flatMap((key) => { + if (!key || key === "none") return [] + return key + .split(",") + .map((part) => part.trim()) + .filter((part) => part && part !== "none") + }), + ), + ) + if (result.length) return result.join(",") + if (keys.some((key) => key === "none")) return "none" + return undefined +} + +export function create(keybinds: LegacyKeybinds): KeymapConfigInput { + const config: SectionsConfig = {} + + add(config, "global", "command.palette.show", keybinds.command_list) + add(config, "global", "session.list", keybinds.session_list) + add(config, "global", "session.new", keybinds.session_new) + add(config, "global", "model.list", keybinds.model_list) + add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent) + add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) + add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite) + add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) + add(config, "global", "agent.list", keybinds.agent_list) + add(config, "global", "agent.cycle", keybinds.agent_cycle) + add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse) + add(config, "global", "variant.cycle", keybinds.variant_cycle) + add(config, "global", "variant.list", keybinds.variant_list) + add(config, "prompt", "prompt.editor", keybinds.editor_open) + add(config, "global", "opencode.status", keybinds.status_view) + add(config, "global", "theme.switch", keybinds.theme_list) + add(config, "global", "app.exit", keybinds.app_exit) + add(config, "global", "terminal.suspend", keybinds.terminal_suspend) + add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle) + + add(config, "session", "session.share", keybinds.session_share) + add(config, "session", "session.rename", keybinds.session_rename) + add(config, "session", "session.timeline", keybinds.session_timeline) + add(config, "session", "session.fork", keybinds.session_fork) + add(config, "session", "session.compact", keybinds.session_compact) + add(config, "session", "session.unshare", keybinds.session_unshare) + add(config, "session", "session.undo", keybinds.messages_undo) + add(config, "session", "session.redo", keybinds.messages_redo) + add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle) + add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal) + add(config, "session", "session.toggle.thinking", keybinds.display_thinking) + add(config, "session", "session.toggle.actions", keybinds.tool_details) + add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle) + add(config, "session", "session.page.up", keybinds.messages_page_up) + add(config, "session", "session.page.down", keybinds.messages_page_down) + add(config, "session", "session.line.up", keybinds.messages_line_up) + add(config, "session", "session.line.down", keybinds.messages_line_down) + add(config, "session", "session.half.page.up", keybinds.messages_half_page_up) + add(config, "session", "session.half.page.down", keybinds.messages_half_page_down) + add(config, "session", "session.first", keybinds.messages_first) + add(config, "session", "session.last", keybinds.messages_last) + add(config, "session", "session.messages_last_user", keybinds.messages_last_user) + add(config, "session", "session.message.next", keybinds.messages_next) + add(config, "session", "session.message.previous", keybinds.messages_previous) + add(config, "session", "messages.copy", keybinds.messages_copy) + add(config, "session", "session.export", keybinds.session_export) + add(config, "session", "session.child.first", keybinds.session_child_first) + add(config, "session", "session.parent", keybinds.session_parent) + add(config, "session", "session.child.next", keybinds.session_child_cycle) + add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) + + add(config, "prompt", "session.interrupt", keybinds.session_interrupt) + add(config, "prompt", "prompt.clear", keybinds.input_clear) + add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) + add(config, "prompt", "prompt.history.previous", keybinds.history_previous) + add(config, "prompt", "prompt.history.next", keybinds.history_next) + + add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) + add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) + add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) + add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) + add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) + + for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) { + add(config, "input", command, keybinds[legacy]) + } + + add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"]) + add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"]) + add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"]) + add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"]) + add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"]) + add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"]) + add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) + add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) + add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) + add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"])) + add(config, "model", "model.dialog.provider", keybinds.model_provider_list) + add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) + + add(config, "permission", "permission.reject.cancel", keybinds.app_exit) + add(config, "permission", "permission.prompt.escape", keybinds.app_exit) + add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) + add(config, "question", "question.reject", keybinds.app_exit) + add(config, "question", "question.edit.clear", keybinds.input_clear) + + add(config, "plugins", "plugins.list", keybinds.plugin_manager) + add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) + add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) + + return { + ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), + sections: config, + } +} + +export * as LegacyKeymapTransform from "./legacy-keymap-transform" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index ed79e8e524..400eb38528 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,4 +1,7 @@ import z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" @@ -11,6 +14,303 @@ const KeybindOverride = z ) .strict() +const KeyStroke = z + .object({ + name: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + meta: z.boolean().optional(), + super: z.boolean().optional(), + hyper: z.boolean().optional(), + }) + .strict() + +const KeymapBindingObject = z + .object({ + key: z.union([z.string(), KeyStroke]), + event: z.enum(["press", "release"]).optional(), + preventDefault: z.boolean().optional(), + fallthrough: z.boolean().optional(), + }) + .passthrough() + +const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject]) +const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)]) + +const keymapBinding = (value: z.input | (() => z.input)) => + KeymapBindingValue.prefault(value) +const keymapSection = (shape: Shape) => { + const schema = z.object(shape).strict() + return schema.prefault({} as z.input) +} +const keymapSectionInput = (shape: Shape) => + z + .object( + Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as { + [Key in keyof Shape]: z.ZodOptional + }, + ) + .strict() + +const GlobalKeymapSection = { + "command.palette.show": keymapBinding("ctrl+p"), + "session.list": keymapBinding("l"), + "session.new": keymapBinding("n"), + "model.list": keymapBinding("m"), + "model.cycle_recent": keymapBinding("f2"), + "model.cycle_recent_reverse": keymapBinding("shift+f2"), + "model.cycle_favorite": keymapBinding("none"), + "model.cycle_favorite_reverse": keymapBinding("none"), + "agent.list": keymapBinding("a"), + "mcp.list": keymapBinding("none"), + "agent.cycle": keymapBinding("tab"), + "agent.cycle.reverse": keymapBinding("shift+tab"), + "variant.cycle": keymapBinding("ctrl+t"), + "variant.list": keymapBinding("none"), + "provider.connect": keymapBinding("none"), + "console.org.switch": keymapBinding("none"), + "opencode.status": keymapBinding("s"), + "theme.switch": keymapBinding("t"), + "theme.switch_mode": keymapBinding("none"), + "theme.mode.lock": keymapBinding("none"), + "help.show": keymapBinding("none"), + "docs.open": keymapBinding("none"), + "app.exit": keymapBinding("ctrl+c,ctrl+d,q"), + "app.debug": keymapBinding("none"), + "app.console": keymapBinding("none"), + "app.heap_snapshot": keymapBinding("none"), + "app.toggle.animations": keymapBinding("none"), + "app.toggle.file_context": keymapBinding("none"), + "app.toggle.diffwrap": keymapBinding("none"), + "app.toggle.paste_summary": keymapBinding("none"), + "app.toggle.session_directory_filter": keymapBinding("none"), + "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")), + "terminal.title.toggle": keymapBinding("none"), +} + +const SessionKeymapSection = { + "session.share": keymapBinding("none"), + "session.rename": keymapBinding("ctrl+r"), + "session.timeline": keymapBinding("g"), + "session.fork": keymapBinding("none"), + "session.compact": keymapBinding("c"), + "session.unshare": keymapBinding("none"), + "session.undo": keymapBinding("u"), + "session.redo": keymapBinding("r"), + "session.sidebar.toggle": keymapBinding("b"), + "session.toggle.conceal": keymapBinding("h"), + "session.toggle.timestamps": keymapBinding("none"), + "session.toggle.thinking": keymapBinding("none"), + "session.toggle.actions": keymapBinding("none"), + "session.toggle.scrollbar": keymapBinding("none"), + "session.toggle.generic_tool_output": keymapBinding("none"), + "session.page.up": keymapBinding("pageup,ctrl+alt+b"), + "session.page.down": keymapBinding("pagedown,ctrl+alt+f"), + "session.line.up": keymapBinding("ctrl+alt+y"), + "session.line.down": keymapBinding("ctrl+alt+e"), + "session.half.page.up": keymapBinding("ctrl+alt+u"), + "session.half.page.down": keymapBinding("ctrl+alt+d"), + "session.first": keymapBinding("ctrl+g,home"), + "session.last": keymapBinding("ctrl+alt+g,end"), + "session.messages_last_user": keymapBinding("none"), + "session.message.next": keymapBinding("none"), + "session.message.previous": keymapBinding("none"), + "messages.copy": keymapBinding("y"), + "session.copy": keymapBinding("none"), + "session.export": keymapBinding("x"), + "session.child.first": keymapBinding("down"), + "session.parent": keymapBinding("up"), + "session.child.next": keymapBinding("right"), + "session.child.previous": keymapBinding("left"), +} + +const PromptKeymapSection = { + "prompt.submit": keymapBinding("none"), + "prompt.editor": keymapBinding("e"), + "prompt.editor_context.clear": keymapBinding("none"), + "prompt.skills": keymapBinding("none"), + "prompt.stash": keymapBinding("none"), + "prompt.stash.pop": keymapBinding("none"), + "prompt.stash.list": keymapBinding("none"), + "workspace.set": keymapBinding("none"), + "session.interrupt": keymapBinding("escape"), + "prompt.clear": keymapBinding("ctrl+c"), + "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }), + "prompt.history.previous": keymapBinding("up"), + "prompt.history.next": keymapBinding("down"), +} + +const AutocompleteKeymapSection = { + "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"), + "prompt.autocomplete.next": keymapBinding("down,ctrl+n"), + "prompt.autocomplete.hide": keymapBinding("escape"), + "prompt.autocomplete.select": keymapBinding("return"), + "prompt.autocomplete.complete": keymapBinding("tab"), +} + +const InputKeymapSection = { + "input.submit": keymapBinding("return"), + "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"), + "input.move.left": keymapBinding("left,ctrl+b"), + "input.move.right": keymapBinding("right,ctrl+f"), + "input.move.up": keymapBinding("up"), + "input.move.down": keymapBinding("down"), + "input.select.left": keymapBinding("shift+left"), + "input.select.right": keymapBinding("shift+right"), + "input.select.up": keymapBinding("shift+up"), + "input.select.down": keymapBinding("shift+down"), + "input.line.home": keymapBinding("ctrl+a"), + "input.line.end": keymapBinding("ctrl+e"), + "input.select.line.home": keymapBinding("ctrl+shift+a"), + "input.select.line.end": keymapBinding("ctrl+shift+e"), + "input.visual.line.home": keymapBinding("alt+a"), + "input.visual.line.end": keymapBinding("alt+e"), + "input.select.visual.line.home": keymapBinding("alt+shift+a"), + "input.select.visual.line.end": keymapBinding("alt+shift+e"), + "input.buffer.home": keymapBinding("home"), + "input.buffer.end": keymapBinding("end"), + "input.select.buffer.home": keymapBinding("shift+home"), + "input.select.buffer.end": keymapBinding("shift+end"), + "input.delete.line": keymapBinding("ctrl+shift+d"), + "input.delete.to.line.end": keymapBinding("ctrl+k"), + "input.delete.to.line.start": keymapBinding("ctrl+u"), + "input.backspace": keymapBinding("backspace,shift+backspace"), + "input.delete": keymapBinding("ctrl+d,delete,shift+delete"), + "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")), + "input.redo": keymapBinding("ctrl+.,super+shift+z"), + "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"), + "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"), + "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"), + "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"), + "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"), + "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"), + "input.select.all": keymapBinding("super+a"), +} + +const DialogSelectKeymapSection = { + "dialog.select.prev": keymapBinding("up,ctrl+p"), + "dialog.select.next": keymapBinding("down,ctrl+n"), + "dialog.select.page_up": keymapBinding("pageup"), + "dialog.select.page_down": keymapBinding("pagedown"), + "dialog.select.home": keymapBinding("home"), + "dialog.select.end": keymapBinding("end"), + "dialog.select.submit": keymapBinding("return"), +} + +const DialogActionsKeymapSection = { + "dialog.action.toggle": keymapBinding("space"), + "dialog.action.delete": keymapBinding("ctrl+d"), + "dialog.action.rename": keymapBinding("ctrl+r"), +} + +const ModelKeymapSection = { + "model.dialog.provider": keymapBinding("ctrl+a"), + "model.dialog.favorite": keymapBinding("ctrl+f"), +} + +const PermissionKeymapSection = { + "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,q"), + "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,q"), + "permission.prompt.fullscreen": keymapBinding("ctrl+f"), +} + +const QuestionKeymapSection = { + "question.reject": keymapBinding("ctrl+c,ctrl+d,q"), + "question.edit.clear": keymapBinding("ctrl+c"), +} + +const PluginsKeymapSection = { + "plugins.list": keymapBinding("none"), + "plugins.install": keymapBinding("none"), + "plugin.dialog.install": keymapBinding("shift+i"), +} + +const HomeTipsKeymapSection = { + "tips.toggle": keymapBinding("h"), +} + +const KeymapSectionsShape = { + global: keymapSection(GlobalKeymapSection), + session: keymapSection(SessionKeymapSection), + prompt: keymapSection(PromptKeymapSection), + autocomplete: keymapSection(AutocompleteKeymapSection), + input: keymapSection(InputKeymapSection), + dialog_select: keymapSection(DialogSelectKeymapSection), + dialog_actions: keymapSection(DialogActionsKeymapSection), + model: keymapSection(ModelKeymapSection), + permission: keymapSection(PermissionKeymapSection), + question: keymapSection(QuestionKeymapSection), + plugins: keymapSection(PluginsKeymapSection), + home_tips: keymapSection(HomeTipsKeymapSection), +} + +const KeymapSectionsInputShape = { + global: keymapSectionInput(GlobalKeymapSection).optional(), + session: keymapSectionInput(SessionKeymapSection).optional(), + prompt: keymapSectionInput(PromptKeymapSection).optional(), + autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), + input: keymapSectionInput(InputKeymapSection).optional(), + dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(), + dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(), + model: keymapSectionInput(ModelKeymapSection).optional(), + permission: keymapSectionInput(PermissionKeymapSection).optional(), + question: keymapSectionInput(QuestionKeymapSection).optional(), + plugins: keymapSectionInput(PluginsKeymapSection).optional(), + home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(), +} + +export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({}) +export type KeymapSections = z.output +export type KeymapSection = keyof KeymapSections +export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[] +export const KeymapLeaderTimeoutDefault = 2000 +export type KeymapInfo = { + leader: string + leader_timeout: number +} & ResolvedBindingSections + +export const KeymapSectionGroups = { + global: "Global", + session: "Session", + prompt: "Prompt", + autocomplete: "Autocomplete", + input: "Text Editing", + dialog_select: "Dialog", + dialog_actions: "Dialog", + model: "Model", + permission: "Permission", + question: "Question", + plugins: "Plugins", + home_tips: "Home", +} satisfies Record + +export function keymapBindingDefaults(input: { section: string; binding: Readonly> }) { + if (input.binding.group !== undefined) return + if (!Object.hasOwn(KeymapSectionGroups, input.section)) return + return { group: KeymapSectionGroups[input.section as KeymapSection] } +} + +export const KeymapConfig = z + .object({ + leader: z.string().prefault("ctrl+x"), + leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"), + sections: KeymapSections, + }) + .strict() + .describe("TUI keymap configuration") +export type KeymapConfig = z.output + +const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional() +export const KeymapConfigInput = z + .object({ + leader: z.string().optional(), + leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), + sections: KeymapSectionsInput, + }) + .strict() + .describe("TUI keymap configuration") +export type KeymapConfigInput = z.output + export const TuiOptions = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z @@ -30,9 +330,17 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional(), + keybinds: KeybindOverride.optional().meta({ + deprecated: true, + description: "Use keymap instead. This will be removed in opencode v2.0.", + }), + keymap: KeymapConfigInput.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) .strict() + +export const TuiJsonSchemaInfo = TuiInfo.extend({ + keymap: KeymapConfig.optional(), +}).strict() diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 890f736228..095bc2c882 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,12 +1,14 @@ export * as TuiConfig from "./tui" -import z from "zod" +import type z from "zod" +import type { KeyEvent, Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { TuiInfo } from "./tui-schema" +import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -20,27 +22,34 @@ import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" +import { LegacyKeymapTransform } from "./legacy-keymap-transform" +import { + KeymapSectionNames, + keymapBindingDefaults, + type KeymapInfo, + type KeymapSection, +} from "./tui-schema" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo +export const JsonSchemaInfo = TuiJsonSchemaInfo +export type Info = z.output type Acc = { result: Info + plugin_origins: ConfigPlugin.Origin[] } -type State = { - config: Info - deps: Array> -} - -export type Info = z.output & { +export type Resolved = Omit & { + keybinds: ConfigKeybinds.Keybinds + keymap: KeymapInfo // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } export interface Interface { - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const scope = pluginScope(file, ctx) const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), + ...acc.plugin_origins, ...data.plugin.map((spec) => ({ spec, scope, source: file })), ]) acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins + acc.plugin_origins = plugins }) // Every config dir we may read from: global config dir, any `.opencode` @@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const acc: Acc = { result: {}, + plugin_origins: [], } // 1. Global tui config (lowest precedence). @@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), ]).join(",") } - acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) + const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {}) + const keymapConfig = KeymapConfig.parse(keymapInput) + const keymap = { + leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader, + leader_timeout: keymapConfig.leader_timeout, + ...resolveBindingSections, KeymapSection>( + keymapConfig.sections, + { + sections: KeymapSectionNames, + bindingDefaults: keymapBindingDefaults, + }, + ), + } + const result: Resolved = { + ...acc.result, + keybinds: parsedKeybinds, + plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, + // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it + // only as the legacy fallback; once `keymap` is configured, ignore + // `keybinds` for keymap resolution. + keymap, + } return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], + config: result, + dirs: result.plugin?.length ? dirs : [], } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx new file mode 100644 index 0000000000..07cca99074 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx @@ -0,0 +1,163 @@ +import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js" +import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { + formatKeyBindings, + reactiveMatcherFromSignal, + type OpenTuiKeymap, + useKeymapSelector, + useOpencodeKeymap, +} from "../keymap" +import { useTuiConfig } from "./tui-config" + +type SlashEntry = { + display: string + description?: string + aliases?: string[] + onSelect: () => void +} + +type CommandPaletteContext = { + run(command: string): void + show(): void + slashes: Accessor + suspend(enabled: boolean): void + readonly suspended: boolean + matcher: ReturnType +} + +const COMMAND_PALETTE_DIALOG = "command.palette.show" +const ctx = createContext() +type PaletteCommandEntry = ReturnType[number] + +function isVisiblePaletteCommand(entry: PaletteCommandEntry) { + return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG +} + +function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { + const suggested = entry.command.suggested + if (typeof suggested === "boolean") return suggested + if (typeof suggested === "function") return suggested() === true + return false +} + +export function CommandPaletteProvider(props: ParentProps) { + const dialog = useDialog() + const keymap = useOpencodeKeymap() + const [suspendCount, setSuspendCount] = createSignal(0) + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + namespace: "palette", + }) + .filter(isVisiblePaletteCommand), + ) + + const run = (command: string) => { + keymap.dispatchCommand(command) + } + + const slashes = createMemo(() => + entries().flatMap((entry) => { + const slashName = entry.command.slashName + if (typeof slashName !== "string" || !slashName) return [] + const slashAliases = entry.command.slashAliases + return { + display: `/${slashName}`, + description: + typeof entry.command.desc === "string" + ? entry.command.desc + : typeof entry.command.title === "string" + ? entry.command.title + : undefined, + aliases: Array.isArray(slashAliases) + ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`) + : undefined, + onSelect: () => run(entry.command.name), + } + }), + ) + + const value: CommandPaletteContext = { + run, + show() { + dialog.replace(() => ) + }, + slashes, + suspend(enabled: boolean) { + setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1))) + }, + get suspended() { + return suspendCount() > 0 || dialog.stack.length > 0 + }, + matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0), + } + + return {props.children} +} + +export function useCommandPalette() { + const value = useContext(ctx) + if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider") + return value +} + +function CommandPaletteDialog(props: { run(command: string): void }) { + const config = useTuiConfig() + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { + const query = { + namespace: "palette", + } + const reachable = keymap + .getCommandEntries({ + ...query, + visibility: "reachable", + }) + .filter(isVisiblePaletteCommand) + const registeredBindings = keymap.getCommandBindings({ + visibility: "registered", + commands: reachable.map((entry) => entry.command.name), + }) + + return reachable.map((entry) => ({ + ...entry, + bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, + })) + }) + const options = createMemo(() => + entries().map((entry) => ({ + title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + category: typeof entry.command.category === "string" ? entry.command.category : undefined, + footer: formatKeyBindings(entry.bindings, config), + value: entry.command.name, + suggested: isSuggestedPaletteCommand(entry), + onSelect: (dialog: DialogContext) => { + dialog.clear() + props.run(entry.command.name) + }, + })), + ) + + let ref: DialogSelectRef + const list = () => { + if (ref?.filter) return options() + return [ + ...options() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ...options(), + ] + } + + return (ref = value)} title="Commands" options={list()} /> +} + +export function useCommandSlashes(): Accessor { + return useCommandPalette().slashes +} diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx deleted file mode 100644 index 2c1ab245a5..0000000000 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { createMemo } from "solid-js" -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" -import { createStore } from "solid-js/store" -import { useKeyboard, useRenderer } from "@opentui/solid" -import { createSimpleContext } from "./helper" -import { useTuiConfig } from "./tui-config" - -export type KeybindKey = keyof NonNullable & string - -export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ - name: "Keybind", - init: () => { - const config = useTuiConfig() - const keybinds = createMemo>(() => { - return pipe( - (config.keybinds ?? {}) as Record, - mapValues((value) => Keybind.parse(value)), - ) - }) - const [store, setStore] = createStore({ - leader: false, - }) - const renderer = useRenderer() - - let focus: Renderable | null - let timeout: NodeJS.Timeout - function leader(active: boolean) { - if (active) { - setStore("leader", true) - focus = renderer.currentFocusedRenderable - focus?.blur() - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - if (!store.leader) return - leader(false) - if (!focus || focus.isDestroyed) return - focus.focus() - }, 2000) - return - } - - if (!active) { - if (focus && !renderer.currentFocusedRenderable) { - focus.focus() - } - setStore("leader", false) - } - } - - useKeyboard(async (evt) => { - if (!store.leader && result.match("leader", evt)) { - leader(true) - return - } - - if (store.leader && evt.name) { - setImmediate(() => { - if (focus && renderer.currentFocusedRenderable === focus) { - focus.focus() - } - leader(false) - }) - } - }) - - const result = { - get all() { - return keybinds() - }, - get leader() { - return store.leader - }, - parse(evt: ParsedKey): Keybind.Info { - // Handle special case for Ctrl+Underscore (represented as \x1F) - if (evt.name === "\x1F") { - return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader) - } - return Keybind.fromParsedKey(evt, store.leader) - }, - match(key: string, evt: ParsedKey) { - const list = keybinds()[key] ?? Keybind.parse(key) - if (!list.length) return false - const parsed: Keybind.Info = result.parse(evt) - for (const item of list) { - if (Keybind.match(item, parsed)) { - return true - } - } - return false - }, - print(key: string) { - const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0) - if (!first) return "" - const text = Keybind.toString(first) - const lead = keybinds().leader?.[0] - if (!lead) return text - return text.replace("", Keybind.toString(lead)) - }, - } - return result - }, -}) diff --git a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts b/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts deleted file mode 100644 index a84e10128c..0000000000 --- a/packages/opencode/src/cli/cmd/tui/context/plugin-keybinds.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ParsedKey } from "@opentui/core" - -export type PluginKeybindMap = Record - -type Base = { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string -} - -export type PluginKeybind = { - readonly all: PluginKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} - -const txt = (value: unknown) => { - if (typeof value !== "string") return - if (!value.trim()) return - return value -} - -export function createPluginKeybind( - base: Base, - defaults: PluginKeybindMap, - overrides?: Record, -): PluginKeybind { - const all = Object.freeze( - Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])), - ) - const get = (name: string) => all[name] ?? name - - return { - get all() { - return all - }, - get, - match: (name, evt) => base.match(get(name), evt), - print: (name) => base.print(get(name)), - } -} diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 05fdd025c7..9691ae5959 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper" export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ name: "TuiConfig", - init: (props: { config: TuiConfig.Info }) => { + init: (props: { config: TuiConfig.Resolved }) => { return props.config }, }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index 26c03ee347..a9542fc127 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -1,10 +1,27 @@ -import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" +import { useBindings } from "../../keymap" const id = "internal:home-tips" -function View(props: { show: boolean; connected: boolean }) { +function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) { + useBindings(() => ({ + commands: [ + { + name: "tips.toggle", + title: props.hidden ? "Show tips" : "Hide tips", + category: "System", + namespace: "palette", + run() { + props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false)) + props.api.ui.dialog.clear() + }, + }, + ], + bindings: props.api.tuiConfig.keymap.sections.home_tips, + })) + return ( @@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips", - value: "tips.toggle", - keybind: "tips_toggle", - category: "System", - hidden: api.route.current.name !== "home", - onSelect() { - api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false)) - api.ui.dialog.clear() - }, - }, - ]) - api.slots.register({ order: 100, slots: { @@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => { ), ) const show = createMemo(() => (!first() || !connected()) && !hidden()) - return + return )} @@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) { options={rows()} current={cur()} onMove={(item) => setCur(item.value)} - keybind={[ + actions={[ { title: "toggle", - keybind: key, + command: "dialog.action.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - keybind: add, + command: "plugin.dialog.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} + bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -241,25 +236,29 @@ function show(api: TuiPluginApi) { } const tui: TuiPlugin = async (api) => { - api.command.register(() => [ - { - title: "Plugins", - value: "plugins.list", - keybind: "plugin_manager", - category: "System", - onSelect() { - show(api) + api.keymap.registerLayer({ + commands: [ + { + name: "plugins.list", + title: "Plugins", + category: "System", + namespace: "palette", + run() { + show(api) + }, }, - }, - { - title: "Install plugin", - value: "plugins.install", - category: "System", - onSelect() { - showInstall(api) + { + name: "plugins.install", + title: "Install plugin", + category: "System", + namespace: "palette", + run() { + showInstall(api) + }, }, - }, - ]) + ], + bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 2e5cea9804..0d899a8bae 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" -import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" +import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { void sync.session.message.sync(props.sessionID) }) - useKeyboard((event) => { - if (event.name !== "escape") return - event.preventDefault() - event.stopPropagation() - props.api.route.navigate("session", { sessionID: props.sessionID }) - }) + useBindings(() => ({ + bindings: [ + { + key: "escape", + cmd() { + props.api.route.navigate("session", { sessionID: props.sessionID }) + }, + }, + ], + })) return ( @@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => { }, ]) - api.command.register(() => [ - { - title: "View v2 session messages", - value: route, - category: "Debug", - suggested: api.route.current.name === "session", - enabled: api.route.current.name === "session", - onSelect() { - const sessionID = currentSessionID(api) - if (!sessionID) return - api.route.navigate(route, { sessionID }) - api.ui.dialog.clear() + api.keymap.registerLayer({ + commands: [ + { + name: route, + title: "View v2 session messages", + category: "Debug", + namespace: "palette", + suggested: () => api.route.current.name === "session", + enabled: () => api.route.current.name === "session", + run() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, }, - }, - ]) + ], + }) } const plugin: TuiPluginModule & { id: string } = { diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx new file mode 100644 index 0000000000..0d65057d79 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -0,0 +1,91 @@ +import { type CliRenderer } from "@opentui/core" +import * as addons from "@opentui/keymap/addons/opentui" +import { + formatCommandBindings as formatCommandBindingsExtra, + formatKeySequence as formatKeySequenceExtra, +} from "@opentui/keymap/extras" +import { + KeymapProvider, + reactiveMatcherFromSignal, + useBindings, + useKeymap, + useKeymapSelector, +} from "@opentui/keymap/solid" +import type { Accessor } from "solid-js" +import type { TuiConfig } from "./config/tui" +import { useTuiConfig } from "./context/tui-config" + +export const LEADER_TOKEN = "leader" + +export const OpencodeKeymapProvider = KeymapProvider +export const useOpencodeKeymap = useKeymap + +export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } + +export type OpenTuiKeymap = ReturnType + +function formatOptions(config: TuiConfig.Resolved) { + return { + tokenDisplay: { + [LEADER_TOKEN]: config.keymap.leader, + }, + keyNameAliases: { + pageup: "pgup", + pagedown: "pgdn", + delete: "del", + }, + modifierAliases: { + meta: "alt", + }, + } as const +} + +export function formatKeySequence(parts: Parameters[0], config: TuiConfig.Resolved) { + return formatKeySequenceExtra(parts, formatOptions(config)) +} + +export function formatKeyBindings( + bindings: Parameters[0], + config: TuiConfig.Resolved, +) { + return formatCommandBindingsExtra(bindings, formatOptions(config)) +} + +export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { + const offCommaBindings = addons.registerCommaBindings(keymap) + const offBaseLayout = addons.registerBaseLayoutFallback(keymap) + const offLeader = addons.registerTimedLeader(keymap, { + trigger: config.keymap.leader, + name: LEADER_TOKEN, + timeoutMs: config.keymap.leader_timeout, + }) + const offEscape = addons.registerEscapeClearsPendingSequence(keymap) + const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { + enabled: () => renderer.currentFocusedEditor !== null, + bindings: config.keymap.sections.input, + }) + + return () => { + offInputBindings() + offBackspace() + offEscape() + offLeader() + offBaseLayout() + offCommaBindings() + } +} + +export function useCommandShortcut(command: string): Accessor { + const config = useTuiConfig() + return useKeymapSelector((keymap) => + formatKeySequence( + keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence, + config, + ), + ) +} + +export function useLeaderActive(): Accessor { + return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 25ea3ac9ed..7b7ce0bbb5 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -1,15 +1,12 @@ -import type { ParsedKey } from "@opentui/core" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" -import type { useCommandDialog } from "@tui/component/dialog-command" import type { useEvent } from "@tui/context/event" -import type { useKeybind } from "@tui/context/keybind" import type { useRoute } from "@tui/context/route" import type { useSDK } from "@tui/context/sdk" import type { useSync } from "@tui/context/sync" import type { useTheme } from "@tui/context/theme" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" import type { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createPluginKeybind } from "../context/plugin-keybinds" +import type { useOpencodeKeymap } from "../keymap" import type { useKV } from "../context/kv" import { DialogAlert } from "../ui/dialog-alert" import { DialogConfirm } from "../ui/dialog-confirm" @@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt" import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Keymap from "../keymap" type RouteEntry = { key: symbol @@ -28,10 +26,9 @@ type RouteEntry = { export type RouteMap = Map type Input = { - command: ReturnType - tuiConfig: TuiConfig.Info + tuiConfig: TuiConfig.Resolved dialog: ReturnType - keybind: ReturnType + keymap: ReturnType kv: ReturnType route: ReturnType routes: RouteMap @@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi { return () => {} }, } - return { app: appApi(), - command: { - register(cb) { - return input.command.register(() => cb()) + keys: { + formatSequence(parts) { + return Keymap.formatKeySequence(parts, input.tuiConfig) }, - trigger(value) { - input.command.trigger(value) - }, - show() { - input.command.show() + formatBindings(bindings) { + return Keymap.formatKeyBindings(bindings, input.tuiConfig) }, }, + keymap: input.keymap, route: { register(list) { return routeRegister(input.routes, list, input.bump) @@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi { }, }, }, - keybind: { - match(key, evt: ParsedKey) { - return input.keybind.match(key, evt) - }, - print(key) { - return input.keybind.print(key) - }, - create(defaults, overrides) { - return createPluginKeybind(input.keybind, defaults, overrides) - }, - }, get tuiConfig() { return input.tuiConfig }, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 73193d142e..a43f62deec 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -1,4 +1,5 @@ -import "@opentui/solid/runtime-plugin-support" +import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules" +import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure" import { type TuiDispose, type TuiPlugin, @@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) + type PluginLoad = { options: ConfigPlugin.Options | undefined spec: string @@ -70,6 +73,36 @@ type PluginEntry = { scope?: PluginScope } +const ScopedKeymapMethods = new Set([ + "acquireResource", + "registerLayer", + "registerLayerFields", + "prependLayerBindingsTransformer", + "appendLayerBindingsTransformer", + "prependBindingTransformer", + "appendBindingTransformer", + "prependBindingParser", + "appendBindingParser", + "registerToken", + "registerSequencePattern", + "prependBindingExpander", + "appendBindingExpander", + "registerBindingFields", + "registerCommandFields", + "prependCommandTransformer", + "appendCommandTransformer", + "prependCommandResolver", + "appendCommandResolver", + "prependLayerAnalyzer", + "appendLayerAnalyzer", + "intercept", + "on", + "prependEventMatchResolver", + "appendEventMatchResolver", + "prependDisambiguationResolver", + "appendDisambiguationResolver", +]) + type RuntimeState = { directory: string api: Api @@ -104,6 +137,25 @@ function warn(message: string, data: Record) { console.warn(`[tui.plugin] ${message}`, data) } +function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] { + const cache = new Map() + return new Proxy(keymap, { + get(target, prop) { + const value = Reflect.get(target, prop, target) + if (typeof value !== "function") return value + if (cache.has(prop)) return cache.get(prop) + const fn = ScopedKeymapMethods.has(prop) + ? (...args: unknown[]) => { + const dispose = (value as (...args: unknown[]) => unknown).apply(target, args) + return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined) + } + : (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args) + cache.set(prop, fn) + return fn + }, + }) +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) { const track = (fn: (() => void) | undefined) => { if (!fn) return () => {} - const off = onDispose(fn) let drop = false - return () => { + let off = () => {} + const wrapped = () => { if (drop) return drop = true off() fn() } + off = onDispose(wrapped) + return wrapped } const lifecycle: TuiPluginApi["lifecycle"] = { @@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) { ) } -function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { return { ...readPluginEnabledMap(config.plugin_enabled), ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})), @@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop const api = runtime.api const host = runtime.slots const load = plugin.load - const command: TuiPluginApi["command"] = { - register(cb) { - return scope.track(api.command.register(cb)) - }, - trigger(value) { - api.command.trigger(value) - }, - show() { - api.command.show() - }, - } const route: TuiPluginApi["route"] = { register(list) { @@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop }, } + const keymap = createScopedKeymap(api.keymap, scope) + let count = 0 const slots: TuiPluginApi["slots"] = { @@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, - command, + keys: api.keys, + keymap, route, ui: api.ui, - keybind: api.keybind, tuiConfig: api.tuiConfig, kv: api.kv, state: api.state, @@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) { return true } -function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { +function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) { const map = pluginEnabledState(state, config) for (const plugin of state.plugins) { const enabled = map[plugin.id] @@ -923,7 +968,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -972,7 +1017,7 @@ export async function dispose() { } } -async function load(input: { api: Api; config: TuiConfig.Info }) { +async function load(input: { api: Api; config: TuiConfig.Resolved }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d43edd2dd5..81df918059 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -49,12 +49,10 @@ 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 { 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" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" @@ -90,6 +88,8 @@ 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" +import { useCommandPalette } from "../../context/command-palette" +import { useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) @@ -124,6 +124,9 @@ export function Session() { const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() @@ -250,7 +253,7 @@ export function Session() { seeded = true r.set(route.prompt) } - const keybind = useKeybind() + const command = useCommandPalette() const dialog = useDialog() const renderer = useRenderer() @@ -271,7 +274,6 @@ export function Session() { }) }) - // Allow exit when in child session (prompt is hidden) const exit = useExit() createEffect(() => { @@ -293,13 +295,6 @@ export function Session() { ) }) - useKeyboard((evt) => { - if (!session()?.parentID) return - if (keybind.match("app_exit", evt)) { - void exit() - } - }) - // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -382,26 +377,24 @@ export function Session() { } } - function childSessionHandler(func: (dialog: DialogContext) => void) { - return (dialog: DialogContext) => { + function childSessionHandler(func: () => void) { + return () => { if (!session()?.parentID || dialog.stack.length > 0) return - func(dialog) + func() } } - const command = useCommandDialog() - command.register(() => [ + const sessionCommandList = createMemo(() => [ { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", suggested: route.type === "session", - keybind: "session_share", category: "Session", enabled: sync.data.config.share !== "disabled", slash: { name: "share", }, - onSelect: async (dialog) => { + run: async () => { const copy = (url: string) => Clipboard.copy(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) @@ -434,24 +427,22 @@ export function Session() { { title: "Rename session", value: "session.rename", - keybind: "session_rename", category: "Session", slash: { name: "rename", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ) }, }, { title: "Jump to message", value: "session.timeline", - keybind: "session_timeline", category: "Session", slash: { name: "timeline", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -469,12 +460,11 @@ export function Session() { { title: "Fork session", value: "session.fork", - keybind: "session_fork", category: "Session", slash: { name: "fork", }, - onSelect: (dialog) => { + run: () => { dialog.replace(() => ( { @@ -492,13 +482,12 @@ export function Session() { { title: "Compact session", value: "session.compact", - keybind: "session_compact", category: "Session", slash: { name: "compact", aliases: ["summarize"], }, - onSelect: (dialog) => { + run: () => { const selectedModel = local.model.current() if (!selectedModel) { toast.show({ @@ -519,13 +508,12 @@ export function Session() { { title: "Unshare session", value: "session.unshare", - keybind: "session_unshare", category: "Session", enabled: !!session()?.share?.url, slash: { name: "unshare", }, - onSelect: async (dialog) => { + run: async () => { await sdk.client.session .unshare({ sessionID: route.sessionID, @@ -543,12 +531,11 @@ export function Session() { { title: "Undo previous message", value: "session.undo", - keybind: "messages_undo", category: "Session", slash: { name: "undo", }, - onSelect: async (dialog) => { + run: async () => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID @@ -581,13 +568,12 @@ export function Session() { { title: "Redo", value: "session.redo", - keybind: "messages_redo", category: "Session", enabled: !!session()?.revert?.messageID, slash: { name: "redo", }, - onSelect: (dialog) => { + run: () => { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return @@ -608,9 +594,8 @@ export function Session() { { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", - keybind: "sidebar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { batch(() => { const isVisible = sidebarVisible() setSidebar(() => (isVisible ? "hide" : "auto")) @@ -622,9 +607,8 @@ export function Session() { { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", - keybind: "messages_toggle_conceal", category: "Session", - onSelect: (dialog) => { + run: () => { setConceal((prev) => !prev) dialog.clear() }, @@ -637,7 +621,7 @@ export function Session() { name: "timestamps", aliases: ["toggle-timestamps"], }, - onSelect: (dialog) => { + run: () => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, @@ -645,13 +629,12 @@ export function Session() { { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", - keybind: "display_thinking", category: "Session", slash: { name: "thinking", aliases: ["toggle-thinking"], }, - onSelect: (dialog) => { + run: () => { setShowThinking((prev) => !prev) dialog.clear() }, @@ -659,9 +642,8 @@ export function Session() { { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", - keybind: "tool_details", category: "Session", - onSelect: (dialog) => { + run: () => { setShowDetails((prev) => !prev) dialog.clear() }, @@ -669,9 +651,8 @@ export function Session() { { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", - keybind: "scrollbar_toggle", category: "Session", - onSelect: (dialog) => { + run: () => { setShowScrollbar((prev) => !prev) dialog.clear() }, @@ -680,7 +661,7 @@ export function Session() { title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", value: "session.toggle.generic_tool_output", category: "Session", - onSelect: (dialog) => { + run: () => { setShowGenericToolOutput((prev) => !prev) dialog.clear() }, @@ -688,10 +669,9 @@ export function Session() { { title: "Page up", value: "session.page.up", - keybind: "messages_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 2) dialog.clear() }, @@ -699,10 +679,9 @@ export function Session() { { title: "Page down", value: "session.page.down", - keybind: "messages_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, @@ -710,10 +689,9 @@ export function Session() { { title: "Line up", value: "session.line.up", - keybind: "messages_line_up", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(-1) dialog.clear() }, @@ -721,10 +699,9 @@ export function Session() { { title: "Line down", value: "session.line.down", - keybind: "messages_line_down", category: "Session", - disabled: true, - onSelect: (dialog) => { + enabled: false, + run: () => { scroll.scrollBy(1) dialog.clear() }, @@ -732,10 +709,9 @@ export function Session() { { title: "Half page up", value: "session.half.page.up", - keybind: "messages_half_page_up", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(-scroll.height / 4) dialog.clear() }, @@ -743,10 +719,9 @@ export function Session() { { title: "Half page down", value: "session.half.page.down", - keybind: "messages_half_page_down", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollBy(scroll.height / 4) dialog.clear() }, @@ -754,10 +729,9 @@ export function Session() { { title: "First message", value: "session.first", - keybind: "messages_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(0) dialog.clear() }, @@ -765,10 +739,9 @@ export function Session() { { title: "Last message", value: "session.last", - keybind: "messages_last", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() }, @@ -776,10 +749,9 @@ export function Session() { { title: "Jump to last user message", value: "session.messages_last_user", - keybind: "messages_last_user", category: "Session", hidden: true, - onSelect: () => { + run: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -808,25 +780,22 @@ export function Session() { { title: "Next message", value: "session.message.next", - keybind: "messages_next", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("next", dialog), + run: () => scrollToMessage("next", dialog), }, { title: "Previous message", value: "session.message.previous", - keybind: "messages_previous", category: "Session", hidden: true, - onSelect: (dialog) => scrollToMessage("prev", dialog), + run: () => scrollToMessage("prev", dialog), }, { title: "Copy last assistant message", value: "messages.copy", - keybind: "messages_copy", category: "Session", - onSelect: (dialog) => { + run: () => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), @@ -871,7 +840,7 @@ export function Session() { slash: { name: "copy", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -897,12 +866,11 @@ export function Session() { { title: "Export session transcript", value: "session.export", - keybind: "session_export", category: "Session", slash: { name: "export", }, - onSelect: async (dialog) => { + run: async () => { try { const sessionData = session() if (!sessionData) return @@ -959,10 +927,9 @@ export function Session() { { title: "Go to child session", value: "session.child.first", - keybind: "session_child_first", category: "Session", hidden: true, - onSelect: (dialog) => { + run: () => { moveFirstChild() dialog.clear() }, @@ -970,11 +937,10 @@ export function Session() { { title: "Go to parent session", value: "session.parent", - keybind: "session_parent", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { const parentID = session()?.parentID if (parentID) { navigate({ @@ -988,11 +954,10 @@ export function Session() { { title: "Next child session", value: "session.child.next", - keybind: "session_child_cycle", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(1) dialog.clear() }), @@ -1000,17 +965,36 @@ export function Session() { { title: "Previous child session", value: "session.child.previous", - keybind: "session_child_cycle_reverse", category: "Session", hidden: true, enabled: !!session()?.parentID, - onSelect: childSessionHandler((dialog) => { + run: childSessionHandler(() => { moveChild(-1) dialog.clear() }), }, ]) + const sessionCommands = createMemo(() => + sessionCommandList().map((command) => ({ + namespace: "palette", + name: command.value, + desc: "description" in command ? command.description : undefined, + slashName: "slash" in command ? command.slash?.name : undefined, + slashAliases: "slash" in command ? command.slash?.aliases : undefined, + ...command, + })), + ) + + useBindings(() => ({ + commands: sessionCommands(), + })) + + useBindings(() => ({ + enabled: command.matcher, + bindings: sections.session, + })) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1082,7 +1066,8 @@ export function Session() { {(function () { - const command = useCommandDialog() + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") const [hover, setHover] = createSignal(false) const dialog = useDialog() @@ -1093,7 +1078,7 @@ export function Session() { "Are you sure you want to restore the reverted messages?", ) if (confirmed) { - command.trigger("session.redo") + command.run("session.redo") } } @@ -1116,7 +1101,7 @@ export function Session() { > {revert()!.reverted.length} message reverted - {keybind.print("messages_redo")} or /redo to + {redoShortcut()} or /redo to restore @@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las return props.message.time.completed - user.time.created }) - const keybind = useKeybind() + const childShortcut = useCommandShortcut("session.child.first") return ( <> @@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las x.type === "tool" && x.tool === "task")}> - {keybind.print("session_child_first")} + {childShortcut()} view subagents diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index e7e4c7cea3..5e7e80b66a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,24 +1,22 @@ import { createStore } from "solid-js/store" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" -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/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { useBindings, useCommandShortcut } from "../../keymap" type PermissionStage = "permission" | "always" | "reject" @@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() - const keybind = useKeybind() - const textareaKeybindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() - - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - props.onCancel() - return - } - if (evt.name === "return") { - evt.preventDefault() - props.onConfirm(input.plainText) - } - }) + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.reject.cancel", + run() { + props.onCancel() + }, + }, + ], + bindings: [ + { key: "escape", cmd: () => props.onCancel() }, + ...keymapConfig.pick("permission", ["permission.reject.cancel"]), + { key: "return", cmd: () => props.onConfirm(input.plainText) }, + ], + })) return ( void; onCancel: ( textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={textareaKeybindings()} /> @@ -545,50 +544,75 @@ function Prompt>(props: { onSelect: (option: keyof T) => void }) { const { theme } = useTheme() - const keybind = useKeybind() + const tuiConfig = useTuiConfig() + const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], expanded: false, }) - const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() + const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") - useKeyboard((evt) => { - if (dialog.stack.length > 0) return - - if (evt.name === "left" || evt.name == "h") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx - 1 + keys.length) % keys.length] - setStore("selected", next) - } - - if (evt.name === "right" || evt.name == "l") { - evt.preventDefault() - const idx = keys.indexOf(store.selected) - const next = keys[(idx + 1) % keys.length] - setStore("selected", next) - } - - if (evt.name === "return") { - evt.preventDefault() - props.onSelect(store.selected) - } - - if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { - evt.preventDefault() - props.onSelect(props.escapeKey) - } - - if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { - evt.preventDefault() - evt.stopPropagation() - setStore("expanded", (v) => !v) - } - }) + useBindings(() => ({ + enabled: dialog.stack.length === 0, + commands: [ + { + name: "permission.prompt.escape", + run() { + if (!props.escapeKey) return + props.onSelect(props.escapeKey) + }, + }, + { + name: "permission.prompt.fullscreen", + run() { + if (!props.fullscreen) return + setStore("expanded", (v) => !v) + }, + }, + ], + bindings: [ + { + key: "left", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "h", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx - 1 + keys.length) % keys.length] + setStore("selected", next) + }, + }, + { + key: "right", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { + key: "l", + cmd: () => { + const idx = keys.indexOf(store.selected) + const next = keys[(idx + 1) % keys.length] + setStore("selected", next) + }, + }, + { key: "return", cmd: () => props.onSelect(store.selected) }, + ...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []), + ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), + ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), + ], + })) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) useRenderer() @@ -661,7 +685,7 @@ function Prompt>(props: { - {"ctrl+f"} {hint()} + {fullscreenHint()} {hint()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 3ff95b4bb8..617ede6395 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,20 +1,22 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" -import { useKeybind } from "../../context/keybind" import { selectedForeground, tint, useTheme } from "../../context/theme" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" -import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { useTuiConfig } from "../../context/tui-config" +import { useBindings } from "../../keymap" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() - const keybind = useKeybind() - const bindings = useTextareaKeybindings() + const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig + const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const dialog = useDialog() - useKeyboard((evt) => { - // Skip processing if a dialog (e.g., command palette) is open - if (dialog.stack.length > 0) return - - // When editing custom answer textarea - if (store.editing && !confirm()) { - if (evt.name === "escape") { - evt.preventDefault() - setStore("editing", false) - return - } - if (keybind.match("input_clear", evt)) { - evt.preventDefault() - const text = textarea?.plainText ?? "" - if (!text) { + useBindings(() => ({ + enabled: store.editing && !confirm(), + commands: [ + { + name: "question.edit.clear", + run() { + const text = textarea?.plainText ?? "" + if (!text) { + setStore("editing", false) + return + } + textarea?.setText("") + }, + }, + ], + bindings: [ + { + key: "escape", + cmd: () => { setStore("editing", false) - return - } - textarea?.setText("") - return - } - if (evt.name === "return") { - evt.preventDefault() - const text = textarea?.plainText?.trim() ?? "" - const prev = store.custom[store.tab] + }, + }, + ...keymapConfig.pick("question", ["question.edit.clear"]), + { + key: "return", + cmd: () => { + const text = textarea?.plainText?.trim() ?? "" + const prev = store.custom[store.tab] - if (!text) { - if (prev) { + if (!text) { + if (prev) { + const inputs = [...store.custom] + inputs[store.tab] = "" + setStore("custom", inputs) + + const answers = [...store.answers] + answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + setStore("answers", answers) + } + setStore("editing", false) + return + } + + if (multi()) { const inputs = [...store.custom] - inputs[store.tab] = "" + inputs[store.tab] = text setStore("custom", inputs) + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (prev) { + const index = next.indexOf(prev) + if (index !== -1) next.splice(index, 1) + } + if (!next.includes(text)) next.push(text) const answers = [...store.answers] - answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) + answers[store.tab] = next setStore("answers", answers) + setStore("editing", false) + return } + + pick(text, true) setStore("editing", false) - return - } + }, + }, + ], + })) - if (multi()) { - const inputs = [...store.custom] - inputs[store.tab] = text - setStore("custom", inputs) + useBindings(() => { + const opts = options() + const total = opts.length + (custom() ? 1 : 0) + const max = Math.min(total, 9) - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (prev) { - const index = next.indexOf(prev) - if (index !== -1) next.splice(index, 1) - } - if (!next.includes(text)) next.push(text) - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) - setStore("editing", false) - return - } - - pick(text, true) - setStore("editing", false) - return - } - // Let textarea handle all other keys - return - } - - if (evt.name === "left" || evt.name === "h") { - evt.preventDefault() - selectTab((store.tab - 1 + tabs()) % tabs()) - } - - if (evt.name === "right" || evt.name === "l") { - evt.preventDefault() - selectTab((store.tab + 1) % tabs()) - } - - if (evt.name === "tab") { - evt.preventDefault() - const direction = evt.shift ? -1 : 1 - selectTab((store.tab + direction + tabs()) % tabs()) - } - - if (confirm()) { - if (evt.name === "return") { - evt.preventDefault() - submit() - } - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } - } else { - const opts = options() - const total = opts.length + (custom() ? 1 : 0) - const max = Math.min(total, 9) - const digit = Number(evt.name) - - if (!Number.isNaN(digit) && digit >= 1 && digit <= max) { - evt.preventDefault() - const index = digit - 1 - moveTo(index) - selectOption() - return - } - - if (evt.name === "up" || evt.name === "k") { - evt.preventDefault() - moveTo((store.selected - 1 + total) % total) - } - - if (evt.name === "down" || evt.name === "j") { - evt.preventDefault() - moveTo((store.selected + 1) % total) - } - - if (evt.name === "return") { - evt.preventDefault() - selectOption() - } - - if (evt.name === "escape" || keybind.match("app_exit", evt)) { - evt.preventDefault() - reject() - } + return { + enabled: dialog.stack.length === 0 && !store.editing, + commands: [ + { + name: "question.reject", + run() { + reject() + }, + }, + ], + bindings: [ + { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) }, + { key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) }, + { + key: "tab", + cmd: ({ event }: { event: { shift: boolean } }) => { + selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs()) + }, + }, + ...(confirm() + ? [ + { key: "return", cmd: () => submit() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + cmd: () => { + moveTo(index) + selectOption() + }, + })), + { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) }, + { key: "down", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "j", cmd: () => moveTo((store.selected + 1) % total) }, + { key: "return", cmd: () => selectOption() }, + { key: "escape", cmd: () => reject() }, + ...sections.question, + ]), + ], } }) @@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { textColor={theme.text} focusedTextColor={theme.text} cursorColor={theme.primary} - keyBindings={bindings()} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index c857937d4a..2a6813ffbe 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" 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/locale" import { useTerminalDimensions } from "@opentui/solid" +import { useCommandPalette } from "../../context/command-palette" +import { useCommandShortcut } from "../../keymap" export function SubagentFooter() { const route = useRouteData("session") @@ -56,8 +56,10 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const keybind = useKeybind() - const command = useCommandDialog() + const command = useCommandPalette() + const parentShortcut = useCommandShortcut("session.parent") + const previousShortcut = useCommandShortcut("session.child.previous") + const nextShortcut = useCommandShortcut("session.child.next") const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) useTerminalDimensions() @@ -96,31 +98,31 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} + onMouseUp={() => command.run("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > - Parent {keybind.print("session_parent")} + Parent {parentShortcut()} setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} + onMouseUp={() => command.run("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > - Prev {keybind.print("session_child_cycle_reverse")} + Prev {previousShortcut()} setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} + onMouseUp={() => command.run("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > - Next {keybind.print("session_child_cycle")} + Next {nextShortcut()} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index fb159115dc..965c80f362 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogAlertProps = { title: string @@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + props.onConfirm?.() + dialog.clear() + }, + }, + ], + })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 3870cf816c..0a1ce0b344 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -3,8 +3,8 @@ import { useTheme } from "../context/theme" 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/locale" +import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string @@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) { active: "confirm" as "confirm" | "cancel", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - if (store.active === "confirm") props.onConfirm?.() - if (store.active === "cancel") props.onCancel?.() - dialog.clear() - } - - if (evt.name === "left" || evt.name === "right") { - setStore("active", store.active === "confirm" ? "cancel" : "confirm") - } - }) + useBindings(() => ({ + bindings: [ + { + key: "return", + cmd: () => { + if (store.active === "confirm") props.onConfirm?.() + if (store.active === "cancel") props.onCancel?.() + dialog.clear() + }, + }, + { + key: "left", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + { + key: "right", + cmd: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + ], + })) return ( @@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingLeft={1} paddingRight={1} backgroundColor={key === store.active ? theme.primary : undefined} - onMouseUp={(_evt) => { + onMouseUp={() => { if (key === "confirm") props.onConfirm?.() if (key === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index b9362db46b..35d9dec4b0 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useBindings } from "../keymap" export type DialogExportOptionsProps = { defaultFilename: string @@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", }) - useKeyboard((evt) => { - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.({ - filename: textarea.plainText, - thinking: store.thinking, - toolDetails: store.toolDetails, - assistantMetadata: store.assistantMetadata, - openWithoutSaving: store.openWithoutSaving, - }) - } - if (evt.name === "tab") { - const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ - "filename", - "thinking", - "toolDetails", - "assistantMetadata", - "openWithoutSaving", - ] - const currentIndex = order.indexOf(store.active) - const nextIndex = (currentIndex + 1) % order.length - setStore("active", order[nextIndex]) - evt.preventDefault() - } - if (evt.name === "space" || evt.name === " ") { - if (store.active === "thinking") setStore("thinking", !store.thinking) - if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) - if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) - if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) - evt.preventDefault() - } - }) + useBindings(() => ({ + bindings: [ + { + key: "tab", + cmd: () => { + const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ + "filename", + "thinking", + "toolDetails", + "assistantMetadata", + "openWithoutSaving", + ] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + }, + }, + ], + })) + + useBindings(() => ({ + enabled: store.active !== "filename", + bindings: [ + { + key: "space", + cmd: () => { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) + if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) + }, + }, + ], + })) onMount(() => { dialog.setSize("medium") @@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { }) }} height={3} - keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val val.traits = { status: "FILENAME" } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 24b93b96a7..b6a394d2de 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -1,21 +1,19 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" -import { useKeyboard } from "@opentui/solid" -import { useKeybind } from "@tui/context/keybind" +import { useBindings, useCommandShortcut } from "../keymap" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() - const keybind = useKeybind() + const commandShortcut = useCommandShortcut("command.palette.show") - useKeyboard((evt) => { - if (evt.name === "return" || evt.name === "escape") { - evt.preventDefault() - evt.stopPropagation() - dialog.clear() - } - }) + useBindings(() => ({ + bindings: [ + { key: "return", cmd: () => dialog.clear() }, + { key: "escape", cmd: () => dialog.clear() }, + ], + })) return ( @@ -29,7 +27,7 @@ export function DialogHelp() { - Press {keybind.print("command_list")} to see all available actions and commands in any context. + Press {commandShortcut()} to see all available actions and commands in any context. diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 92d6d277d0..34ab9161f6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { Show, createEffect, onMount, type JSX } from "solid-js" -import { useKeyboard } from "@opentui/solid" import { Spinner } from "../component/spinner" export type DialogPromptProps = { @@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) { const { theme } = useTheme() let textarea: TextareaRenderable - useKeyboard((evt) => { - if (props.busy) { - if (evt.name === "escape") return - evt.preventDefault() - evt.stopPropagation() - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - props.onConfirm?.(textarea.plainText) - } - }) - onMount(() => { dialog.setSize("medium") setTimeout(() => { @@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) { props.onConfirm?.(textarea.plainText) }} height={3} - keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => { textarea = val }} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index ef7d4bd3bb..cbf5d2dbfc 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,17 +1,24 @@ -import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { + InputRenderable, + RGBA, + ScrollBoxRenderable, + TextAttributes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTerminalDimensions } from "@opentui/solid" 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/keybind" import { Locale } from "@/util/locale" import { getScrollAcceleration } from "../util/scroll" import { useTuiConfig } from "../context/tui-config" +import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap" export interface DialogSelectProps { title: string @@ -24,13 +31,14 @@ export interface DialogSelectProps { onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean - keybind?: { - keybind?: Keybind.Info + actions?: { + command: string title: string side?: "left" | "right" disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] + bindings?: readonly Binding[] current?: T } @@ -57,6 +65,9 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const { + keymap: { sections }, + } = tuiConfig const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -81,6 +92,25 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable + const actions = createMemo(() => props.actions ?? []) + const actionBindings = useKeymapSelector((keymap) => + keymap.getCommandBindings({ + visibility: "registered", + commands: actions().map((item) => item.command), + }), + ) + + const actionLabels = createMemo(() => { + const labels = new Map() + + for (const action of actions()) { + const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig) + if (label) labels.set(action.command, label) + } + + return labels + }) + const filtered = createMemo(() => { if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() @@ -171,7 +201,7 @@ export function DialogSelect(props: DialogSelectProps) { const option = selected() if (option) props.onMove?.(option) if (!scroll) return - const target = scroll.getChildren().find((child) => { + const target = scroll.getChildren().find((child: { id?: string }) => { return child.id === JSON.stringify(selected()?.value) }) if (!target) return @@ -192,36 +222,86 @@ export function DialogSelect(props: DialogSelectProps) { } } - const keybind = useKeybind() - useKeyboard((evt) => { + function submit() { setStore("input", "keyboard") + const option = selected() + if (!option) return + option.onSelect?.(dialog) + props.onSelect?.(option) + } - if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) - if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) - if (evt.name === "pageup") move(-10) - if (evt.name === "pagedown") move(10) - if (evt.name === "home") moveTo(0) - if (evt.name === "end") moveTo(flat().length - 1) + useBindings(() => { + const enabledActions = actions().filter((item) => !item.disabled) - if (evt.name === "return") { - const option = selected() - if (option) { - evt.preventDefault() - evt.stopPropagation() - if (option.onSelect) option.onSelect(dialog) - props.onSelect?.(option) - } - } - - for (const item of props.keybind ?? []) { - if (item.disabled || !item.keybind) continue - if (Keybind.match(item.keybind, keybind.parse(evt))) { - const s = selected() - if (s) { - evt.preventDefault() - item.onTrigger(s) - } - } + return { + commands: [ + { + name: "dialog.select.prev", + run() { + setStore("input", "keyboard") + move(-1) + }, + }, + { + name: "dialog.select.next", + run() { + setStore("input", "keyboard") + move(1) + }, + }, + { + name: "dialog.select.page_up", + run() { + setStore("input", "keyboard") + move(-10) + }, + }, + { + name: "dialog.select.page_down", + run() { + setStore("input", "keyboard") + move(10) + }, + }, + { + name: "dialog.select.home", + run() { + setStore("input", "keyboard") + moveTo(0) + }, + }, + { + name: "dialog.select.end", + run() { + setStore("input", "keyboard") + moveTo(flat().length - 1) + }, + }, + { + name: "dialog.select.submit", + run: submit, + }, + ...enabledActions.map((item) => ({ + name: item.command, + run() { + setStore("input", "keyboard") + const option = selected() + if (!option) return + item.onTrigger(option) + }, + })), + ], + bindings: [ + ...sections.dialog_select, + ...tuiConfig.keymap.pick( + "dialog_actions", + enabledActions.map((item) => item.command), + ), + ...(props.bindings ?? []).filter((binding) => { + if (typeof binding.cmd !== "string") return true + return enabledActions.some((item) => item.command === binding.cmd) + }), + ], } }) @@ -236,9 +316,13 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) - const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) - const right = createMemo(() => keybinds().filter((item) => item.side === "right")) + const visibleActions = createMemo(() => + actions() + .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) + .filter((item) => !item.disabled && item.label), + ) + const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) + const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) return ( @@ -365,7 +449,7 @@ export function DialogSelect(props: DialogSelectProps) { - }> + }> (props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} @@ -393,7 +477,7 @@ export function DialogSelect(props: DialogSelectProps) { {item.title}{" "} - {Keybind.toString(item.keybind)} + {item.label} )} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index a5da735f65..0dff8b5433 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,4 +1,4 @@ -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@opencode-ai/core/flag/flag" import * as Selection from "@tui/util/selection" +import { useBindings } from "../keymap" export function Dialog( props: ParentProps<{ @@ -47,7 +48,7 @@ export function Dialog( backgroundColor={RGBA.fromInts(0, 0, 0, 150)} > { + onMouseUp={(e: { stopPropagation(): void }) => { dismiss = false e.stopPropagation() }} @@ -73,23 +74,6 @@ function init() { const renderer = useRenderer() - useKeyboard((evt) => { - if (store.stack.length === 0) return - if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return - if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { - if (renderer.getSelection()) { - renderer.clearSelection() - } - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - evt.preventDefault() - evt.stopPropagation() - refocus() - } - }) - let focus: Renderable | null function refocus() { setTimeout(() => { @@ -108,6 +92,36 @@ function init() { }, 1) } + useBindings(() => ({ + enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), + bindings: [ + { + key: "escape", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + { + key: "ctrl+c", + cmd: () => { + if (renderer.getSelection()) { + renderer.clearSelection() + } + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + }, + }, + ], + })) + return { clear() { for (const item of store.stack) { @@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) { const value = init() const renderer = useRenderer() const toast = useToast() + return ( {props.children} { + onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => { if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return diff --git a/packages/opencode/src/cli/cmd/tui/util/scroll.ts b/packages/opencode/src/cli/cmd/tui/util/scroll.ts index 30d0069639..715a8480cf 100644 --- a/packages/opencode/src/cli/cmd/tui/util/scroll.ts +++ b/packages/opencode/src/cli/cmd/tui/util/scroll.ts @@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration { reset(): void {} } -export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration { +export function getScrollAcceleration( + tuiConfig?: Pick, +): ScrollAcceleration { if (tuiConfig?.scroll_acceleration?.enabled) { return new MacOSScrollAccel() } diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 0e0c47874e..bb2f658cc2 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -5,9 +5,21 @@ type Toast = { error: (err: unknown) => void } +type FocusableSelectionTarget = { + hasSelection: () => boolean +} + type Renderer = { - getSelection: () => { getSelectedText: () => string } | null + getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null clearSelection: () => void + currentFocusedRenderable?: FocusableSelectionTarget | null +} + +type SelectionKeyEvent = { + ctrl?: boolean + name: string + preventDefault: () => void + stopPropagation: () => void } export function copy(renderer: Renderer, toast: Toast): boolean { @@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean { return true } +export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) { + const selection = renderer.getSelection() + if (!selection) return + + if (event.ctrl && event.name === "c") { + if (!copy(renderer, toast)) { + renderer.clearSelection() + return + } + + event.preventDefault() + event.stopPropagation() + return + } + + if (event.name === "escape") { + renderer.clearSelection() + event.preventDefault() + event.stopPropagation() + return + } + + const focus = renderer.currentFocusedRenderable + if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return + + renderer.clearSelection() +} + export * as Selection from "./selection" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index a84fc0b37d..d9a397f516 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({ theme_list: keybind("t", "List available themes"), sidebar_toggle: keybind("b", "Toggle sidebar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - username_toggle: keybind("none", "Toggle username visibility"), status_view: keybind("s", "View status"), session_export: keybind("x", "Export session to editor"), session_new: keybind("n", "Create a new session"), @@ -59,6 +58,22 @@ const KeybindsSchema = Schema.Struct({ model_cycle_favorite: keybind("none", "Next favorite model"), model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), command_list: keybind("ctrl+p", "List available commands"), + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), agent_list: keybind("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), agent_cycle_reverse: keybind("shift+tab", "Previous agent"), @@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({ input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + input_select_all: keybind("super+a", "Select all in input"), history_previous: keybind("up", "Previous history item"), history_next: keybind("down", "Next history item"), session_child_first: keybind("down", "Go to first child session"), diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts deleted file mode 100644 index e3c9b2bc02..0000000000 --- a/packages/opencode/src/util/keybind.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isDeepEqual } from "remeda" -import type { ParsedKey } from "@opentui/core" - -/** - * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. - * This ensures type compatibility and catches missing fields at compile time. - */ -export type Info = Pick & { - leader: boolean // our custom field -} - -export function match(a: Info | undefined, b: Info): boolean { - if (!a) return false - const normalizedA = { ...a, super: a.super ?? false } - const normalizedB = { ...b, super: b.super ?? false } - return isDeepEqual(normalizedA, normalizedB) -} - -/** - * Convert OpenTUI's ParsedKey to our Keybind.Info format. - * This helper ensures all required fields are present and avoids manual object creation. - */ -export function fromParsedKey(key: ParsedKey, leader = false): Info { - return { - name: key.name === " " ? "space" : key.name, - ctrl: key.ctrl, - meta: key.meta, - shift: key.shift, - super: key.super ?? false, - leader, - } -} - -export function toString(info: Info | undefined): string { - if (!info) return "" - const parts: string[] = [] - - if (info.ctrl) parts.push("ctrl") - if (info.meta) parts.push("alt") - if (info.super) parts.push("super") - if (info.shift) parts.push("shift") - if (info.name) { - if (info.name === "delete") parts.push("del") - else parts.push(info.name) - } - - let result = parts.join("+") - - if (info.leader) { - result = result ? ` ${result}` : `` - } - - return result -} - -export function parse(key: string): Info[] { - if (key === "none") return [] - - return key.split(",").map((combo) => { - // Handle syntax by replacing with leader+ - const normalized = combo.replace(//g, "leader+") - const parts = normalized.toLowerCase().split("+") - const info: Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "", - } - - for (const part of parts) { - switch (part) { - case "ctrl": - info.ctrl = true - break - case "alt": - case "meta": - case "option": - info.meta = true - break - case "super": - info.super = true - break - case "shift": - info.shift = true - break - case "leader": - info.leader = true - break - case "esc": - info.name = "escape" - break - default: - info.name = part - break - } - } - - return info - }) -} - -export * as Keybind from "./keybind" diff --git a/packages/opencode/test/cli/tui/keybind-plugin.test.ts b/packages/opencode/test/cli/tui/keybind-plugin.test.ts deleted file mode 100644 index 7cd4c87a73..0000000000 --- a/packages/opencode/test/cli/tui/keybind-plugin.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { ParsedKey } from "@opentui/core" -import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds" - -describe("createPluginKeybind", () => { - const defaults = { - open: "ctrl+o", - close: "escape", - } - - test("uses defaults when overrides are missing", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults) - - expect(bind.all).toEqual(defaults) - expect(bind.get("open")).toBe("ctrl+o") - expect(bind.get("close")).toBe("escape") - }) - - test("applies valid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+alt+o", - close: "q", - }) - - expect(bind.all).toEqual({ - open: "ctrl+alt+o", - close: "q", - }) - }) - - test("ignores invalid overrides", () => { - const api = { - match: () => false, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: " ", - close: 1, - extra: "ctrl+x", - }) - - expect(bind.all).toEqual(defaults) - expect(bind.get("extra")).toBe("extra") - }) - - test("resolves names for match", () => { - const list: string[] = [] - const api = { - match: (key: string) => { - list.push(key) - return true - }, - print: (key: string) => key, - } - const bind = createPluginKeybind(api, defaults, { - open: "ctrl+shift+o", - }) - - bind.match("open", { name: "x" } as ParsedKey) - bind.match("ctrl+k", { name: "x" } as ParsedKey) - - expect(list).toEqual(["ctrl+shift+o", "ctrl+k"]) - }) - - test("resolves names for print", () => { - const list: string[] = [] - const api = { - match: () => false, - print: (key: string) => { - list.push(key) - return `print:${key}` - }, - } - const bind = createPluginKeybind(api, defaults, { - close: "q", - }) - - expect(bind.print("close")).toBe("print:q") - expect(bind.print("ctrl+p")).toBe("print:ctrl+p") - expect(list).toEqual(["q", "ctrl+p"]) - }) -}) diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 972da0f50f..c54dbaacaa 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { await Bun.write( path.join(tmp.extra.mod, "index.ts"), diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index ca7e8fcd21..50ca4dbad2 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [], - plugin_origins: undefined, - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 66858e2d0d..35df997e8b 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Npm } from "@opencode-ai/core/npm" @@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) @@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index ba7a4b3959..fb4a3bb57d 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 4266906a24..1702101233 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" +import { createTestKeymap } from "@opentui/keymap/testing" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { Global } from "@opencode-ai/core/global" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { Filesystem } from "@/util/filesystem" @@ -79,7 +81,10 @@ async function load(): Promise { await Bun.write( localPluginPath, - `export const ignored = async (_input, options) => { + `import { resolveBindingSections } from "@opentui/keymap/extras" +import { useBindings } from "@opentui/keymap/solid" + +export const ignored = async (_input, options) => { if (!options?.fn_marker) return await Bun.write(options.fn_marker, "called") } @@ -93,10 +98,21 @@ export default { const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled const cfg_submit = api.tuiConfig.keybinds?.input_submit - const key = api.keybind.create( - { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" }, - options.keybinds, - ) + const has_keys = typeof api.keys.formatBindings === "function" + const keymap = resolveBindingSections(options.keymap?.sections ?? { + main: { + "plugin.loader.local": "ctrl+shift+m", + "plugin.loader.close": "escape", + }, + }, { sections: ["main"] }).sections + const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key + const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key + const key_unknown = "ctrl+k" + const off = api.keymap.registerLayer({ + commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], + bindings: keymap.main, + }) + off() const kv_before = api.kv.get(options.kv_key, "missing") api.kv.set(options.kv_key, "stored") const kv_after = api.kv.get(options.kv_key, "missing") @@ -132,10 +148,13 @@ export default { set_installed, selected: api.theme.selected, same: first === second, - key_modal: key.get("modal"), - key_close: key.get("close"), - key_unknown: key.get("ctrl+k"), - key_print: key.print("modal"), + key_modal, + key_close, + key_unknown, + has_keys, + has_keymap: typeof api.keymap.registerLayer === "function", + has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, kv_ready: api.kv.ready, @@ -337,7 +356,14 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keybinds: { modal: "ctrl+alt+m", close: "q" }, + keymap: { + sections: { + main: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", + }, + }, + }, } const invalidOpts = { marker: tmp.extra.invalidMarker, @@ -356,7 +382,7 @@ export default { theme_name: tmp.extra.globalThemeName, } - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.localSpec, localOpts], [tmp.extra.invalidSpec, invalidOpts], @@ -373,7 +399,7 @@ export default { source: path.join(Global.Path.config, "tui.json"), }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi({ @@ -386,9 +412,6 @@ export default { input_submit: "ctrl+enter", }, }, - keybind: { - print: (key) => `print:${key}`, - }, state: { session: { diff(sessionID) { @@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) @@ -606,13 +629,13 @@ export default { const b = path.join(tmp.path, "order-b.ts") const aSpec = pathToFileURL(a).href const bSpec = pathToFileURL(b).href - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [aSpec, bSpec], plugin_origins: [ { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, ], - } + }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") expect(lines).toEqual(["a-start", "a-end", "b"]) @@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => { expect(data.local.key_modal).toBe("ctrl+alt+m") expect(data.local.key_close).toBe("q") expect(data.local.key_unknown).toBe("ctrl+k") - expect(data.local.key_print).toBe("print:ctrl+alt+m") + expect(data.local.has_keys).toBe(true) + expect(data.local.has_keymap).toBe(true) + expect(data.local.has_resolve_binding_sections).toBe(true) + expect(data.local.has_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") expect(data.local.kv_ready).toBe(true) @@ -703,6 +729,227 @@ describe("tui.plugin.loader", () => { }) }) +test("auto-disposes plugin keymap layers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup", + tui: async (api) => { + api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup", run() {} }], + bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }], + }) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_add = 0 + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false + if (tracked) command_add += 1 + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(command_add).toBe(1) + expect(command_drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("plugin keymap proxy preserves real keymap receiver", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-receiver-plugin.ts") + const spec = pathToFileURL(file).href + const marker = path.join(dir, "keymap-receiver.txt") + + await Bun.write( + file, + `export default { + id: "demo.keymap.receiver", + tui: async (api) => { + api.keymap.setData("demo.receiver", "ok") + await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver"))) + }, +} +`, + ) + + return { spec, marker } + }, + }) + + const harness = createTestKeymap({ defaultKeys: true }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ + keymap: harness.keymap as unknown as NonNullable[0]>["keymap"], + }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok") + expect(harness.keymap.getData("demo.receiver")).toBe("ok") + } finally { + await TuiPluginRuntime.dispose() + harness.cleanup() + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("auto-disposes plugin keymap transformers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.transformer.cleanup", + tui: async (api) => { + api.keymap.prependLayerBindingsTransformer((bindings) => bindings) + api.keymap.appendLayerBindingsTransformer((bindings) => bindings) + api.keymap.prependCommandTransformer(() => {}) + api.keymap.appendCommandTransformer(() => {}) + }, +} +`, + ) + + return { spec } + }, + }) + + let add = 0 + let drop = 0 + const track = () => { + add += 1 + return () => { + drop += 1 + } + } + const keymap = { + registerLayer: () => () => {}, + prependLayerBindingsTransformer: track, + appendLayerBindingsTransformer: track, + prependCommandTransformer: track, + appendCommandTransformer: track, + } as unknown as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(add).toBe(4) + expect(drop).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(drop).toBe(4) + cwd.mockRestore() + wait.mockRestore() + } +}) + +test("manual onDispose for plugin keymap layers stays idempotent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "keymap-cleanup-manual-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.keymap.cleanup.manual", + tui: async (api) => { + const off = api.keymap.registerLayer({ + commands: [{ name: "demo.keymap.cleanup.manual", run() {} }], + bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }], + }) + api.lifecycle.onDispose(off) + }, +} +`, + ) + + return { spec } + }, + }) + + let command_drop = 0 + const keymap = { + registerLayer(layer: { commands?: Array<{ name: string }> }) { + const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false + return () => { + if (!tracked) return + command_drop += 1 + } + }, + } as NonNullable[0]>["keymap"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ keymap }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + } finally { + await TuiPluginRuntime.dispose() + expect(command_drop).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("updates installed theme when plugin metadata changes", async () => { await using tmp = await tmpdir<{ spec: string @@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => { }, }) - const mkConfig = (): TuiConfig.Info => ({ - plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], - plugin_origins: [ - { - spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], - scope: "local", - source: path.join(tmp.path, "tui.json"), - }, - ], - }) + const mkConfig = () => + createTuiResolvedConfig({ + plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], + plugin_origins: [ + { + spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + ], + }) try { await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 11fdf5ce46..4dde1add4d 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { source: path.join(tmp.path, "tui.json"), }, ], - } + }) const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5053a7e1f7..5acc3d84fa 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) => ), ) +async function withPlatform(platform: typeof process.platform, fn: () => Promise) { + const original = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { + ...original, + value: platform, + }) + try { + return await fn() + } finally { + if (original) Object.defineProperty(process, "platform", original) + } +} + afterEach(async () => { delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_TUI_CONFIG @@ -389,6 +402,98 @@ test("merges keybind overrides across precedence layers", async () => { expect(config.keybinds?.theme_list).toBe("ctrl+k") }) +test("resolves semantic keymap sections", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { command_list: "ctrl+z" }, + keymap: { + sections: { + global: { "command.palette.show": "alt+p" }, + prompt: { "prompt.editor": "ctrl+e" }, + autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, + dialog_actions: { "dialog.action.toggle": "ctrl+t" }, + model: { "model.dialog.favorite": "ctrl+f" }, + plugins: { "plugin.dialog.install": "shift+i" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") + expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") + expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") + expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugin.dialog.install", + ]) + expect( + (config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, + ).toBe("Plugins") + expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) +}) + +test("legacy keybinds transform into semantic keymap sections", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: "alt+p", + editor_open: "ctrl+e", + "prompt.autocomplete.next": "ctrl+j", + "dialog.mcp.toggle": "ctrl+t", + "dialog.plugins.install": "shift+i", + plugin_manager: "ctrl+shift+p", + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(Object.keys(config.keymap.sections)).toEqual([ + "global", + "session", + "prompt", + "autocomplete", + "input", + "dialog_select", + "dialog_actions", + "model", + "permission", + "question", + "plugins", + "home_tips", + ]) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") + expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") + expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") + expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p") + expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugin.dialog.install", + ]) + expect( + (config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, + ).toBe("Plugins") + expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ + "plugins.list", + ]) +}) + wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) @@ -419,6 +524,62 @@ wintest("ignores terminal suspend bindings on Windows", async () => { expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }) +test("applies Windows keymap defaults", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir() + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() + expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( + "ctrl+z,ctrl+-,super+z", + ) + }) +}) + +test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keymap: { + sections: { + global: { "terminal.suspend": "alt+z" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") + }) +}) + +test("keeps explicit configured keymap input undo on Windows", async () => { + await withPlatform("win32", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keymap: { + sections: { + input: { "input.undo": "ctrl+y" }, + }, + }, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + }) +}) + test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 26913222e8..a4a5aaad60 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,7 +1,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" -import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" +import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" +import { ConfigKeybinds } from "../../src/config/keybinds" +import { createTuiResolvedKeymap } from "./tui-runtime" type Count = { event_add: number @@ -84,8 +86,8 @@ type Opts = { client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) renderer?: HostPluginApi["renderer"] count?: Count - keybind?: Partial - tuiConfig?: HostPluginApi["tuiConfig"] + keymap?: HostPluginApi["keymap"] + tuiConfig?: Partial app?: Partial state?: { ready?: HostPluginApi["state"]["ready"] @@ -109,6 +111,15 @@ type Opts = { } } +function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { + const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) + return { + ...input, + keybinds, + keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})), + } +} + export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { const kv: Record = {} const count = opts.count @@ -128,10 +139,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { let size: "medium" | "large" | "xlarge" = "medium" const has = opts.theme?.has ?? (() => false) let selected = opts.theme?.selected ?? "opencode" - const key = { - match: opts.keybind?.match ?? (() => false), - print: opts.keybind?.print ?? ((name: string) => name), - } const set = opts.theme?.set ?? ((name: string) => { @@ -145,6 +152,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return this }, } + const keymap = + opts.keymap ?? + ({ + acquireResource(_key: symbol, setup: () => () => void) { + const dispose = setup() + return () => { + dispose() + } + }, + registerLayer() { + if (count) count.command_add += 1 + return () => { + if (!count) return + count.command_drop += 1 + } + }, + runCommand() { + return { ok: true } as const + }, + } as unknown as HostPluginApi["keymap"]) function kvGet(name: string): unknown function kvGet(name: string, fallback: Value): Value @@ -160,6 +187,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return opts.app?.version ?? "0.0.0-test" }, }, + keys: { + formatSequence: () => "", + formatBindings: () => undefined, + }, get client() { return client() }, @@ -192,17 +223,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { return () => {} }, }, - command: { - register: () => { - if (count) count.command_add += 1 - return () => { - if (!count) return - count.command_drop += 1 - } - }, - trigger: () => {}, - show: () => {}, - }, + keymap, route: { register: () => { if (count) count.route_add += 1 @@ -247,15 +268,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { }, }, }, - keybind: { - ...key, - create: - opts.keybind?.create ?? - ((defaults, over) => { - return createPluginKeybind(key, defaults, over) - }), - }, - tuiConfig: opts.tuiConfig ?? {}, + tuiConfig: tuiConfig(opts.tuiConfig), kv: { get: kvGet, set(name, value) { diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index ba8099fcdd..d1e4c744b0 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,8 +1,47 @@ import { spyOn } from "bun:test" import path from "path" +import type { KeyEvent, Renderable } from "@opentui/core" +import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" +import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" +import { ConfigKeybinds } from "../../src/config/keybinds" +import { + KeymapConfig, + KeymapSectionNames, + keymapBindingDefaults, + type KeymapConfigInput, + type KeymapSection, +} from "../../src/cli/cmd/tui/config/tui-schema" type PluginSpec = string | [string, Record] +type ResolvedInput = Omit & { + keybinds?: TuiConfig.Resolved["keybinds"] + keymap?: TuiConfig.Resolved["keymap"] +} + +export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { + const config = KeymapConfig.parse(input) + return { + leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, + leader_timeout: config.leader_timeout, + ...resolveBindingSections, KeymapSection>( + config.sections, + { + sections: KeymapSectionNames, + bindingDefaults: keymapBindingDefaults, + }, + ), + } +} + +export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { + const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + return { + ...input, + keybinds, + keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), + } +} export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record }) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") @@ -14,11 +53,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => dir) - const config: TuiConfig.Info = { + const config = createTuiResolvedConfig({ plugin, plugin_origins, ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), - } + }) return { config, diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts deleted file mode 100644 index 09df519925..0000000000 --- a/packages/opencode/test/keybind.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { Keybind } from "@/util/keybind" - -describe("Keybind.toString", () => { - test("should convert simple key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.toString(info)).toBe("f") - }) - - test("should convert ctrl modifier to string", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+x") - }) - - test("should convert leader key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.toString(info)).toBe(" f") - }) - - test("should convert multiple modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.toString(info)).toBe("ctrl+alt+g") - }) - - test("should convert all modifiers to string", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift+h") - }) - - test("should convert shift modifier to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - } - expect(Keybind.toString(info)).toBe("shift+return") - }) - - test("should convert function key to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" } - expect(Keybind.toString(info)).toBe("f2") - }) - - test("should convert special key to string", () => { - const info: Keybind.Info = { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - } - expect(Keybind.toString(info)).toBe("pgup") - }) - - test("should handle empty name", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" } - expect(Keybind.toString(info)).toBe("ctrl") - }) - - test("should handle only modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" } - expect(Keybind.toString(info)).toBe(" ctrl+alt+shift") - }) - - test("should handle only leader with no other parts", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" } - expect(Keybind.toString(info)).toBe("") - }) - - test("should convert super modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+z") - }) - - test("should convert super+shift modifier to string", () => { - const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+shift+z") - }) - - test("should handle super with ctrl modifier", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } - expect(Keybind.toString(info)).toBe("ctrl+super+a") - }) - - test("should handle super with all modifiers", () => { - const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") - }) - - test("should handle undefined super field (omitted)", () => { - const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - expect(Keybind.toString(info)).toBe("ctrl+c") - }) -}) - -describe("Keybind.match", () => { - test("should match identical keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match different key names", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should not match different modifiers", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match leader keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match leader vs non-leader", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match complex keybinds", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match with one modifier different", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match simple key without modifiers", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super modifier keybinds", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match super vs non-super", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(false) - }) - - test("should match undefined super with false super", () => { - const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } - const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should match super+shift combination", () => { - const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.match(a, b)).toBe(true) - }) - - test("should not match when only super differs", () => { - const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" } - const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" } - expect(Keybind.match(a, b)).toBe(false) - }) -}) - -describe("Keybind.parse", () => { - test("should parse simple key", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f", - }, - ]) - }) - - test("should parse leader key syntax", () => { - const result = Keybind.parse("f") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "f", - }, - ]) - }) - - test("should parse ctrl modifier", () => { - const result = Keybind.parse("ctrl+x") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse multiple modifiers", () => { - const result = Keybind.parse("ctrl+alt+u") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "u", - }, - ]) - }) - - test("should parse shift modifier", () => { - const result = Keybind.parse("shift+f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "f2", - }, - ]) - }) - - test("should parse meta/alt modifier", () => { - const result = Keybind.parse("meta+g") - expect(result).toEqual([ - { - ctrl: false, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should parse leader with modifier", () => { - const result = Keybind.parse("h") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "h", - }, - ]) - }) - - test("should parse multiple keybinds separated by comma", () => { - const result = Keybind.parse("ctrl+c,q") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "c", - }, - { - ctrl: false, - meta: false, - shift: false, - leader: true, - name: "q", - }, - ]) - }) - - test("should parse shift+return combination", () => { - const result = Keybind.parse("shift+return") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - leader: false, - name: "return", - }, - ]) - }) - - test("should parse ctrl+j combination", () => { - const result = Keybind.parse("ctrl+j") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "j", - }, - ]) - }) - - test("should handle 'none' value", () => { - const result = Keybind.parse("none") - expect(result).toEqual([]) - }) - - test("should handle special keys", () => { - const result = Keybind.parse("pgup") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "pgup", - }, - ]) - }) - - test("should handle function keys", () => { - const result = Keybind.parse("f2") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - leader: false, - name: "f2", - }, - ]) - }) - - test("should handle complex multi-modifier combination", () => { - const result = Keybind.parse("ctrl+alt+g") - expect(result).toEqual([ - { - ctrl: true, - meta: true, - shift: false, - leader: false, - name: "g", - }, - ]) - }) - - test("should be case insensitive", () => { - const result = Keybind.parse("CTRL+X") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "x", - }, - ]) - }) - - test("should parse super modifier", () => { - const result = Keybind.parse("super+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse super with shift modifier", () => { - const result = Keybind.parse("super+shift+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: true, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse multiple keybinds with super", () => { - const result = Keybind.parse("ctrl+-,super+z") - expect(result).toEqual([ - { - ctrl: true, - meta: false, - shift: false, - leader: false, - name: "-", - }, - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) -}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 861208770c..b433818ead 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,19 +22,24 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.2", - "@opentui/solid": ">=0.2.2" + "@opentui/core": ">=0.2.4", + "@opentui/keymap": ">=0.2.4", + "@opentui/solid": ">=0.2.4" }, "peerDependenciesMeta": { "@opentui/core": { "optional": true }, + "@opentui/keymap": { + "optional": true + }, "@opentui/solid": { "optional": true } }, "devDependencies": { "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 1c57a71ab3..86175c3891 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -15,11 +15,39 @@ import type { TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" -import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core" +import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" +import type { Binding, Keymap } from "@opentui/keymap" +import { + resolveBindingSections as resolveKeymapBindingSections, + type BindingSectionsConfig, + type KeySequenceFormatPart, + type SequenceBindingLike, +} from "@opentui/keymap/extras" import type { JSX, SolidPlugin } from "@opentui/solid" import type { Config as PluginConfig, PluginOptions } from "./index.js" -export type { CliRenderer, SlotMode } from "@opentui/core" +export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core" +export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap" +export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" +export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" +export type { + BindingSectionsConfig, + BindingValue, + FormatCommandBindingsOptions, + FormatKeySequenceOptions, + KeySequenceFormatPart, + SequenceBindingLike, +} from "@opentui/keymap/extras" + +export function resolveBindingSections
    ( + config: BindingSectionsConfig | undefined, + options: { sections: readonly Section[] }, +) { + return resolveKeymapBindingSections, Section>( + config ?? {}, + options, + ) +} export type TuiRouteCurrent = | { @@ -42,39 +70,12 @@ export type TuiRouteDefinition = { render: (input: { params?: Record }) => JSX.Element } -export type TuiCommand = { - title: string - value: string - description?: string - category?: string - keybind?: string - suggested?: boolean - hidden?: boolean - enabled?: boolean - slash?: { - name: string - aliases?: string[] - } - onSelect?: () => void +export type TuiKeys = { + formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string + formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined } -export type TuiKeybind = { - name: string - ctrl: boolean - meta: boolean - shift: boolean - super?: boolean - leader: boolean -} - -export type TuiKeybindMap = Record - -export type TuiKeybindSet = { - readonly all: TuiKeybindMap - get: (name: string) => string - match: (name: string, evt: ParsedKey) => boolean - print: (name: string) => string -} +export type TuiKeymap = Keymap export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" @@ -288,6 +289,14 @@ export type TuiState = { type TuiConfigView = Pick & NonNullable & { plugin_enabled?: Record + keymap: { + leader: string + leader_timeout: number + sections: Record>> + get: (section: string, cmd: string) => ReadonlyArray> | undefined + pick: (section: string, commands: readonly string[]) => Binding[] + omit: (section: string, commands: readonly string[]) => Binding[] + } } export type TuiApp = { @@ -448,11 +457,8 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp - command: { - register: (cb: () => TuiCommand[]) => () => void - trigger: (value: string) => void - show: () => void - } + keys: TuiKeys + keymap: TuiKeymap route: { register: (routes: TuiRouteDefinition[]) => () => void navigate: (name: string, params?: Record) => void @@ -469,11 +475,6 @@ export type TuiPluginApi = { toast: (input: TuiToast) => void dialog: TuiDialogStack } - keybind: { - match: (key: string, evt: ParsedKey) => boolean - print: (key: string) => string - create: (defaults: TuiKeybindMap, overrides?: Record) => TuiKeybindSet - } readonly tuiConfig: Frozen kv: TuiKV state: TuiState diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 8568ffbb9e..39c9974c56 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keybinds +### Keymap -Customize keybinds in `tui.json`. +Customize TUI keyboard shortcuts in `tui.json` with `keymap`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keybinds": {} + "keymap": { + "sections": { + "global": { + "command.palette.show": "ctrl+p" + } + } + } } ``` +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. + +The older `keybinds` field is deprecated and only applies when `keymap` is not present. + [Learn more here](/docs/keybinds). --- diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 86970638c7..a137aef37f 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,144 +1,317 @@ --- title: Keybinds -description: Customize your keybinds. +description: Customize your keyboard shortcuts. --- -OpenCode has a list of keybinds that you can customize through `tui.json`. +OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keybinds": { - "leader": "ctrl+x", - "app_exit": "ctrl+c,ctrl+d,q", - "editor_open": "e", - "theme_list": "t", - "sidebar_toggle": "b", - "scrollbar_toggle": "none", - "username_toggle": "none", - "status_view": "s", - "tool_details": "none", - "session_export": "x", - "session_new": "n", - "session_list": "l", - "session_timeline": "g", - "session_fork": "none", - "session_rename": "ctrl+r", - "session_share": "none", - "session_unshare": "none", - "session_interrupt": "escape", - "session_compact": "c", - "session_child_first": "down", - "session_child_cycle": "right", - "session_child_cycle_reverse": "left", - "session_parent": "up", - "messages_page_up": "pageup,ctrl+alt+b", - "messages_page_down": "pagedown,ctrl+alt+f", - "messages_line_up": "ctrl+alt+y", - "messages_line_down": "ctrl+alt+e", - "messages_half_page_up": "ctrl+alt+u", - "messages_half_page_down": "ctrl+alt+d", - "messages_first": "ctrl+g,home", - "messages_last": "ctrl+alt+g,end", - "messages_next": "none", - "messages_previous": "none", - "messages_copy": "y", - "messages_undo": "u", - "messages_redo": "r", - "messages_last_user": "none", - "messages_toggle_conceal": "h", - "model_list": "m", - "model_cycle_recent": "f2", - "model_cycle_recent_reverse": "shift+f2", - "model_cycle_favorite": "none", - "model_cycle_favorite_reverse": "none", - "variant_cycle": "ctrl+t", - "variant_list": "none", - "command_list": "ctrl+p", - "agent_list": "a", - "agent_cycle": "tab", - "agent_cycle_reverse": "shift+tab", - "input_clear": "ctrl+c", - "input_paste": "ctrl+v", - "input_submit": "return", - "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", - "input_move_left": "left,ctrl+b", - "input_move_right": "right,ctrl+f", - "input_move_up": "up", - "input_move_down": "down", - "input_select_left": "shift+left", - "input_select_right": "shift+right", - "input_select_up": "shift+up", - "input_select_down": "shift+down", - "input_line_home": "ctrl+a", - "input_line_end": "ctrl+e", - "input_select_line_home": "ctrl+shift+a", - "input_select_line_end": "ctrl+shift+e", - "input_visual_line_home": "alt+a", - "input_visual_line_end": "alt+e", - "input_select_visual_line_home": "alt+shift+a", - "input_select_visual_line_end": "alt+shift+e", - "input_buffer_home": "home", - "input_buffer_end": "end", - "input_select_buffer_home": "shift+home", - "input_select_buffer_end": "shift+end", - "input_delete_line": "ctrl+shift+d", - "input_delete_to_line_end": "ctrl+k", - "input_delete_to_line_start": "ctrl+u", - "input_backspace": "backspace,shift+backspace", - "input_delete": "ctrl+d,delete,shift+delete", - "input_undo": "ctrl+-,super+z", - "input_redo": "ctrl+.,super+shift+z", - "input_word_forward": "alt+f,alt+right,ctrl+right", - "input_word_backward": "alt+b,alt+left,ctrl+left", - "input_select_word_forward": "alt+shift+f,alt+shift+right", - "input_select_word_backward": "alt+shift+b,alt+shift+left", - "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", - "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", - "history_previous": "up", - "history_next": "down", - "terminal_suspend": "ctrl+z", - "terminal_title_toggle": "none", - "tips_toggle": "h", - "display_thinking": "none" - } -} -``` +The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution. -:::note -On Windows, the defaults for `input_undo` and `terminal_suspend` are different: - -- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). -- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. - ::: +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. --- ## Leader key -OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal. +OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal. -By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. +By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. -You don't need to use a leader key for your keybinds but we recommend doing so. - -Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `\down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. +You do not need to use a leader key, but we recommend doing so. --- -## Disable keybind +## Minimal example -You can disable a keybind by adding the key to `tui.json` with a value of "none". +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p", + "session.new": "n", + "session.list": "l" + }, + "session": { + "session.compact": "c", + "session.undo": "u", + "session.redo": "r" + }, + "input": { + "input.submit": "return", + "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] + } + } + } +} +``` + +--- + +## Keymap structure + +`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. + +| Field | Description | +| ----- | ----------- | +| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | +| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | +| `sections` | A map of TUI areas to command bindings. | + +--- + +## Binding values + +A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "sections": { + "session": { + "session.compact": "none", + "session.export": "x,ctrl+shift+x", + "session.copy": ["y", "ctrl+shift+c"] + } + } + } +} +``` + +For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "sections": { + "prompt": { + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + } + } + } + } +} +``` + +--- + +## Complete keymap reference + +This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p", + "session.list": "l", + "session.new": "n", + "model.list": "m", + "model.cycle_recent": "f2", + "model.cycle_recent_reverse": "shift+f2", + "model.cycle_favorite": "none", + "model.cycle_favorite_reverse": "none", + "agent.list": "a", + "mcp.list": "none", + "agent.cycle": "tab", + "agent.cycle.reverse": "shift+tab", + "variant.cycle": "ctrl+t", + "variant.list": "none", + "provider.connect": "none", + "console.org.switch": "none", + "opencode.status": "s", + "theme.switch": "t", + "theme.switch_mode": "none", + "theme.mode.lock": "none", + "help.show": "none", + "docs.open": "none", + "app.exit": "ctrl+c,ctrl+d,q", + "app.debug": "none", + "app.console": "none", + "app.heap_snapshot": "none", + "app.toggle.animations": "none", + "app.toggle.file_context": "none", + "app.toggle.diffwrap": "none", + "app.toggle.paste_summary": "none", + "app.toggle.session_directory_filter": "none", + "terminal.suspend": "ctrl+z", + "terminal.title.toggle": "none" + }, + "session": { + "session.share": "none", + "session.rename": "ctrl+r", + "session.timeline": "g", + "session.fork": "none", + "session.compact": "c", + "session.unshare": "none", + "session.undo": "u", + "session.redo": "r", + "session.sidebar.toggle": "b", + "session.toggle.conceal": "h", + "session.toggle.timestamps": "none", + "session.toggle.thinking": "none", + "session.toggle.actions": "none", + "session.toggle.scrollbar": "none", + "session.toggle.generic_tool_output": "none", + "session.page.up": "pageup,ctrl+alt+b", + "session.page.down": "pagedown,ctrl+alt+f", + "session.line.up": "ctrl+alt+y", + "session.line.down": "ctrl+alt+e", + "session.half.page.up": "ctrl+alt+u", + "session.half.page.down": "ctrl+alt+d", + "session.first": "ctrl+g,home", + "session.last": "ctrl+alt+g,end", + "session.messages_last_user": "none", + "session.message.next": "none", + "session.message.previous": "none", + "messages.copy": "y", + "session.copy": "none", + "session.export": "x", + "session.child.first": "down", + "session.parent": "up", + "session.child.next": "right", + "session.child.previous": "left" + }, + "prompt": { + "prompt.submit": "none", + "prompt.editor": "e", + "prompt.editor_context.clear": "none", + "prompt.skills": "none", + "prompt.stash": "none", + "prompt.stash.pop": "none", + "prompt.stash.list": "none", + "workspace.set": "none", + "session.interrupt": "escape", + "prompt.clear": "ctrl+c", + "prompt.paste": { + "key": "ctrl+v", + "preventDefault": false + }, + "prompt.history.previous": "up", + "prompt.history.next": "down" + }, + "autocomplete": { + "prompt.autocomplete.prev": "up,ctrl+p", + "prompt.autocomplete.next": "down,ctrl+n", + "prompt.autocomplete.hide": "escape", + "prompt.autocomplete.select": "return", + "prompt.autocomplete.complete": "tab" + }, + "input": { + "input.submit": "return", + "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input.move.left": "left,ctrl+b", + "input.move.right": "right,ctrl+f", + "input.move.up": "up", + "input.move.down": "down", + "input.select.left": "shift+left", + "input.select.right": "shift+right", + "input.select.up": "shift+up", + "input.select.down": "shift+down", + "input.line.home": "ctrl+a", + "input.line.end": "ctrl+e", + "input.select.line.home": "ctrl+shift+a", + "input.select.line.end": "ctrl+shift+e", + "input.visual.line.home": "alt+a", + "input.visual.line.end": "alt+e", + "input.select.visual.line.home": "alt+shift+a", + "input.select.visual.line.end": "alt+shift+e", + "input.buffer.home": "home", + "input.buffer.end": "end", + "input.select.buffer.home": "shift+home", + "input.select.buffer.end": "shift+end", + "input.delete.line": "ctrl+shift+d", + "input.delete.to.line.end": "ctrl+k", + "input.delete.to.line.start": "ctrl+u", + "input.backspace": "backspace,shift+backspace", + "input.delete": "ctrl+d,delete,shift+delete", + "input.undo": "ctrl+-,super+z", + "input.redo": "ctrl+.,super+shift+z", + "input.word.forward": "alt+f,alt+right,ctrl+right", + "input.word.backward": "alt+b,alt+left,ctrl+left", + "input.select.word.forward": "alt+shift+f,alt+shift+right", + "input.select.word.backward": "alt+shift+b,alt+shift+left", + "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete", + "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace", + "input.select.all": "super+a" + }, + "dialog_select": { + "dialog.select.prev": "up,ctrl+p", + "dialog.select.next": "down,ctrl+n", + "dialog.select.page_up": "pageup", + "dialog.select.page_down": "pagedown", + "dialog.select.home": "home", + "dialog.select.end": "end", + "dialog.select.submit": "return" + }, + "dialog_actions": { + "dialog.action.toggle": "space", + "dialog.action.delete": "ctrl+d", + "dialog.action.rename": "ctrl+r" + }, + "model": { + "model.dialog.provider": "ctrl+a", + "model.dialog.favorite": "ctrl+f" + }, + "permission": { + "permission.reject.cancel": "ctrl+c,ctrl+d,q", + "permission.prompt.escape": "ctrl+c,ctrl+d,q", + "permission.prompt.fullscreen": "ctrl+f" + }, + "question": { + "question.reject": "ctrl+c,ctrl+d,q", + "question.edit.clear": "ctrl+c" + }, + "plugins": { + "plugins.list": "none", + "plugins.install": "none", + "plugin.dialog.install": "shift+i" + }, + "home_tips": { + "tips.toggle": "h" + } + } + } +} +``` + +--- + +## Legacy keybinds + +`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. + +Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "session_compact": "none" + "command_list": "ctrl+p", + "session_new": "n", + "session_compact": "c" } } ``` +:::note +On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`: + +- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). +- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. +::: + --- ## Desktop prompt shortcuts diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 73ecce93b5..99e9aa752b 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic /help ``` -Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds). +Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds). Here are all available slash commands: @@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keybinds": { - "leader": "ctrl+x" + "keymap": { + "leader": "ctrl+x", + "leader_timeout": 2000, + "sections": { + "global": { + "command.palette.show": "ctrl+p" + } + } }, "scroll_speed": 3, "scroll_acceleration": { @@ -367,10 +373,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. +`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. + ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. diff --git a/packages/opencode/script/upgrade-opentui.ts b/script/upgrade-opentui.ts similarity index 63% rename from packages/opencode/script/upgrade-opentui.ts rename to script/upgrade-opentui.ts index 615a407745..3fc194e167 100644 --- a/packages/opencode/script/upgrade-opentui.ts +++ b/script/upgrade-opentui.ts @@ -9,29 +9,30 @@ if (!raw) { } const ver = raw.replace(/^v/, "") -const root = path.resolve(import.meta.dir, "../../..") +const root = path.resolve(import.meta.dir, "..") const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"]) -const keys = ["@opentui/core", "@opentui/solid"] as const +const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter( (file) => !file.split("/").some((part) => skip.has(part)), ) -const set = (cur: string) => { +const setVersion = (cur: string) => { + if (cur === "catalog:" || cur.startsWith("workspace:")) return cur if (cur.startsWith(">=")) return `>=${ver}` if (cur.startsWith("^")) return `^${ver}` if (cur.startsWith("~")) return `~${ver}` return ver } -const edit = (obj: unknown) => { +const editDeps = (obj: unknown) => { if (!obj || typeof obj !== "object") return false const map = obj as Record return keys .map((key) => { const cur = map[key] if (typeof cur !== "string") return false - const next = set(cur) + const next = setVersion(cur) if (next === cur) return false map[key] = next return true @@ -39,13 +40,31 @@ const edit = (obj: unknown) => { .some(Boolean) } +const editCatalog = (obj: unknown) => { + if (!obj || typeof obj !== "object") return false + const map = obj as Record + return keys + .map((key) => { + const cur = map[key] + if (typeof cur !== "string" || cur === ver) return false + map[key] = ver + return true + }) + .some(Boolean) +} + const out = ( await Promise.all( files.map(async (rel) => { const file = path.join(root, rel) const txt = await Bun.file(file).text() const json = JSON.parse(txt) - const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean) + const hit = [ + editCatalog(json.workspaces?.catalog), + editDeps(json.dependencies), + editDeps(json.devDependencies), + editDeps(json.peerDependencies), + ].some(Boolean) if (!hit) return null await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`) return rel From 2c17e3a4db61769da539c06e27522beb2f2946d0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 18:36:42 +0000 Subject: [PATCH 061/812] chore: generate --- .../cli/cmd/tui/component/dialog-provider.tsx | 3 +- .../cli/cmd/tui/component/prompt/index.tsx | 15 +++++----- .../cmd/tui/config/legacy-keymap-transform.ts | 14 ++++++++-- .../src/cli/cmd/tui/config/tui-schema.ts | 7 ++++- .../opencode/src/cli/cmd/tui/config/tui.ts | 7 +---- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- .../cli/cmd/tui/routes/session/question.tsx | 6 +--- packages/opencode/test/config/tui.test.ts | 28 ++++++++++++------- packages/web/src/content/docs/keybinds.mdx | 10 +++---- 9 files changed, 54 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index db7cf1bb0a..a03ac7cac2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -244,7 +244,8 @@ function AutoMethod(props: AutoMethodProps) { { key: "c", cmd: () => { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + const code = + props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 71fb256a7f..898d14e979 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -60,12 +60,7 @@ import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" -import { - useBindings, - useCommandShortcut, - useLeaderActive, - useOpencodeKeymap, -} from "../../keymap" +import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { @@ -890,7 +885,13 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0 + return ( + inputTarget() !== undefined && + !props.disabled && + store.mode === "normal" && + !auto()?.visible && + input?.visualCursor.offset === 0 + ) })(), bindings: [ { diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts index c0c621862e..4b266a4ecc 100644 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts @@ -46,7 +46,12 @@ const inputCommands = { input_select_all: "input.select.all", } as const satisfies Partial> -function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue | undefined) { +function add( + config: SectionsConfig, + section: KeymapSection, + command: string, + binding: BindingValue | undefined, +) { if (binding === undefined) return config[section] ??= {} config[section][command] = binding @@ -154,7 +159,12 @@ export function create(keybinds: LegacyKeybinds): KeymapConfigInput { add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) - add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"])) + add( + config, + "dialog_actions", + "dialog.action.toggle", + combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]), + ) add(config, "model", "model.dialog.provider", keybinds.model_provider_list) add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 400eb38528..74e1b696f8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -293,7 +293,12 @@ export function keymapBindingDefaults(input: { section: string; binding: Readonl export const KeymapConfig = z .object({ leader: z.string().prefault("ctrl+x"), - leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"), + leader_timeout: z + .number() + .int() + .positive() + .prefault(KeymapLeaderTimeoutDefault) + .describe("Leader key timeout in milliseconds"), sections: KeymapSections, }) .strict() diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 095bc2c882..429d7e5c1c 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -23,12 +23,7 @@ import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { LegacyKeymapTransform } from "./legacy-keymap-transform" -import { - KeymapSectionNames, - keymapBindingDefaults, - type KeymapInfo, - type KeymapSection, -} from "./tui-schema" +import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema" const log = Log.create({ service: "tui.config" }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 81df918059..9ba300ea14 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1101,8 +1101,7 @@ export function Session() { > {revert()!.reverted.length} message reverted - {redoShortcut()} or /redo to - restore + {redoShortcut()} or /redo to restore diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 617ede6395..811db7e82f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -220,11 +220,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { }, }, ...(confirm() - ? [ - { key: "return", cmd: () => submit() }, - { key: "escape", cmd: () => reject() }, - ...sections.question, - ] + ? [{ key: "return", cmd: () => submit() }, { key: "escape", cmd: () => reject() }, ...sections.question] : [ ...Array.from({ length: max }, (_, index) => ({ key: String(index + 1), diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 5acc3d84fa..5adff22422 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -428,16 +428,20 @@ test("resolves semantic keymap sections", async () => { expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( + "ctrl+j", + ) + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( + "ctrl+t", + ) expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugin.dialog.install", ]) - expect( - (config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, - ).toBe("Plugins") + expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( + "Plugins", + ) expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) }) @@ -477,8 +481,12 @@ test("legacy keybinds transform into semantic keymap sections", async () => { ]) expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j") - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t") + expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( + "ctrl+j", + ) + expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( + "ctrl+t", + ) expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") @@ -486,9 +494,9 @@ test("legacy keybinds transform into semantic keymap sections", async () => { expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugin.dialog.install", ]) - expect( - (config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group, - ).toBe("Plugins") + expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( + "Plugins", + ) expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ "plugins.list", ]) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index a137aef37f..599945428e 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -55,11 +55,11 @@ You do not need to use a leader key, but we recommend doing so. `keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. -| Field | Description | -| ----- | ----------- | -| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | +| Field | Description | +| ---------------- | --------------------------------------------------------------------------------------------------- | +| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | | `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | -| `sections` | A map of TUI areas to command bindings. | +| `sections` | A map of TUI areas to command bindings. | --- @@ -310,7 +310,7 @@ On native Windows, the defaults for undo and terminal suspend are different for - `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). - `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. -::: + ::: --- From cc6dd5321c393beca7dc97c957bb207898d41dd8 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 20:36:19 +0200 Subject: [PATCH 062/812] chore: improve variant parsing for zen --- .../src/routes/zen/go/v1/chat/completions.ts | 3 ++- .../app/src/routes/zen/go/v1/messages.ts | 3 ++- .../app/src/routes/zen/util/variant.ts | 21 +++++++++++++++++++ .../app/src/routes/zen/v1/chat/completions.ts | 3 ++- .../console/app/src/routes/zen/v1/messages.ts | 3 ++- .../app/src/routes/zen/v1/models/[model].ts | 3 ++- .../app/src/routes/zen/v1/responses.ts | 3 ++- 7 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 packages/console/app/src/routes/zen/util/variant.ts diff --git a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts index a5cf41f8f0..71fb8f2e6d 100644 --- a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts index e66f3658c6..d356b0bf53 100644 --- a/packages/console/app/src/routes/zen/go/v1/messages.ts +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "lite", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.effort, + parseVariant: (url: string, body: any) => parseAnthropicVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/util/variant.ts b/packages/console/app/src/routes/zen/util/variant.ts new file mode 100644 index 0000000000..63464397f9 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/variant.ts @@ -0,0 +1,21 @@ +export function parseAnthropicVariant(body: any) { + const effort = body.effort ?? body.output_config?.effort ?? body.outputConfig?.effort ?? body.thinking?.effort + if (effort) return effort + + const budget = body.thinking?.budget_tokens ?? body.thinking?.budgetTokens + if (body.thinking?.type !== "enabled" || typeof budget !== "number") return undefined + return budget > 16_000 ? "max" : "high" +} + +export function parseGoogleVariant(body: any) { + const thinkingConfig = body.generationConfig?.thinkingConfig ?? body.thinkingConfig + if (thinkingConfig?.thinkingLevel) return thinkingConfig.thinkingLevel + + const budget = thinkingConfig?.thinkingBudget ?? thinkingConfig?.thinking_budget + if (typeof budget !== "number" || budget <= 0) return undefined + return budget > 16_000 ? "max" : "high" +} + +export function parseOpenAiVariant(body: any) { + return body.reasoningEffort ?? body.reasoning_effort ?? body.reasoning?.effort +} diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index a55d743565..745e0c2182 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoningEffort ?? body.reasoning_effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index f023d4f9ac..876a16029e 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.effort, + parseVariant: (url: string, body: any) => parseAnthropicVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index bfe6e86540..372f666761 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseGoogleVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", - parseVariant: (url: string, body: any) => body.thinkingLevel, + parseVariant: (url: string, body: any) => parseGoogleVariant(body), parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index 539b2fdad0..b82735817f 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -1,5 +1,6 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" +import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { @@ -7,7 +8,7 @@ export function POST(input: APIEvent) { modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => body.reasoning?.effort, + parseVariant: (url: string, body: any) => parseOpenAiVariant(body), parseIsStream: (url: string, body: any) => !!body.stream, }) } From e691e8f2746f9b66e70a22092c5c69381d7e3238 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 18:55:24 +0000 Subject: [PATCH 063/812] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 078b600d05..3db79cde1d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=", - "aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=", - "aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=", - "x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0=" + "x86_64-linux": "sha256-qzsDP+XJJtBv1Ta/RLhrgEk5Tm8qjN04Bj+KxLU2TAs=", + "aarch64-linux": "sha256-Kntdz/NtpE3H1oe1eAC6Yr2L2hSzFr+e9CaiDaE5NHw=", + "aarch64-darwin": "sha256-5+9JW70lwXuUPWjvKlQZ4ZRPNylBfGk2DX7J6Ya0Sgc=", + "x86_64-darwin": "sha256-iCY9VrpdEpYw/Hw8eY+CWHnJTWNpQYgkbMxx9BD4fTY=" } } From 2a1305f2315d8d0625a75b4627cc6c8292abb486 Mon Sep 17 00:00:00 2001 From: vimtor Date: Thu, 7 May 2026 21:07:19 +0200 Subject: [PATCH 064/812] chore: increase alerting threshold --- infra/monitoring.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index aad090aa80..313e6c1dd4 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -100,7 +100,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { queryJson: modelHttpErrorsQuery("go"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -119,7 +119,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { queryJson: modelHttpErrorsQuery("zen"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -138,7 +138,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -157,7 +157,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.8, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, From 22e64cac67f58c3371a93a8af64c337138f73684 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 15:27:36 -0400 Subject: [PATCH 065/812] sync: cleanup --- packages/console/app/src/routes/debug/index.ts | 13 ------------- packages/console/core/sst-env.d.ts | 4 ++-- packages/console/function/sst-env.d.ts | 4 ++-- packages/console/resource/sst-env.d.ts | 4 ++-- packages/enterprise/sst-env.d.ts | 4 ++-- packages/function/sst-env.d.ts | 4 ++-- sst-env.d.ts | 4 ++-- 7 files changed, 12 insertions(+), 25 deletions(-) delete mode 100644 packages/console/app/src/routes/debug/index.ts diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts deleted file mode 100644 index 4bfb633944..0000000000 --- a/packages/console/app/src/routes/debug/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { APIEvent } from "@solidjs/start/server" -import { json } from "@solidjs/router" -import { Database } from "@opencode-ai/console-core/drizzle/index.js" -import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" - -export async function GET(_evt: APIEvent) { - return json({ - data: await Database.use(async (tx) => { - const result = await tx.$count(UserTable) - return result - }), - }) -} diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 9680a53aab..bc56bd789d 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 9680a53aab..bc56bd789d 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 9680a53aab..bc56bd789d 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 9680a53aab..bc56bd789d 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 9680a53aab..bc56bd789d 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index e75c54d056..52702acd7c 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "HoneycombWebhookSecret": { - "type": "random.index/randomPassword.RandomPassword" + "INCIDENT_WEBHOOK_SIGNING_SECRET": { + "type": "sst.sst.Secret" "value": string } "LogProcessor": { From f5d0371efefcc05cd296b0d65b088123dee860dd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 16:53:24 -0500 Subject: [PATCH 066/812] tui: go plan payg msg (#26248) --- ...-go-upsell.tsx => dialog-retry-action.tsx} | 94 +++++++------- .../src/cli/cmd/tui/routes/session/index.tsx | 6 +- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 4 + packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/retry.ts | 117 ++++++++++++++---- packages/opencode/src/session/status.ts | 8 ++ packages/opencode/test/session/retry.test.ts | 80 ++++++++++-- .../test/session/schema-decoding.test.ts | 15 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 6 + script/zen-limit-server.ts | 37 ++++++ 10 files changed, 285 insertions(+), 83 deletions(-) rename packages/opencode/src/cli/cmd/tui/component/{dialog-go-upsell.tsx => dialog-retry-action.tsx} (58%) create mode 100644 script/zen-limit-server.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx similarity index 58% rename from packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx rename to packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 3a1fd97b2c..9dad1b4561 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -8,32 +8,36 @@ import { GoLogo } from "./logo" import { BgPulse, type BgPulseMask } from "./bg-pulse" import { useBindings } from "../keymap" -const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 -export type DialogGoUpsellProps = { +export type DialogRetryActionProps = { + title: string + message: string + label: string + link?: string onClose?: (dontShowAgain?: boolean) => void } -function subscribe(props: DialogGoUpsellProps, dialog: ReturnType) { - open(GO_URL).catch(() => {}) +function runAction(props: DialogRetryActionProps, dialog: ReturnType) { + if (props.link) open(props.link).catch(() => {}) props.onClose?.() dialog.clear() } -function dismiss(props: DialogGoUpsellProps, dialog: ReturnType) { +function dismiss(props: DialogRetryActionProps, dialog: ReturnType) { props.onClose?.(true) dialog.clear() } -export function DialogGoUpsell(props: DialogGoUpsellProps) { +export function DialogRetryAction(props: DialogRetryActionProps) { const dialog = useDialog() const { theme } = useTheme() const fg = selectedForeground(theme) - const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe") + const [selected, setSelected] = createSignal<"dismiss" | "action">("action") const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() const [masks, setMasks] = createSignal([]) + const showGoTreatment = () => props.link === "https://opencode.ai/go" let content: BoxRenderable | undefined let logoBox: BoxRenderable | undefined let headingBox: BoxRenderable | undefined @@ -41,11 +45,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { let buttonsBox: BoxRenderable | undefined const sync = () => { - if (!content || !logoBox) return - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) + if (!content) return + if (logoBox) { + setCenter({ + x: logoBox.x - content.x + logoBox.width / 2, + y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, + }) + } const next: BgPulseMask[] = [] const baseY = PAD_TOP_OUTER for (const b of [headingBox, descBox, buttonsBox]) { @@ -75,20 +81,20 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { bindings: [ { key: "left", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "right", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "tab", - cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")), + cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")), }, { key: "return", cmd: () => { - if (selected() === "subscribe") subscribe(props, dialog) + if (selected() === "action") runAction(props, dialog) else dismiss(props, dialog) }, }, @@ -97,33 +103,34 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { return ( (content = item)}> - - - - + {showGoTreatment() ? ( + + + + ) : null} + (headingBox = item)} flexDirection="row" justifyContent="space-between"> - Free limit reached + {props.title} dialog.clear()}> esc (descBox = item)} gap={0}> - - Subscribe to - - OpenCode Go - - for reliable access to the - - best open-source models, starting at $5/month. + {props.message} - - (logoBox = item)}> - - - + + {showGoTreatment() ? ( + (logoBox = item)} alignItems="center"> + + + ) : null} + {props.link ? ( + + + + ) : null} (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> setSelected("subscribe")} - onMouseUp={() => subscribe(props, dialog)} + backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)} + onMouseOver={() => setSelected("action")} + onMouseUp={() => runAction(props, dialog)} > - subscribe + {props.label} @@ -160,10 +167,13 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) { ) } -DialogGoUpsell.show = (dialog: DialogContext) => { +DialogRetryAction.show = ( + dialog: DialogContext, + props: Pick, +) => { return new Promise((resolve) => { dialog.replace( - () => resolve(dontShow ?? false)} />, + () => resolve(dontShow ?? false)} />, () => resolve(false), ) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9ba300ea14..d2b50c32f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -85,7 +85,7 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" -import { DialogGoUpsell } from "../../component/dialog-go-upsell" +import { DialogRetryAction } from "../../component/dialog-retry-action" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" @@ -260,7 +260,7 @@ export function Session() { event.on("session.status", (evt) => { if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.status.type !== "retry") return - if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return + if (!evt.properties.status.action) return if (dialog.stack.length > 0) return const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) @@ -268,7 +268,7 @@ export function Session() { if (kv.get(GO_UPSELL_DONT_SHOW)) return - void DialogGoUpsell.show(dialog).then((dontShowAgain) => { + void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 3b328e478d..01c4b6e713 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,8 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + width?: number | "auto" | `${number}%` + wrapMode?: "word" | "none" } /** @@ -18,6 +20,8 @@ export function Link(props: LinkProps) { return ( { open(props.href).catch(() => {}) }} diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f22da92927..66a2d47975 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -717,6 +717,7 @@ export const layer: Layer.Layer< type: "retry", attempt: info.attempt, message: info.message, + action: info.action, next: info.next, }) }, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index e81e197375..6a14dfc35b 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -5,9 +5,19 @@ import { iife } from "@/util/iife" export type Err = ReturnType -// This exported message is shared with the TUI upsell detector. Matching on a -// literal error string kind of sucks, but it is the simplest for now. -export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go" +export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" +export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" +export const GO_UPSELL_URL = "https://opencode.ai/go" + +export type Retryable = { + message: string + action?: { + title: string + message: string + label: string + link?: string + } +} export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 @@ -59,8 +69,49 @@ export function retryable(error: Err) { // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined - if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE - return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message + if (error.data.responseBody?.includes("FreeUsageLimitError")) { + return { + message: GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: GO_UPSELL_URL, + }, + } + } + if (error.data.responseBody?.includes("GoUsageLimitError")) { + const body = parseJSON(error.data.responseBody) + const workspace = str(body?.metadata?.workspace) + const limit = str(body?.metadata?.limit) + const resetAt = num(body?.metadata?.resetAt) + const resetIn = iife(() => { + if (resetAt === undefined) return "" + const seconds = Math.max(0, Math.ceil(resetAt)) + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + const minutes = Math.ceil((seconds % 3_600) / 60) + const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}` + + if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day") + if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") + return minutes > 0 ? unit(minutes, "minute") : "less than a minute" + }) + return { + message: PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + limit && resetIn + ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` + : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", + label: "enable PAYG", + ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + }, + } + } + return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } // Check for rate limit patterns in plain text error messages @@ -72,50 +123,66 @@ export function retryable(error: Err) { lower.includes("rate limit") || lower.includes("too many requests") ) { - return msg + return { message: msg } } } - const json = iife(() => { - try { - if (typeof error.data?.message === "string") { - const parsed = JSON.parse(error.data.message) - return parsed - } - - return JSON.parse(error.data.message) - } catch { - return undefined - } - }) + const json = parseJSON(error.data?.message) if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" if (json.type === "error" && json.error?.type === "too_many_requests") { - return "Too Many Requests" + return { message: "Too Many Requests" } } if (code.includes("exhausted") || code.includes("unavailable")) { - return "Provider is overloaded" + return { message: "Provider is overloaded" } } if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return "Rate Limited" + return { message: "Rate Limited" } } return undefined } +function str(value: unknown) { + if (value === undefined || value === null) return "" + return String(value) +} + +function num(value: unknown) { + const parsed = Number.parseFloat(str(value)) + if (Number.isNaN(parsed)) return undefined + return parsed +} + +function parseJSON(value: unknown) { + return iife(() => { + try { + if (typeof value !== "string") return undefined + return JSON.parse(value) + } catch { + return undefined + } + }) +} + export function policy(opts: { parse: (error: unknown) => Err - set: (input: { attempt: number; message: string; next: number }) => Effect.Effect + set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const message = retryable(error) - if (!message) return Cause.done(meta.attempt) + const retry = retryable(error) + if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis - yield* opts.set({ attempt: meta.attempt, message, next: now + wait }) + yield* opts.set({ + attempt: meta.attempt, + message: retry.message, + action: retry.action, + next: now + wait, + }) return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] }) }), diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index a0e57afc22..1d6e96d935 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -15,6 +15,14 @@ export const Info = Schema.Union([ type: Schema.Literal("retry"), attempt: NonNegativeInt, message: Schema.String, + action: Schema.optional( + Schema.Struct({ + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: Schema.optional(Schema.String), + }), + ), next: NonNegativeInt, }), Schema.Struct({ diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 105c772d97..f65c403e68 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -118,12 +118,12 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toBe("Too Many Requests") + expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toBe("Provider is overloaded") + expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { @@ -146,19 +146,19 @@ describe("session.retry.retryable", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toBe(msg) + expect(SessionRetry.retryable(error)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -180,7 +180,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Internal server error") + expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +192,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Bad gateway") + expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +204,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBe("Service unavailable") + expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -230,7 +230,65 @@ describe("session.retry.retryable", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Response decompression failed") + expect(retryable).toEqual({ message: "Response decompression failed" }) + }) + + test("maps free limits to Go upsell action", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Free usage exceeded", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { type: "FreeUsageLimitError", message: "Free usage exceeded" }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.GO_UPSELL_MESSAGE, + action: { + title: "Free limit reached", + message: + "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + label: "subscribe", + link: SessionRetry.GO_UPSELL_URL, + }, + }) + }) + + test("maps Go subscription limits to workspace PAYG upsell", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: 19_380, + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error)).toEqual({ + message: SessionRetry.PAYG_UPSELL_MESSAGE, + action: { + title: "Go limit reached", + message: + "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", + label: "enable PAYG", + link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", + }, + }) }) }) @@ -283,7 +341,7 @@ describe("session.message-v2.fromError", () => { const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() - expect(retryable).toBe("Connection reset by server") + expect(retryable).toEqual({ message: "Connection reset by server" }) }) test("marks OpenAI 404 status codes as retryable", () => { @@ -321,6 +379,6 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") + expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index abe99dddc7..8bb94bdd8c 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -230,8 +230,19 @@ describe("SessionStatus.Info", () => { expect(SessionStatus.Info.zod.parse({ type: "idle" })).toEqual({ type: "idle" }) }) - test("retry carries attempt/message/next", () => { - const input = { type: "retry" as const, attempt: 1, message: "transient", next: 500 } + test("retry carries attempt/message/action/next", () => { + const input = { + type: "retry" as const, + attempt: 1, + message: "transient", + action: { + title: "Free limit reached", + message: "Subscribe to OpenCode Go.", + label: "subscribe", + link: "https://opencode.ai/go", + }, + next: 500, + } expect(decode(input)).toEqual(input) expect(SessionStatus.Info.zod.parse(input)).toEqual(input) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 175fe69e66..5a330c37b6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -266,6 +266,12 @@ export type SessionStatus = type: "retry" attempt: number message: string + action?: { + title: string + message: string + label: string + link?: string + } next: number } | { diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts new file mode 100644 index 0000000000..3be1b5e111 --- /dev/null +++ b/script/zen-limit-server.ts @@ -0,0 +1,37 @@ +const retryAfterSeconds = 15 * 60 + +// const response = { +// type: "error", +// error: { +// type: "FreeUsageLimitError", +// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", +// }, +// metadata: {}, +// } + +const response = { + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + limit: "5 hour", + resetAt: retryAfterSeconds, + }, +} + +Bun.serve({ + port: 4141, + fetch() { + return Response.json(response, { + status: 429, + headers: { + "retry-after": String(retryAfterSeconds), + }, + }) + }, +}) + +console.log("Zen limit repro server listening on http://localhost:4141") From 7ded0ec9e91f62e3f0fa9c0d058ac6e86cdb4cd8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 7 May 2026 21:54:31 +0000 Subject: [PATCH 067/812] chore: generate --- packages/opencode/src/session/retry.ts | 3 +-- packages/opencode/test/session/retry.test.ts | 6 ++---- packages/sdk/openapi.json | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 6a14dfc35b..a4ef5b7a8f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -74,8 +74,7 @@ export function retryable(error: Err) { message: GO_UPSELL_MESSAGE, action: { title: "Free limit reached", - message: - "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", link: GO_UPSELL_URL, }, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f65c403e68..8a4d6d6af0 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -250,8 +250,7 @@ describe("session.retry.retryable", () => { message: SessionRetry.GO_UPSELL_MESSAGE, action: { title: "Free limit reached", - message: - "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", + message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", link: SessionRetry.GO_UPSELL_URL, }, @@ -283,8 +282,7 @@ describe("session.retry.retryable", () => { message: SessionRetry.PAYG_UPSELL_MESSAGE, action: { title: "Go limit reached", - message: - "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", + message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", label: "enable PAYG", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 04c34e2dc1..fcd7a8547e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9401,6 +9401,25 @@ "message": { "type": "string" }, + "action": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "label": { + "type": "string" + }, + "link": { + "type": "string" + } + }, + "required": ["title", "message", "label"], + "additionalProperties": false + }, "next": { "type": "integer", "minimum": 0 From b8799be3c802693e431e7c48737d481290d7d385 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 09:58:16 +1000 Subject: [PATCH 068/812] feat(desktop): allow silent install and only user-wide scope (#26253) --- packages/desktop/electron-builder.config.ts | 4 ++-- packages/desktop/src/main/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index da734dc81d..986008c4f4 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -66,8 +66,8 @@ const getBase = (): Configuration => ({ verifyUpdateCodeSignature: false, }, nsis: { - oneClick: false, - allowToChangeInstallationDirectory: true, + oneClick: true, + perMachine: false, installerIcon: `resources/icons/icon.ico`, installerHeaderIcon: `resources/icons/icon.ico`, }, diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index f75cd719a2..52e45a702c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -444,7 +444,7 @@ async function installUpdate() { version: downloadedUpdateVersion, }) await killSidecar() - autoUpdater.quitAndInstall() + autoUpdater.quitAndInstall(true, true) } async function checkForUpdates(alertOnFail: boolean) { From e8ce5df414070c174239fbc90e1c4e8a6be5ee0f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 22:08:29 -0400 Subject: [PATCH 069/812] fix(tui): retain cleared prompt drafts (#26258) --- .../cli/cmd/tui/component/prompt/index.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 898d14e979..e165f75ac0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -94,6 +94,8 @@ const money = new Intl.NumberFormat("en-US", { currency: "USD", }) +const DRAFT_RETENTION_MIN_CHARS = 20 + function randomIndex(count: number) { if (count <= 0) return 0 return Math.floor(Math.random() * count) @@ -412,13 +414,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", hidden: true, run: () => { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + clearPrompt() dialog.clear() }, }, @@ -1356,6 +1352,22 @@ export function Prompt(props: PromptProps) { return } + function clearPrompt() { + if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + history.append({ + ...store.prompt, + mode: store.mode, + }) + } + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + const highlight = createMemo(() => { if (leader()) return theme.border if (store.mode === "shell") return theme.primary From 5c401673b2486bd3743fb27ab172051e0f336758 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 May 2026 04:39:42 +0200 Subject: [PATCH 070/812] improve go sub animation perf (#26251) --- .../cli/cmd/tui/component/bg-pulse-render.ts | 429 ++++++++++++++++++ .../src/cli/cmd/tui/component/bg-pulse.tsx | 189 ++++---- .../cmd/tui/component/dialog-retry-action.tsx | 98 ++-- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 2 + 4 files changed, 542 insertions(+), 176 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts new file mode 100644 index 0000000000..25b728f1e5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -0,0 +1,429 @@ +import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core" +import { go } from "@/cli/logo" + +const PERIOD = 4600 +const RINGS = 3 +const WIDTH = 3.8 +const TAIL = 9.5 +const AMP = 0.55 +const TAIL_AMP = 0.16 +const BREATH_AMP = 0.05 +const BREATH_SPEED = 0.0008 +// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks. +const PHASE_OFFSET = 0.29 +const LOGO_GAP = 1 +const LOGO_TOP_BIAS = -1 +const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0 +const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index]) +const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0 +const LOGO_HEIGHT = LOGO_LINES.length +const SPACE = " ".codePointAt(0)! +const TOP_HALF = "▀".codePointAt(0)! +const FULL_BLOCK = "█".codePointAt(0)! +const RING_SCALE = 1 / RINGS +const TAIL_SCALE = 1 / TAIL +const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3 + +const enum LogoCellKind { + Background, + Top, + ShadowTop, + Solid, + Char, +} + +type LogoTemplateCell = { + x: number + y: number + kind: LogoCellKind + charCode: number + attributes: number + topDist: number + bottomDist: number +} + +const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) => + Array.from(line) + .map((char, x) => { + if (char === " ") return + const kind = + char === "_" + ? LogoCellKind.Background + : char === "^" + ? LogoCellKind.Top + : char === "~" + ? LogoCellKind.ShadowTop + : char === "█" + ? LogoCellKind.Solid + : LogoCellKind.Char + return { + x, + y, + kind, + charCode: char.codePointAt(0) ?? SPACE, + attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0, + topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT), + bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT), + } + }) + .filter((cell): cell is LogoTemplateCell => !!cell), +) + +export type Rgb = [number, number, number] + +export type GoUpsellArtRenderOptions = { + deltaTime?: number + rgb?: boolean + cache?: boolean +} + +const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30)) +const CACHE_FRAMES_PER_RENDER = 1 + +export function toRgb(color: RGBA): Rgb { + const [r, g, b] = color.toInts() + return [r, g, b] +} + +function clamp(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) { + buffer[offset] = r + buffer[offset + 1] = g + buffer[offset + 2] = b + buffer[offset + 3] = a +} + +function mixChannel(base: number, overlay: number, alpha: number) { + return Math.round(base + (overlay - base) * clamp(alpha)) +} + +function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { + const p = clamp(primaryMix) + const q = clamp(peakMix) + const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) + const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q) + const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q) + writeRgb(buffer, offset, r, g, b) +} + +function sameRgb(a: Rgb, b: Rgb) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +export class GoUpsellArtPainter { + private panelRgb: Rgb = [0, 0, 0] + private primaryRgb: Rgb = [255, 255, 255] + private logoBaseRgb: Rgb = [180, 180, 180] + private elapsed = 0 + private distances = new Float32Array(0) + private edgeFalloff = new Float32Array(0) + private geometryWidth = 0 + private geometryHeight = 0 + private reach = 1 + private logoX = 0 + private logoY = 0 + private logoIndexes = new Int32Array(0) + private logoRgb: boolean | undefined + private pulsePeak = 0 + private pulsePrimary = 0 + private cacheDirty = true + private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = [] + private cacheBuildIndex = 0 + + setBackgroundPanel(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.panelRgb, next)) return false + this.panelRgb = next + this.invalidateCache() + return true + } + + setLogoBase(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.logoBaseRgb, next)) return false + this.logoBaseRgb = next + this.invalidateCache() + return true + } + + setPrimary(value: RGBA | Rgb | undefined) { + if (!value) return false + const next = value instanceof RGBA ? toRgb(value) : value + if (sameRgb(this.primaryRgb, next)) return false + this.primaryRgb = next + this.invalidateCache() + return true + } + + render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) { + const rgb = options.rgb === true + this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD + this.rebuildGeometry(frameBuffer, rgb) + if (options.cache !== false) { + this.drawCached(frameBuffer, rgb) + return + } + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + } + + private invalidateCache() { + this.cacheDirty = true + this.cacheBuildIndex = 0 + this.frameCache = [] + } + + private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) { + const width = frameBuffer.width + const height = frameBuffer.height + const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight + const logoTemplateChanged = this.logoRgb !== rgb + if (!geometryChanged && !logoTemplateChanged) return + + if (geometryChanged) { + this.geometryWidth = width + this.geometryHeight = height + this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2)) + this.logoY = Math.max( + 0, + Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS), + ) + + const centerX = this.logoX + LOGO_WIDTH / 2 + const centerY = this.logoY + LOGO_HEIGHT / 2 + this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL + this.distances = new Float32Array(width * height) + this.edgeFalloff = new Float32Array(width * height) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = y * width + x + const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2) + this.distances[index] = dist + this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2) + } + } + } + + this.logoRgb = rgb + this.invalidateCache() + this.rebuildCellTemplate(frameBuffer, rgb) + } + + private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) { + if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb) + if (this.cacheBuildIndex < CACHE_FRAME_COUNT) { + this.buildFrameCache(frameBuffer, rgb) + this.drawBackground(frameBuffer, this.elapsed) + this.drawLogo(frameBuffer, this.elapsed, rgb) + return + } + + const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT] + if (frame) { + frameBuffer.buffers.fg.set(frame.fg) + frameBuffer.buffers.bg.set(frame.bg) + } + } + + private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + this.frameCache = [] + this.cacheBuildIndex = 0 + this.rebuildCellTemplate(frameBuffer, rgb) + this.cacheDirty = false + } + + private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) { + const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER) + for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) { + const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD + this.drawBackground(frameBuffer, t) + this.drawLogo(frameBuffer, t, rgb) + this.frameCache.push({ + fg: new Uint16Array(frameBuffer.buffers.fg), + bg: new Uint16Array(frameBuffer.buffers.bg), + }) + } + } + + private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) { + const buffers = frameBuffer.buffers + buffers.char.fill(SPACE) + buffers.attributes.fill(0) + + if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) { + this.logoIndexes = new Int32Array(0) + return + } + + this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length) + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x + this.logoIndexes[i] = index + buffers.attributes[index] = cell.attributes + buffers.char[index] = + cell.kind === LogoCellKind.Background + ? SPACE + : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop + ? TOP_HALF + : cell.kind === LogoCellKind.Solid + ? rgb + ? TOP_HALF + : FULL_BLOCK + : cell.charCode + } + } + + private drawBackground(frameBuffer: OptimizedBuffer, t: number) { + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const distances = this.distances + const edgeFalloff = this.edgeFalloff + const baseR = this.panelRgb[0] + const baseG = this.panelRgb[1] + const baseB = this.panelRgb[2] + const deltaR = this.primaryRgb[0] - baseR + const deltaG = this.primaryRgb[1] - baseG + const deltaB = this.primaryRgb[2] - baseB + const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP + + const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1 + const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1 + const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const envelope2 = Math.sin(phase2 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2) + const head0 = phase0 * this.reach + const head1 = phase1 * this.reach + const head2 = phase2 * this.reach + + for (let index = 0; index < distances.length; index++) { + const dist = distances[index] + const delta0 = dist - head0 + const abs0 = delta0 < 0 ? -delta0 : delta0 + const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0 + const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0 + + const delta1 = dist - head1 + const abs1 = delta1 < 0 ? -delta1 : delta1 + const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0 + const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0 + + const delta2 = dist - head2 + const abs2 = delta2 < 0 ? -delta2 : delta2 + const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0 + const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0 + + const level = + (crest0 * AMP + tail0 * TAIL_AMP) * eased0 + + (crest1 * AMP + tail1 * TAIL_AMP) * eased1 + + (crest2 * AMP + tail2 * TAIL_AMP) * eased2 + const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index] + const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7 + const offset = index * 4 + const r = Math.round(baseR + deltaR * strength) + const g = Math.round(baseG + deltaG * strength) + const b = Math.round(baseB + deltaB * strength) + bg[offset] = fg[offset] = r + bg[offset + 1] = fg[offset + 1] = g + bg[offset + 2] = fg[offset + 2] = b + bg[offset + 3] = fg[offset + 3] = 255 + } + } + + private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) { + let peak = 0.04 + let primary = 0 + + const delta0 = dist - head0 + const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8)) + const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6)) + const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0 + peak += core0 * 0.65 * eased0 + primary += (soft0 * 0.16 + tail0 * 0.22) * eased0 + + const delta1 = dist - head1 + const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8)) + const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6)) + const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0 + peak += core1 * 0.65 * eased1 + primary += (soft1 * 0.16 + tail1 * 0.22) * eased1 + + this.pulsePeak = peak > 1 ? 1 : peak + this.pulsePrimary = primary > 1 ? 1 : primary + } + + private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) { + if (this.logoIndexes.length === 0) return + + const buffers = frameBuffer.buffers + const fg = buffers.fg + const bg = buffers.bg + const shadow: Rgb = [ + mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25), + mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25), + mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25), + ] + const phase0 = (t / PERIOD) % 1 + const phase1 = (t / PERIOD + 0.5) % 1 + const envelope0 = Math.sin(phase0 * Math.PI) + const envelope1 = Math.sin(phase1 * Math.PI) + const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0) + const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1) + const head0 = phase0 * LOGO_REACH + const head1 = phase1 * LOGO_REACH + + for (let i = 0; i < LOGO_TEMPLATE.length; i++) { + const cell = LOGO_TEMPLATE[i]! + const index = this.logoIndexes[i]! + const offset = index * 4 + this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1) + const topPeak = this.pulsePeak + const topPrimary = this.pulsePrimary + this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1) + const bottomPeak = this.pulsePeak + const bottomPrimary = this.pulsePrimary + + if (cell.kind === LogoCellKind.Background) { + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Top) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.ShadowTop) { + writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18) + continue + } + + if (cell.kind === LogoCellKind.Solid && rgb) { + writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak) + writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak) + continue + } + + writeLogoTint( + fg, + offset, + this.logoBaseRgb, + this.primaryRgb, + (topPrimary + bottomPrimary) / 2, + (topPeak + bottomPeak) / 2, + ) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 541ecea4e1..0482adea33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,130 +1,93 @@ -import { BoxRenderable, RGBA } from "@opentui/core" -import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js" +import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { extend, useRenderer } from "@opentui/solid" +import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" +import { GoUpsellArtPainter } from "./bg-pulse-render" -const PERIOD = 4600 -const RINGS = 3 -const WIDTH = 3.8 -const TAIL = 9.5 -const AMP = 0.55 -const TAIL_AMP = 0.16 -const BREATH_AMP = 0.05 -const BREATH_SPEED = 0.0008 -// Offset so bg ring emits from GO center at the moment the logo pulse peaks. -const PHASE_OFFSET = 0.29 - -export type BgPulseMask = { - x: number - y: number - width: number - height: number - pad?: number - strength?: number +type GoUpsellArtOptions = RenderableOptions & { + backgroundPanel?: RGBA + primary?: RGBA + logoBase?: RGBA } -export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) { - const { theme } = useTheme() - const [now, setNow] = createSignal(performance.now()) - const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 }) - let box: BoxRenderable | undefined +class GoUpsellArtRenderable extends FrameBufferRenderable { + private painter = new GoUpsellArtPainter() - const timer = setInterval(() => setNow(performance.now()), 50) - onCleanup(() => clearInterval(timer)) + constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) { + const width = typeof options.width === "number" ? options.width : 1 + const height = typeof options.height === "number" ? options.height : 1 + super(ctx, { + ...options, + width, + height, + live: options.live ?? true, + respectAlpha: false, + }) - const sync = () => { - if (!box) return - setSize({ width: box.width, height: box.height }) + if (options.width !== undefined && typeof options.width !== "number") this.width = options.width + if (options.height !== undefined && typeof options.height !== "number") this.height = options.height + this.painter.setBackgroundPanel(options.backgroundPanel) + this.painter.setPrimary(options.primary) + this.painter.setLogoBase(options.logoBase) } + set backgroundPanel(value: RGBA | undefined) { + if (this.painter.setBackgroundPanel(value)) this.requestRender() + } + + set logoBase(value: RGBA | undefined) { + if (this.painter.setLogoBase(value)) this.requestRender() + } + + set primary(value: RGBA | undefined) { + if (this.painter.setPrimary(value)) this.requestRender() + } + + protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void { + if (!this.visible || this.isDestroyed) return + + this.painter.render(this.frameBuffer, { + deltaTime, + rgb: this._ctx.capabilities?.rgb === true, + }) + super.renderSelf(buffer) + } +} + +declare module "@opentui/solid" { + interface OpenTUIComponents { + go_upsell_art: typeof GoUpsellArtRenderable + } +} + +extend({ go_upsell_art: GoUpsellArtRenderable }) + +export function BgPulse() { + const { theme } = useTheme() + const renderer = useRenderer() + let targetFps = renderer.targetFps + let maxFps = renderer.maxFps + onMount(() => { - sync() - box?.on("resize", sync) + targetFps = renderer.targetFps + maxFps = renderer.maxFps + renderer.targetFps = 30 + renderer.maxFps = 30 }) onCleanup(() => { - box?.off("resize", sync) - }) - - const grid = createMemo(() => { - const t = now() - const w = size().width - const h = size().height - if (w === 0 || h === 0) return [] as RGBA[][] - const cxv = props.centerX ?? w / 2 - const cyv = props.centerY ?? h / 2 - const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL - const ringStates = Array.from({ length: RINGS }, (_, i) => { - const offset = i / RINGS - const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1 - const envelope = Math.sin(phase * Math.PI) - const eased = envelope * envelope * (3 - 2 * envelope) - return { - head: phase * reach, - eased, - } - }) - const normalizedMasks = props.masks?.map((m) => { - const pad = m.pad ?? 2 - return { - left: m.x - pad, - right: m.x + m.width + pad, - top: m.y - pad, - bottom: m.y + m.height + pad, - pad, - strength: m.strength ?? 0.85, - } - }) - const rows = [] as RGBA[][] - for (let y = 0; y < h; y++) { - const row = [] as RGBA[] - for (let x = 0; x < w; x++) { - const dx = x + 0.5 - cxv - const dy = (y + 0.5 - cyv) * 2 - const dist = Math.hypot(dx, dy) - let level = 0 - for (const ring of ringStates) { - const delta = dist - ring.head - const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0 - const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0 - level += (crest * AMP + tail * TAIL_AMP) * ring.eased - } - const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2) - const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP - let maskAtten = 1 - if (normalizedMasks) { - for (const m of normalizedMasks) { - if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue - const inX = Math.min(x - m.left, m.right - x) - const inY = Math.min(y - m.top, m.bottom - y) - const edge = Math.min(inX / m.pad, inY / m.pad, 1) - const eased = edge * edge * (3 - 2 * edge) - const reduce = 1 - m.strength * eased - if (reduce < maskAtten) maskAtten = reduce - } - } - const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten) - row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7)) - } - rows.push(row) - } - return rows + renderer.targetFps = targetFps + renderer.maxFps = maxFps }) return ( - (box = item)} width="100%" height="100%"> - - {(row) => ( - - - {(color) => ( - - {" "} - - )} - - - )} - - + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx index 9dad1b4561..cbc8f0ef08 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx @@ -1,15 +1,16 @@ -import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" +import { RGBA, TextAttributes } from "@opentui/core" import open from "open" -import { createSignal, onCleanup, onMount } from "solid-js" +import { createSignal } from "solid-js" import { selectedForeground, useTheme } from "@tui/context/theme" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { Link } from "@tui/ui/link" -import { GoLogo } from "./logo" -import { BgPulse, type BgPulseMask } from "./bg-pulse" +import { BgPulse } from "./bg-pulse" import { useBindings } from "../keymap" +const GO_URL = "https://opencode.ai/go" const PAD_X = 3 const PAD_TOP_OUTER = 1 +const FOREGROUND_ALPHA = 186 export type DialogRetryActionProps = { title: string @@ -30,52 +31,18 @@ function dismiss(props: DialogRetryActionProps, dialog: ReturnType props.link === GO_URL + const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined) const [selected, setSelected] = createSignal<"dismiss" | "action">("action") - const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>() - const [masks, setMasks] = createSignal([]) - const showGoTreatment = () => props.link === "https://opencode.ai/go" - let content: BoxRenderable | undefined - let logoBox: BoxRenderable | undefined - let headingBox: BoxRenderable | undefined - let descBox: BoxRenderable | undefined - let buttonsBox: BoxRenderable | undefined - - const sync = () => { - if (!content) return - if (logoBox) { - setCenter({ - x: logoBox.x - content.x + logoBox.width / 2, - y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER, - }) - } - const next: BgPulseMask[] = [] - const baseY = PAD_TOP_OUTER - for (const b of [headingBox, descBox, buttonsBox]) { - if (!b) continue - next.push({ - x: b.x - content.x, - y: b.y - content.y + baseY, - width: b.width, - height: b.height, - pad: 2, - strength: 0.78, - }) - } - setMasks(next) - } - - onMount(() => { - sync() - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync) - }) - - onCleanup(() => { - for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) - }) useBindings(() => ({ bindings: [ @@ -102,37 +69,40 @@ export function DialogRetryAction(props: DialogRetryActionProps) { })) return ( - (content = item)}> + {showGoTreatment() ? ( - + ) : null} - - (headingBox = item)} flexDirection="row" justifyContent="space-between"> - + + + {props.title} - dialog.clear()}> + dialog.clear()}> esc - (descBox = item)} gap={0}> - {props.message} + + + {props.message} + - - {showGoTreatment() ? ( - (logoBox = item)} alignItems="center"> - + {props.link ? ( + showGoTreatment() ? ( + + - ) : null} - {props.link ? ( - + ) : ( + - ) : null} - - (buttonsBox = item)} flexDirection="row" justifyContent="space-between"> + ) + ) : ( + + )} + don't show again @@ -156,6 +127,7 @@ export function DialogRetryAction(props: DialogRetryActionProps) { > {props.label} diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 01c4b6e713..cfd78bc333 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -6,6 +6,7 @@ export interface LinkProps { href: string children?: JSX.Element | string fg?: RGBA + bg?: RGBA width?: number | "auto" | `${number}%` wrapMode?: "word" | "none" } @@ -20,6 +21,7 @@ export function Link(props: LinkProps) { return ( { From 6ff833a22bed0f087103e7969bfbb94736e62cb9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 02:40:47 +0000 Subject: [PATCH 071/812] chore: generate --- .../src/cli/cmd/tui/component/bg-pulse-render.ts | 9 ++++++++- packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts index 25b728f1e5..09a50ebe45 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse-render.ts @@ -100,7 +100,14 @@ function mixChannel(base: number, overlay: number, alpha: number) { return Math.round(base + (overlay - base) * clamp(alpha)) } -function writeLogoTint(buffer: Uint16Array, offset: number, base: Rgb, primary: Rgb, primaryMix: number, peakMix: number) { +function writeLogoTint( + buffer: Uint16Array, + offset: number, + base: Rgb, + primary: Rgb, + primaryMix: number, + peakMix: number, +) { const p = clamp(primaryMix) const q = clamp(peakMix) const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q) diff --git a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx index 0482adea33..e7b02f8ee3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx @@ -1,4 +1,10 @@ -import { FrameBufferRenderable, RGBA, type OptimizedBuffer, type RenderContext, type RenderableOptions } from "@opentui/core" +import { + FrameBufferRenderable, + RGBA, + type OptimizedBuffer, + type RenderContext, + type RenderableOptions, +} from "@opentui/core" import { extend, useRenderer } from "@opentui/solid" import { onCleanup, onMount } from "solid-js" import { tint, useTheme } from "@tui/context/theme" From db6a03882954f9f3a54cc14c20a8315c07e2a685 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 7 May 2026 22:55:48 -0400 Subject: [PATCH 072/812] sync --- packages/console/app/src/routes/zen/util/error.ts | 10 ++++++---- packages/console/app/src/routes/zen/util/handler.ts | 12 ++++++++---- packages/console/core/sst-env.d.ts | 4 ++-- packages/console/function/sst-env.d.ts | 4 ++-- packages/console/resource/sst-env.d.ts | 4 ++-- packages/enterprise/sst-env.d.ts | 4 ++-- packages/function/sst-env.d.ts | 4 ++-- sst-env.d.ts | 4 ++-- 8 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index 216b6564e7..d17741ff70 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -13,13 +13,15 @@ class LimitError extends Error { } export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} +export class BlackUsageLimitError extends LimitError {} -class SubscriptionUsageLimitError extends LimitError { +type LimitName = "5 hour" | "weekly" | "monthly" +export class GoUsageLimitError extends LimitError { workspace: string - constructor(message: string, workspace: string, retryAfter?: number) { + limitName: LimitName + constructor(message: string, workspace: string, limitName: LimitName, retryAfter?: number) { super(message, retryAfter) this.workspace = workspace + this.limitName = limitName } } -export class GoUsageLimitError extends SubscriptionUsageLimitError {} -export class BlackUsageLimitError extends SubscriptionUsageLimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 7cee86b47e..4b6fe5feb8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -415,8 +415,11 @@ export async function handler( message: error.message, }, metadata: - error instanceof GoUsageLimitError || error instanceof BlackUsageLimitError - ? { workspace: error.workspace } + error instanceof GoUsageLimitError + ? { + workspace: error.workspace, + limitName: error.limitName, + } : {}, }), { status: 429, headers }, @@ -710,7 +713,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -729,7 +731,6 @@ export async function handler( t("zen.api.error.subscriptionQuotaExceeded", { retryIn: formatRetryTime(result.resetInSec), }), - authInfo.workspaceID, result.resetInSec, ) } @@ -757,6 +758,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "weekly", result.resetInSec, ) } @@ -773,6 +775,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "monthly", result.resetInSec, ) } @@ -789,6 +792,7 @@ export async function handler( throw new GoUsageLimitError( t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), authInfo.workspaceID, + "5 hour", result.resetInSec, ) } diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index bc56bd789d..9680a53aab 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -91,8 +91,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "R2AccessKey": { diff --git a/sst-env.d.ts b/sst-env.d.ts index 52702acd7c..e75c54d056 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -114,8 +114,8 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "INCIDENT_WEBHOOK_SIGNING_SECRET": { - "type": "sst.sst.Secret" + "HoneycombWebhookSecret": { + "type": "random.index/randomPassword.RandomPassword" "value": string } "LogProcessor": { From 1cf8123bc6e17d759d651ef3ca493145adb23741 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:32:47 -0400 Subject: [PATCH 073/812] fix(provider): align GPT-5 reasoning variants (#26268) --- packages/opencode/src/provider/transform.ts | 92 ++++++++-- .../opencode/test/provider/transform.test.ts | 171 +++++++++++++++--- 2 files changed, 216 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd29e40822..7c0eaced26 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -500,6 +500,13 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] // OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). // Models released before it 400 on `reasoning_effort: "none"`, so we only expose @@ -513,17 +520,49 @@ const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04" // "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex". // Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ + +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream -// routed through it, e.g. cf-ai-gateway) model exposes. Returns null for models -// with no tunable effort knob (gpt-5-pro). Effort order: weakest to strongest. -function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | null { +// routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest +// to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() - if (id === "gpt-5-pro" || id === "openai/gpt-5-pro") return null - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return [...WIDELY_SUPPORTED_EFFORTS] - } + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ + // additionally accepts `xhigh`. Model pages list the supported subset. + if (versionedEfforts) return versionedEfforts const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none") @@ -531,6 +570,14 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | return efforts } +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "xhigh", "max"] @@ -577,8 +624,13 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + if (!id.includes("gpt") && !id.includes("gemini-3") && !id.includes("claude")) return {} + return Object.fromEntries( + (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [ + effort, + { reasoning: { effort } }, + ]), + ) case "ai-gateway-provider": { // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible @@ -589,7 +641,6 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) @@ -652,7 +703,9 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + return Object.fromEntries( + openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]), + ) case "@ai-sdk/github-copilot": if (model.id.includes("gemini")) { @@ -700,12 +753,11 @@ export function variants(model: Provider.Model): Record [ + (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined + ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [ effort, { reasoningEffort: effort, @@ -717,7 +769,6 @@ export function variants(model: Provider.Model): Record [ effort, @@ -1102,6 +1153,11 @@ export function smallOptions(model: Provider.Model) { model.api.npm === "@ai-sdk/github-copilot" ) { if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("-chat")) { + if (gpt5Version(model.api.id) === undefined) return { store: false } + return { store: false, reasoningEffort: "medium" } + } + if (model.api.id.includes("search-api")) return { store: false } if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) { return { store: false, reasoningEffort: "low" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c7a321d571..064313ec51 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2464,6 +2464,32 @@ describe("ProviderTransform.variants", () => { expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "openrouter", + api: { + id: testCase.id, + url: "https://openrouter.ai", + npm: "@openrouter/ai-sdk-provider", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } + test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => { const model = createMockModel({ id: "openrouter/gemini-3-5-pro", @@ -2651,6 +2677,32 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + for (const testCase of [ + { id: "openai/gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5-5-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-3-codex-max", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5-2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported OpenAI reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.id, + providerID: "gateway", + api: { + id: testCase.id, + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } }) describe("@ai-sdk/github-copilot", () => { @@ -2929,10 +2981,27 @@ describe("ProviderTransform.variants", () => { const result = ProviderTransform.variants(model) expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) }) + + for (const id of ["gpt-5-4", "gpt-5-5"]) { + test(`${id} does not add minimal effort`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id, + providerID: "azure", + api: { + id, + url: "https://azure.com", + npm: "@ai-sdk/azure", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + } }) describe("@ai-sdk/openai", () => { - test("gpt-5-pro returns empty object", () => { + test("gpt-5-pro returns only high effort", () => { const model = createMockModel({ id: "gpt-5-pro", providerID: "openai", @@ -2943,7 +3012,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["high"]) }) test("standard openai models return custom efforts with reasoningSummary", () => { @@ -2983,10 +3052,10 @@ describe("ProviderTransform.variants", () => { test("models after 2025-12-04 include 'xhigh' effort", () => { const model = createMockModel({ - id: "openai/gpt-5-chat", + id: "openai/gpt-5-reasoning", providerID: "openai", api: { - id: "gpt-5-chat", + id: "gpt-5-reasoning", url: "https://api.openai.com", npm: "@ai-sdk/openai", }, @@ -2996,20 +3065,38 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) }) - test("dotted gpt-5.x ids include 'minimal' (regression: matcher used to miss gpt-5.4)", () => { - const model = createMockModel({ - id: "gpt-5.4", - providerID: "openai", - api: { - id: "gpt-5.4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - release_date: "2026-03-05", + for (const testCase of [ + { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, + { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex", releaseDate: "2025-11-13", efforts: ["low", "medium", "high"] }, + { id: "gpt-5.1-codex-max", releaseDate: "2025-11-13", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.2-codex", releaseDate: "2025-12-11", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.3-codex-max", releaseDate: "2026-01-22", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-chat-latest", releaseDate: "2025-08-07", efforts: [] }, + { id: "gpt-5.1-chat-latest", releaseDate: "2025-11-13", efforts: ["medium"] }, + { id: "gpt-5.2-chat-latest", releaseDate: "2025-12-11", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: testCase.modelID ?? testCase.id, + providerID: "openai", + api: { + id: testCase.id, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + release_date: testCase.releaseDate, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - }) + } test("gpt-50 (lookalike) does not get gpt-5 family treatment", () => { const model = createMockModel({ @@ -3486,18 +3573,20 @@ describe("ProviderTransform.variants", () => { release_date: releaseDate, }) - test("openai gpt-5.4 includes xhigh effort (regression: variant=xhigh used to be silently ignored)", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.4", "2026-03-05")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - expect(Object.keys(result)).toContain("minimal") - }) - - test("openai gpt-5.2-codex includes xhigh", () => { - const result = ProviderTransform.variants(cfModel("openai/gpt-5.2-codex", "2025-12-11")) - expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) - }) + for (const testCase of [ + { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.2-codex", efforts: ["low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5.3-codex", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "openai/gpt-5-pro", efforts: ["high"] }, + { id: "openai/gpt-5.2-pro", efforts: ["medium", "high", "xhigh"] }, + { id: "openai/gpt-5-chat-latest", efforts: [] }, + { id: "openai/gpt-5.2-chat-latest", efforts: ["medium"] }, + ]) { + test(`${testCase.id} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants(cfModel(testCase.id, "2026-03-05")) + expect(Object.keys(result)).toEqual(testCase.efforts) + }) + } test("openai gpt-4o (no reasoning) returns empty", () => { const model = cfModel("openai/gpt-4o") @@ -3517,6 +3606,30 @@ describe("ProviderTransform.variants", () => { }) }) +describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { + const createModel = (apiId: string) => + ({ + id: `openai/${apiId}`, + providerID: "openai", + api: { + id: apiId, + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + }) as any + + for (const testCase of [ + { id: "gpt-5-chat-latest", options: { store: false } }, + { id: "gpt-5.1-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5.2-chat-latest", options: { store: false, reasoningEffort: "medium" } }, + { id: "gpt-5-search-api", options: { store: false } }, + ]) { + test(`${testCase.id} returns only supported small options`, () => { + expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { const createModel = (overrides: Partial = {}) => ({ From 114eeb21dc5af4649979463dfaa25471b3120468 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 03:33:55 +0000 Subject: [PATCH 074/812] chore: generate --- packages/opencode/test/provider/transform.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 064313ec51..3fdc226375 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3068,7 +3068,12 @@ describe("ProviderTransform.variants", () => { for (const testCase of [ { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.5", modelID: "gpt-5-5", releaseDate: "2026-04-23", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { + id: "gpt-5.5", + modelID: "gpt-5-5", + releaseDate: "2026-04-23", + efforts: ["none", "low", "medium", "high", "xhigh"], + }, { id: "gpt-5.4-pro", releaseDate: "2026-03-05", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5.5-pro", releaseDate: "2026-04-23", efforts: ["medium", "high", "xhigh"] }, { id: "gpt-5-codex", releaseDate: "2025-09-23", efforts: ["low", "medium", "high"] }, From 2ba9aa21961697bf9ff5de3b18becaabe56aefd7 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Thu, 7 May 2026 23:42:39 -0400 Subject: [PATCH 075/812] feat(desktop): working indicator on project sidebar (#26223) --- packages/app/src/pages/layout/sidebar-items.tsx | 12 +++++++++++- packages/app/src/pages/layout/sidebar-project.tsx | 10 +++++++++- .../cli/cmd/tui/component/dialog-session-list.tsx | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 296f035ce2..f27a9bb7a9 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -26,7 +26,12 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url return icon?.url } -export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { +export const ProjectIcon = (props: { + project: LocalProject + class?: string + notify?: boolean + working?: boolean +}): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() const permission = usePermission() @@ -65,6 +70,11 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti }} /> + +
    + +
    +
    ) } diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 2ba20092c5..58595c25b9 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -56,6 +56,7 @@ const ProjectTile = (props: { sidebarHovering: Accessor selected: Accessor active: Accessor + isWorking: Accessor overlay: Accessor suppressHover: Accessor dirs: Accessor @@ -143,7 +144,7 @@ const ProjectTile = (props: { }} onBlur={() => props.setOpen(false)} > - + @@ -301,6 +302,12 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const isWorking = createMemo(() => + dirs().some((directory) => { + const [store] = globalSync.child(directory, { bootstrap: false }) + return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry") + }), + ) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) @@ -313,6 +320,7 @@ export const SortableProject = (props: { sidebarHovering={props.ctx.sidebarHovering} selected={selected} active={active} + isWorking={isWorking} overlay={overlay} suppressHover={() => state.suppressHover} dirs={dirs} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 6d3322151a..542449f5df 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -154,7 +154,7 @@ export function DialogSessionList() { } const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" + const isWorking = status?.type === "busy" || status?.type === "retry" return { title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, From 319498e2fd4e22eb6d38bc5810c1c089cf709162 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 7 May 2026 23:43:42 -0400 Subject: [PATCH 076/812] fix(provider): constrain OpenAI deep research efforts (#26273) --- packages/opencode/src/provider/transform.ts | 1 + packages/opencode/test/provider/transform.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7c0eaced26..69a0d484f4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -554,6 +554,7 @@ function gpt5ChatReasoningEfforts(apiId: string) { // to strongest. function openaiReasoningEfforts(apiId: string, releaseDate: string) { const id = apiId.toLowerCase() + if (id.includes("deep-research")) return ["medium"] const chatEfforts = gpt5ChatReasoningEfforts(id) if (chatEfforts) return chatEfforts if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3fdc226375..25ed2aadc3 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3066,6 +3066,14 @@ describe("ProviderTransform.variants", () => { }) for (const testCase of [ + { id: "o1", releaseDate: "2024-12-17", efforts: ["low", "medium", "high"] }, + { id: "o1-pro", releaseDate: "2025-03-19", efforts: ["low", "medium", "high"] }, + { id: "o3", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-mini", releaseDate: "2025-01-31", efforts: ["low", "medium", "high"] }, + { id: "o3-pro", releaseDate: "2025-06-10", efforts: ["low", "medium", "high"] }, + { id: "o4-mini", releaseDate: "2025-04-16", efforts: ["low", "medium", "high"] }, + { id: "o3-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, + { id: "o4-mini-deep-research", releaseDate: "2025-06-26", efforts: ["medium"] }, { id: "gpt-5.1", releaseDate: "2025-11-13", efforts: ["none", "low", "medium", "high"] }, { id: "gpt-5.4", releaseDate: "2026-03-05", efforts: ["none", "low", "medium", "high", "xhigh"] }, { From e0396b809a8b685c8f84a2f5f711b68846e17bb5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 00:06:07 -0400 Subject: [PATCH 077/812] fix(provider): align Anthropic Opus 4.5 efforts (#26275) --- packages/opencode/src/provider/transform.ts | 4 + .../opencode/test/provider/transform.test.ts | 91 +++++++++---------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 69a0d484f4..210c574d4f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -811,6 +811,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + } + return { high: { thinking: { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 25ed2aadc3..c52a7bfa44 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3128,53 +3128,50 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/anthropic", () => { - test("sonnet 4.6 returns adaptive thinking options", () => { - const model = createMockModel({ - id: "anthropic/claude-sonnet-4-6", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-6", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "high", - }) - }) - - test("opus 4.7 returns adaptive thinking options with xhigh", () => { - const model = createMockModel({ - id: "anthropic/claude-opus-4-7", - providerID: "anthropic", - api: { - id: "claude-opus-4-7", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(result.xhigh).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "xhigh", - }) - expect(result.max).toEqual({ - thinking: { - type: "adaptive", - display: "summarized", - }, - effort: "max", - }) - }) + for (const testCase of [ + { + name: "opus 4.5", + apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], + efforts: ["low", "medium", "high"], + expectedHigh: { effort: "high" }, + }, + { + name: "sonnet 4.6", + apiIds: ["claude-sonnet-4-6", "claude-sonnet-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.6", + apiIds: ["claude-opus-4-6", "claude-opus-4.6"], + efforts: ["low", "medium", "high", "max"], + expectedHigh: { thinking: { type: "adaptive" }, effort: "high" }, + }, + { + name: "opus 4.7", + apiIds: ["claude-opus-4-7", "claude-opus-4.7"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `anthropic/${apiId}`, + providerID: "anthropic", + api: { + id: apiId, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + }) + } + } test("github copilot opus 4.7 returns only medium reasoning effort", () => { const model = createMockModel({ From 4e14f79511728d04329da664b747dd0b359cf931 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 7 May 2026 23:17:43 -0500 Subject: [PATCH 078/812] fix: tweaks to transform logic for anthropic and bedrock (#26276) --- packages/opencode/src/provider/transform.ts | 18 +- packages/opencode/src/session/message-v2.ts | 39 ++--- .../opencode/test/session/message-v2.test.ts | 157 +++++++++++++++++- 3 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 210c574d4f..3f52f6a2aa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -135,9 +135,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -156,9 +163,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ed09262d0e..2930dbaeb3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // a proxy, or a lower-level library, but preserving a non-empty separator // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. Bedrock-hosted Claude stores - // the same signature under the bedrock metadata namespace. + // the neighboring signed reasoning blocks. const hasSignedReasoning = msg.parts.some((part) => { if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null + return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { if (part.type === "text") { @@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 999b61b48e..d9c71f8c07 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("filters out user messages with only empty text parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("filters empty user text parts while keeping non-empty parts", async () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "text", + text: "", + }, + { + ...basePart(messageID, "p2"), + type: "text", + text: "hello", + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + ]) + }) + test("includes synthetic text parts", async () => { const messageID = "m-user" @@ -443,6 +491,108 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { + const bedrockModel: Provider.Model = { + ...model, + id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + providerID: ProviderID.make("amazon-bedrock"), + api: { + id: "anthropic.claude-sonnet-4-6", + url: "https://bedrock-runtime.us-east-1.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const pdf = Buffer.from("%PDF-1.4\n").toString("base64") + const userID = "m-user-bedrock-pdf" + const assistantID = "m-assistant-bedrock-pdf" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-bedrock-pdf"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-bedrock-pdf"), + type: "tool", + callID: "call-bedrock-pdf-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/example.pdf" }, + output: "PDF read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-bedrock-pdf-1"), + type: "file", + mime: "application/pdf", + filename: "example.pdf", + url: `data:application/pdf;base64,${pdf}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + input: { filePath: "/tmp/example.pdf" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-bedrock-pdf-1", + toolName: "read", + output: { type: "text", value: "PDF read successfully" }, + }, + ], + }, + { + role: "user", + content: [ + { type: "text", text: "Attached media from tool result:" }, + { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + ], + }, + ]) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -1134,8 +1284,9 @@ describe("session.message-v2.toModelMessage", () => { expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer") }) - test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => { - // AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock + test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => { + // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the + // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" const input: MessageV2.WithParts[] = [ { @@ -1157,7 +1308,7 @@ describe("session.message-v2.toModelMessage", () => { expect(result).toHaveLength(1) const texts = (result[0].content as any[]).filter((p) => p.type === "text") - expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"]) + expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) test("leaves empty text alone when reasoning has no Anthropic signature", async () => { From 9c88235121eda5f5afd2dd806fbc6e6d8eef664d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 04:18:54 +0000 Subject: [PATCH 079/812] chore: generate --- packages/opencode/test/session/message-v2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index d9c71f8c07..08629f5b1b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -587,7 +587,12 @@ describe("session.message-v2.toModelMessage", () => { role: "user", content: [ { type: "text", text: "Attached media from tool result:" }, - { type: "file", mediaType: "application/pdf", filename: "example.pdf", data: `data:application/pdf;base64,${pdf}` }, + { + type: "file", + mediaType: "application/pdf", + filename: "example.pdf", + data: `data:application/pdf;base64,${pdf}`, + }, ], }, ]) From 30868f52ea997ada6ac452e47ec00fb5ee59302c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 00:26:08 -0400 Subject: [PATCH 080/812] go: update rate limit error copy --- packages/console/app/src/i18n/ar.ts | 8 ++++++-- packages/console/app/src/i18n/br.ts | 8 ++++++-- packages/console/app/src/i18n/da.ts | 8 ++++++-- packages/console/app/src/i18n/de.ts | 8 ++++++-- packages/console/app/src/i18n/en.ts | 8 ++++++-- packages/console/app/src/i18n/es.ts | 8 ++++++-- packages/console/app/src/i18n/fr.ts | 8 ++++++-- packages/console/app/src/i18n/it.ts | 8 ++++++-- packages/console/app/src/i18n/ja.ts | 8 ++++++-- packages/console/app/src/i18n/ko.ts | 8 ++++++-- packages/console/app/src/i18n/no.ts | 8 ++++++-- packages/console/app/src/i18n/pl.ts | 8 ++++++-- packages/console/app/src/i18n/ru.ts | 8 ++++++-- packages/console/app/src/i18n/th.ts | 8 ++++++-- packages/console/app/src/i18n/tr.ts | 8 ++++++-- packages/console/app/src/i18n/zh.ts | 7 ++++++- packages/console/app/src/i18n/zht.ts | 7 ++++++- .../console/app/src/routes/zen/util/handler.ts | 16 +++++++++++++--- 18 files changed, 115 insertions(+), 35 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 12ec7f1fbd..f413b5572f 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}", "zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 0a6d8f153e..8466acc5fd 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.invalidApiKey": "Chave de API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 15e7151b67..9338e3add5 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 0efcce78bf..7a2d3e91b4 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -362,8 +362,12 @@ export const dict = { "zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}", "zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index f2cf3c14a4..b7ef397be6 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -355,8 +355,12 @@ export const dict = { "zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.invalidApiKey": "Invalid API key.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Subscription quota exceeded. You can continue using free models.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}", "zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 5614a8c7ad..f6347d3b52 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.invalidApiKey": "Clave API inválida.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 390025d275..5d1cd0fab7 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.invalidApiKey": "Clé API invalide.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}", "zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 3737186996..07da9434eb 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -359,8 +359,12 @@ export const dict = { "zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.invalidApiKey": "Chiave API non valida.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}", "zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 66f3c4a89d..975728fe7e 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.invalidApiKey": "無効なAPIキーです。", "zen.api.error.subscriptionQuotaExceeded": "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}", "zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 04482d35f6..293c3eb7d9 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -354,8 +354,12 @@ export const dict = { "zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}", "zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 31200d3edd..27b5522e32 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -360,8 +360,12 @@ export const dict = { "zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}", "zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 50d904bc56..7f8c849156 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -361,8 +361,12 @@ export const dict = { "zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}", "zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 651309fc95..4ac54c2ac0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -365,8 +365,12 @@ export const dict = { "zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}", "zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 42c9e455fd..280b9d9fa8 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -356,8 +356,12 @@ export const dict = { "zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}", "zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 64380db375..a8f449dc47 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -363,8 +363,12 @@ export const dict = { "zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": - "Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}", "zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 3b104cca6d..ced0060ca0 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index a4d5512da4..e3e374a329 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -343,7 +343,12 @@ export const dict = { "zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", - "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。", + "zen.api.error.goSubscriptionRollingLimitExceeded": + "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionWeeklyLimitExceeded": + "已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", + "zen.api.error.goSubscriptionMonthlyLimitExceeded": + "已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", "zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}", "zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}", "zen.api.error.workspaceMonthlyLimitReached": diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4b6fe5feb8..278a541610 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -744,6 +744,7 @@ export async function handler( // Validate lite subscription billing if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) { try { + const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go` const sub = authInfo.lite const liteData = LiteData.getLimits() @@ -756,7 +757,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionWeeklyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "weekly", result.resetInSec, @@ -773,7 +777,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionMonthlyLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "monthly", result.resetInSec, @@ -790,7 +797,10 @@ export async function handler( }) if (result.status === "rate-limited") throw new GoUsageLimitError( - t("zen.api.error.subscriptionQuotaExceededUseFreeModels"), + t("zen.api.error.goSubscriptionRollingLimitExceeded", { + retryIn: formatRetryTime(result.resetInSec), + consoleGoUrl, + }), authInfo.workspaceID, "5 hour", result.resetInSec, From dd8bb44d1db520ada12da5efc5611696372d0810 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 13:34:53 +0800 Subject: [PATCH 081/812] refactor(desktop): use electron-log in shell-env and simplify env merging (#26284) --- packages/desktop/src/main/logging.ts | 6 +++++- packages/desktop/src/main/server.ts | 19 ++++++++----------- packages/desktop/src/main/shell-env.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 1f1c5e54e3..5d373ed27f 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -1,3 +1,4 @@ +import { MainLogger } from "electron-log" import log from "electron-log/main.js" import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs" import { dirname, join } from "node:path" @@ -5,11 +6,14 @@ import { dirname, join } from "node:path" const MAX_LOG_AGE_DAYS = 7 const TAIL_LINES = 1000 +let logger: MainLogger +export const getLogger = () => logger + export function initLogging() { log.transports.file.maxSize = 5 * 1024 * 1024 initConsoleTransport() cleanup() - return log + return (logger = log) } export function tail(): string { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 635a93578a..909138b89c 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" import type { Details } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" -import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" +import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" import type { SqliteMigrationProgress } from "../preload/types" @@ -57,16 +57,13 @@ export function setWslConfig(config: WslConfig) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() - Object.assign( - process.env, - mergeShellEnv(shell ? loadShellEnv(shell) : null, { - ...process.env, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, - }), - ) + Object.assign(process.env, { + ...(shell ? loadShellEnv(shell) : null), + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + }) } export async function spawnLocalServer( diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index f57677323c..8a1ee1f586 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" +import { getLogger } from "./logging"; const TIMEOUT = 5_000 @@ -55,28 +56,29 @@ export function isNushell(shell: string) { } export function loadShellEnv(shell: string) { + const logger = getLogger() if (isNushell(shell)) { - console.log(`[server] Skipping shell env probe for nushell: ${shell}`) + logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null } const interactive = probe(shell, "-il") if (interactive.type === "Loaded") { - console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - console.warn(`[server] Interactive shell env probe timed out: ${shell}`) + logger.log(`[server] Interactive shell env probe timed out: ${shell}`) return null } const login = probe(shell, "-l") if (login.type === "Loaded") { - console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - console.warn(`[server] Falling back to app environment: ${shell}`) + logger.log(`[server] Falling back to app environment: ${shell}`) return null } From cef0c8ac844189872875cae6950fc14ee3b522cb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 05:36:06 +0000 Subject: [PATCH 082/812] chore: generate --- packages/desktop/src/main/shell-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index 8a1ee1f586..4a65fbf0f7 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process" import { basename } from "node:path" -import { getLogger } from "./logging"; +import { getLogger } from "./logging" const TIMEOUT = 5_000 From 6f165e23deae6d3a812af2b0aaf1557d94251a15 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 8 May 2026 15:36:28 +1000 Subject: [PATCH 083/812] perf(ui): defer tool status width measurement (#26282) --- .../ui/src/components/tool-status-title.tsx | 132 +++++++++--------- 1 file changed, 65 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 412d92e3db..5c46593f71 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" @@ -15,10 +15,8 @@ function common(active: string, done: string) { } function contentWidth(el: HTMLSpanElement | undefined) { - if (!el) return 0 - const range = document.createRange() - range.selectNodeContents(el) - return Math.ceil(range.getBoundingClientRect().width) + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` } export function ToolStatusTitle(props: { @@ -37,99 +35,99 @@ export function ToolStatusTitle(props: { const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) const [state, setState] = createStore({ - width: "auto", - ready: false, + active: props.active, + animating: false, + width: undefined as string | undefined, }) const width = () => state.width - const ready = () => state.ready + const active = () => state.active + const animating = () => state.animating let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined let frame: number | undefined - let readyFrame: number | undefined - - const measure = () => { - const target = props.active ? activeRef : doneRef - const px = contentWidth(target) - if (px > 0) setState("width", `${px}px`) - } - - const schedule = () => { - if (typeof requestAnimationFrame !== "function") { - measure() - return - } - if (frame !== undefined) cancelAnimationFrame(frame) - frame = requestAnimationFrame(() => { - frame = undefined - measure() - }) - } + let finishTimer: ReturnType | undefined const finish = () => { - if (typeof requestAnimationFrame !== "function") { - setState("ready", true) + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) + } + + const animate = () => { + const first = contentWidth(widthRef) + finish() + setState("animating", true) + setState("active", props.active) + const last = contentWidth(props.active ? activeRef : doneRef) + if (!first || !last) { + finish() return } - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) - readyFrame = requestAnimationFrame(() => { - readyFrame = undefined - setState("ready", true) + + setState("width", first) + if (first === last) { + finishTimer = setTimeout(finish, 600) + return + } + + frame = requestAnimationFrame(() => { + frame = undefined + setState("width", last) + finishTimer = setTimeout(finish, 600) }) } - createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule())) - - onMount(() => { - measure() - const fonts = typeof document !== "undefined" ? document.fonts : undefined - if (!fonts) { - finish() - return - } - void fonts.ready.finally(() => { - measure() - finish() - }) - }) + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) onCleanup(() => { - if (frame !== undefined) cancelAnimationFrame(frame) - if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) + finish() }) return ( - - - - - - + + + + + + + + + + + } > - + - - - - - - - + + + + + + + + + + + From bb3f14119b25edcf0478757f7c5f9e1a8e664dab Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 01:51:31 -0400 Subject: [PATCH 084/812] tui: update go upsell copy --- packages/opencode/src/session/retry.ts | 24 ++++++++++---------- packages/opencode/test/session/retry.test.ts | 8 ++++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index a4ef5b7a8f..3bccee212d 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -6,7 +6,6 @@ import { iife } from "@/util/iife" export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" -export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG" export const GO_UPSELL_URL = "https://opencode.ai/go" export type Retryable = { @@ -83,11 +82,11 @@ export function retryable(error: Err) { if (error.data.responseBody?.includes("GoUsageLimitError")) { const body = parseJSON(error.data.responseBody) const workspace = str(body?.metadata?.workspace) - const limit = str(body?.metadata?.limit) - const resetAt = num(body?.metadata?.resetAt) + const limitName = str(body?.metadata?.limitName) + const retryAfter = num(error.data.responseHeaders?.["retry-after"]) const resetIn = iife(() => { - if (resetAt === undefined) return "" - const seconds = Math.max(0, Math.ceil(resetAt)) + if (retryAfter === undefined) return "" + const seconds = Math.max(0, Math.ceil(retryAfter)) const days = Math.floor(seconds / 86_400) const hours = Math.floor((seconds % 86_400) / 3_600) const minutes = Math.ceil((seconds % 3_600) / 60) @@ -97,16 +96,17 @@ export function retryable(error: Err) { if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour") return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) + + const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + + const link = `https://opencode.ai/workspace/${workspace}/go` return { - message: PAYG_UPSELL_MESSAGE, + message: `${message} - ${link}`, action: { title: "Go limit reached", - message: - limit && resetIn - ? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.` - : "Enable pay-as-you-go to keep using Go models after your subscription quota is used.", - label: "enable PAYG", - ...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}), + message, + label: "open settings", + link, }, } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 8a4d6d6af0..9bb5e48652 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -279,11 +279,13 @@ describe("session.retry.retryable", () => { ) expect(SessionRetry.retryable(error)).toEqual({ - message: SessionRetry.PAYG_UPSELL_MESSAGE, + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { title: "Go limit reached", - message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.", - label: "enable PAYG", + message: + "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", + label: "open settings", link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", }, }) From 21ae91b4f237f9ef9947a6988f4a25de3ab1c31a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 8 May 2026 14:19:09 +0800 Subject: [PATCH 085/812] refactor(desktop): convert main process to Effect-TS (#26148) --- packages/desktop/src/main/index.ts | 650 +++++++++++---------------- packages/desktop/src/main/updater.ts | 126 ++++++ 2 files changed, 384 insertions(+), 392 deletions(-) create mode 100644 packages/desktop/src/main/updater.ts diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 52e45a702c..1b624800e8 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, dialog } from "electron" -import pkg from "electron-updater" +import { app, BrowserWindow } from "electron" import contextMenu from "electron-context-menu" -contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - -// on macOS apps run in `/` which can cause issues with ripgrep -try { - process.chdir(homedir()) -} catch {} - -process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - -const APP_NAMES: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" -const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -const onboardingTestRoot = setupOnboardingTestEnv() -app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setAppUserModelId(appId) -app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)) -if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) -const logger = initLogging() -const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import { checkAppExists, resolveAppPath, wslPath } from "./apps" @@ -64,104 +35,30 @@ import { setDockIcon, } from "./windows" import { migrate } from "./migrate" +import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater" +import { Deferred, Effect, Fiber } from "effect" + +const APP_NAMES: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const APP_IDS: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" + +let logger: ReturnType +let mainWindow: BrowserWindow | null = null +let server: SidecarListener | null = null const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } -let mainWindow: BrowserWindow | null = null -let server: SidecarListener | null = null -const loadingComplete = defer() - const pendingDeepLinks: string[] = [] -const serverReady = defer() - -useSystemCertificates() - -function setupOnboardingTestEnv() { - if (!TEST_ONBOARDING) return - - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), - ) - process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") - return root -} - -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingTestRoot), -}) - -setupApp() - -function setupApp() { - ensureLoopbackNoProxy() - useEnvProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") - - if (!app.requestSingleInstanceLock()) { - app.quit() - return - } - - preferAppEnv(app.getPath("userData")) - - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) - } - focusMainWindow() - }) - - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) - - app.on("before-quit", () => { - void killSidecar() - }) - - app.on("will-quit", () => { - void killSidecar() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - void killSidecar().finally(() => app.exit(0)) - }) - } - - void app.whenReady().then(async () => { - if (!TEST_ONBOARDING) migrate() - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() - }) -} - -function useSystemCertificates() { - try { - setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} - function useEnvProxy() { try { // Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2. @@ -177,145 +74,12 @@ function emitDeepLinks(urls: string[]) { if (mainWindow) sendDeepLinks(mainWindow, urls) } -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} - function setInitStep(step: InitStep) { initStep = step logger.log("init step", { step }) initEmitter.emit("step", step) } -async function initialize() { - const needsMigration = !sqliteFileExists() - let overlay: BrowserWindow | null = null - - const port = await getSidecarPort() - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - - const loadingTask = (async () => { - logger.log("sidecar connection started", { url }) - - initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (overlay) sendSqliteMigrationProgress(overlay, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - }) - - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ) - server = listener - serverReady.resolve({ - url, - username: "opencode", - password, - }) - - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) - - logger.log("loading task finished") - })() - - if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow() - await delay(1_000) - } - } - - await loadingTask - setInitStep({ phase: "done" }) - - if (overlay) { - await loadingComplete.promise - } - - mainWindow = createMainWindow() - wireMenu() - - overlay?.close() -} - -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - void killSidecar().finally(() => { - app.relaunch() - app.exit(0) - }) - }, - }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), -}) - async function killSidecar() { if (!server) return const current = server @@ -343,163 +107,265 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed +const main = Effect.gen(function* () { + contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) + + // on macOS apps run in `/` which can cause issues with ripgrep + try { + process.chdir(homedir()) + } catch {} + + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const onboardingTestRoot = ((): string | undefined => { + if (!TEST_ONBOARDING) return + + const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + rmSync(root, { recursive: true, force: true }) + ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => + mkdirSync(join(root, dir), { recursive: true }), + ) + process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = join(root, "data") + process.env.XDG_CONFIG_HOME = join(root, "config") + process.env.XDG_CACHE_HOME = join(root, "cache") + process.env.XDG_STATE_HOME = join(root, "state") + return root + })() + app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") + app.setAppUserModelId(appId) + app.setPath( + "userData", + onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + ) + if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + logger = initLogging() + + try { + setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) + } catch (error) { + logger.warn("failed to load system certificates", error) } - return await new Promise((resolve, reject) => { + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + onboardingTest: Boolean(onboardingTestRoot), + }) + + ensureLoopbackNoProxy() + useEnvProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + preferAppEnv(app.getPath("userData")) + + app.on("second-instance", (_event: Event, argv: string[]) => { + const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + emitDeepLinks(urls) + } + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }) + + app.on("open-url", (event: Event, url: string) => { + event.preventDefault() + logger.log("deep link received via open-url", { url }) + emitDeepLinks([url]) + }) + + app.on("before-quit", () => { + void killSidecar() + }) + + app.on("will-quit", () => { + void killSidecar() + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void killSidecar().finally(() => app.exit(0)) + }) + } + + const serverReady = Deferred.makeUnsafe() + const loadingComplete = Deferred.makeUnsafe() + + registerIpcHandlers({ + killSidecar: () => killSidecar(), + awaitInitialization: Effect.fnUntraced( + function* (sendStep) { + sendStep(initStep) + const listener = (step: InitStep) => sendStep(step) + initEmitter.on("step", listener) + try { + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + return res + } finally { + initEmitter.off("step", listener) + } + }, + (e) => Effect.runPromise(e), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), + getDisplayBackend: async () => null, + setDisplayBackend: async () => undefined, + parseMarkdown: async (markdown) => parseMarkdown(markdown), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: async (path, mode) => wslPath(path, mode), + resolveAppPath: async (appName) => resolveAppPath(appName), + loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void), + runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar), + checkUpdate: async () => checkUpdate(), + installUpdate: async () => installUpdate(killSidecar), + setBackgroundColor: (color) => setBackgroundColor(color), + }) + + yield* Effect.promise(() => app.whenReady()) + + if (!TEST_ONBOARDING) migrate() + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() + + const needsMigration = ((): boolean => { + if (process.env.OPENCODE_DB === ":memory:") return false + + const xdg = process.env.XDG_DATA_HOME + const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") + return !existsSync(join(base, "opencode", "opencode.db")) + })() + let overlay: BrowserWindow | null = null + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() const server = createServer() - server.on("error", reject) + server.on("error", (e) => Deferred.failSync(res, () => e)) server.listen(0, "127.0.0.1", () => { const address = server.address() if (typeof address !== "object" || !address) { server.close() - reject(new Error("Failed to get port")) + Deferred.failSync(res, () => new Error("Failed to get port")) return } const port = address.port - server.close(() => resolve(port)) + server.close(() => Effect.runSync(Deferred.succeed(res, port))) }) + + return yield* Deferred.await(res) }) -} + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() -function sqliteFileExists() { - if (process.env.OPENCODE_DB === ":memory:") return true + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { url }) - const xdg = process.env.XDG_DATA_HOME - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") - return existsSync(join(base, "opencode", "opencode.db")) -} - -function setupAutoUpdater() { - if (!UPDATER_ENABLED) return - autoUpdater.logger = logger - autoUpdater.channel = "latest" - autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - logger.log("auto updater configured", { - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - currentVersion: app.getVersion(), - }) -} - -let downloadedUpdateVersion: string | undefined - -async function checkUpdate() { - if (!UPDATER_ENABLED) return { updateAvailable: false } - if (downloadedUpdateVersion) { - logger.log("returning cached downloaded update", { - version: downloadedUpdateVersion, + initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { + setInitStep({ phase: "sqlite_waiting" }) + if (overlay) sendSqliteMigrationProgress(overlay, progress) + if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) - return { updateAvailable: true, version: downloadedUpdateVersion } - } - logger.log("checking for updates", { - currentVersion: app.getVersion(), - channel: autoUpdater.channel, - allowPrerelease: autoUpdater.allowPrerelease, - allowDowngrade: autoUpdater.allowDowngrade, - }) - try { - const result = await autoUpdater.checkForUpdates() - const updateInfo = result?.updateInfo - logger.log("update metadata fetched", { - releaseVersion: updateInfo?.version ?? null, - releaseDate: updateInfo?.releaseDate ?? null, - releaseName: updateInfo?.releaseName ?? null, - files: updateInfo?.files?.map((file) => file.url) ?? [], + + logger.log("spawning sidecar", { url }) + const { listener, health } = yield* Effect.promise(() => + spawnLocalServer( + hostname, + port, + password, + () => { + ensureLoopbackNoProxy() + useEnvProxy() + }, + { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }, + ), + ) + server = listener + yield* Deferred.succeed(serverReady, { + url, + username: "opencode", + password, }) - const version = result?.updateInfo?.version - if (result?.isUpdateAvailable === false || !version) { - logger.log("no update available", { - reason: "provider returned no newer version", - }) - return { updateAvailable: false } + + yield* Effect.promise(() => health.wait).pipe( + Effect.timeout("30 seconds"), + Effect.catch((e) => + Effect.sync(() => { + logger.error("sidecar health check failed", e.toString()) + }), + ), + ) + + logger.log("loading task finished") + }).pipe(Effect.forkChild) + + if (needsMigration) { + const show = yield* loadingTask.pipe( + Fiber.await, + Effect.timeout("1 second"), + Effect.as(false), + Effect.catch(() => Effect.succeed(true)), + ) + if (show) { + overlay = createLoadingWindow() + yield* Effect.sleep("1 second") } - logger.log("update available", { version }) - await autoUpdater.downloadUpdate() - logger.log("update download completed", { version }) - downloadedUpdateVersion = version - return { updateAvailable: true, version } - } catch (error) { - logger.error("update check failed", error) - return { updateAvailable: false, failed: true } } -} -async function installUpdate() { - if (!downloadedUpdateVersion) { - logger.log("install update skipped", { - reason: "no downloaded update ready", + yield* Fiber.await(loadingTask) + setInitStep({ phase: "done" }) + + if (overlay) yield* Deferred.await(loadingComplete) + + mainWindow = createMainWindow() + if (mainWindow) { + createMenu({ + trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), + checkForUpdates: () => { + void checkForUpdates(true, killSidecar) + }, + reload: () => mainWindow?.reload(), + relaunch: () => { + void killSidecar().finally(() => { + app.relaunch() + app.exit(0) + }) + }, }) - return - } - logger.log("installing downloaded update", { - version: downloadedUpdateVersion, - }) - await killSidecar() - autoUpdater.quitAndInstall(true, true) -} - -async function checkForUpdates(alertOnFail: boolean) { - if (!UPDATER_ENABLED) return - logger.log("checkForUpdates invoked", { alertOnFail }) - const result = await checkUpdate() - if (!result.updateAvailable) { - if (result.failed) { - logger.log("no update decision", { reason: "update check failed" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "error", - message: "Update check failed.", - title: "Update Error", - }) - return - } - - logger.log("no update decision", { reason: "already up to date" }) - if (!alertOnFail) return - await dialog.showMessageBox({ - type: "info", - message: "You're up to date.", - title: "No Updates", - }) - return } - const response = await dialog.showMessageBox({ - type: "info", - message: `Update ${result.version ?? ""} downloaded. Restart now?`, - title: "Update Ready", - buttons: ["Restart", "Later"], - defaultId: 0, - cancelId: 1, - }) - logger.log("update prompt response", { - version: result.version ?? null, - restartNow: response.response === 0, - }) - if (response.response === 0) { - await installUpdate() - } -} + overlay?.close() +}) -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} +Effect.runFork(main) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000000..a220e00891 --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,126 @@ +import { app, dialog } from "electron" +import pkg from "electron-updater" +import { UPDATER_ENABLED } from "./constants" +import { initLogging } from "./logging" + +const logger = initLogging() +const { autoUpdater } = pkg + +let downloadedUpdateVersion: string | undefined + +export function setupAutoUpdater() { + if (!UPDATER_ENABLED) return + autoUpdater.logger = logger + autoUpdater.channel = "latest" + autoUpdater.allowPrerelease = false + autoUpdater.allowDowngrade = true + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + logger.log("auto updater configured", { + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + currentVersion: app.getVersion(), + }) +} + +export async function checkUpdate() { + if (!UPDATER_ENABLED) return { updateAvailable: false } + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } + logger.log("checking for updates", { + currentVersion: app.getVersion(), + channel: autoUpdater.channel, + allowPrerelease: autoUpdater.allowPrerelease, + allowDowngrade: autoUpdater.allowDowngrade, + }) + try { + const result = await autoUpdater.checkForUpdates() + const updateInfo = result?.updateInfo + logger.log("update metadata fetched", { + releaseVersion: updateInfo?.version ?? null, + releaseDate: updateInfo?.releaseDate ?? null, + releaseName: updateInfo?.releaseName ?? null, + files: updateInfo?.files?.map((file) => file.url) ?? [], + }) + const version = result?.updateInfo?.version + if (result?.isUpdateAvailable === false || !version) { + logger.log("no update available", { + reason: "provider returned no newer version", + }) + return { updateAvailable: false } + } + logger.log("update available", { version }) + await autoUpdater.downloadUpdate() + logger.log("update download completed", { version }) + downloadedUpdateVersion = version + return { updateAvailable: true, version } + } catch (error) { + logger.error("update check failed", error) + return { updateAvailable: false, failed: true } + } +} + +export async function installUpdate(killSidecar: () => Promise) { + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) + await killSidecar() + autoUpdater.quitAndInstall() +} + +export async function checkForUpdates( + alertOnFail: boolean, + killSidecar: () => Promise, +) { + if (!UPDATER_ENABLED) return + logger.log("checkForUpdates invoked", { alertOnFail }) + const result = await checkUpdate() + if (!result.updateAvailable) { + if (result.failed) { + logger.log("no update decision", { reason: "update check failed" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "error", + message: "Update check failed.", + title: "Update Error", + }) + return + } + + logger.log("no update decision", { reason: "already up to date" }) + if (!alertOnFail) return + await dialog.showMessageBox({ + type: "info", + message: "You're up to date.", + title: "No Updates", + }) + return + } + + const response = await dialog.showMessageBox({ + type: "info", + message: `Update ${result.version ?? ""} downloaded. Restart now?`, + title: "Update Ready", + buttons: ["Restart", "Later"], + defaultId: 0, + cancelId: 1, + }) + logger.log("update prompt response", { + version: result.version ?? null, + restartNow: response.response === 0, + }) + if (response.response === 0) { + await installUpdate(killSidecar) + } +} From 014dbd34c4f5612d9a037b3641a8244b213a8a30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 06:20:21 +0000 Subject: [PATCH 086/812] chore: generate --- packages/desktop/src/main/updater.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index a220e00891..b7f4bce785 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -79,10 +79,7 @@ export async function installUpdate(killSidecar: () => Promise) { autoUpdater.quitAndInstall() } -export async function checkForUpdates( - alertOnFail: boolean, - killSidecar: () => Promise, -) { +export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise) { if (!UPDATER_ENABLED) return logger.log("checkForUpdates invoked", { alertOnFail }) const result = await checkUpdate() From f8c6742e5483a6e198e13674e526cca35691290e Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 02:03:39 -0400 Subject: [PATCH 087/812] zen: lift default rate limit --- packages/console/app/src/routes/zen/util/keyRateLimiter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts index 0bf495f7db..37fe9f127e 100644 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -13,7 +13,7 @@ export function createRateLimiter( if (!zenApiKey) return const dict = i18n(localeFromRequest(request)) - const LIMIT = rateLimit ?? 300 + const LIMIT = rateLimit ?? 500 const yyyyMMddHHmm = new Date(Date.now()) .toISOString() .replace(/[^0-9]/g, "") From 6869186fc69983becd55f2a9ec6f9c623037d3fc Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 8 May 2026 03:52:56 -0400 Subject: [PATCH 088/812] zen: update tpm rate limit algo --- .../src/routes/zen/util/modelTpmLimiter.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts index 8e3e8cc95e..2ccc47589f 100644 --- a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts +++ b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts @@ -1,4 +1,4 @@ -import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" import { ModelTpmRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { UsageInfo } from "./provider/provider" @@ -6,12 +6,16 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp const ids = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`) if (ids.length === 0) return - const yyyyMMddHHmm = parseInt( - new Date(Date.now()) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12), - ) + const toInterval = (date: Date) => + parseInt( + date + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12), + ) + const now = Date.now() + const currInterval = toInterval(new Date(now)) + const prevInterval = toInterval(new Date(now - 60_000)) return { check: async () => { @@ -19,13 +23,18 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp tx .select() .from(ModelTpmRateLimitTable) - .where(and(inArray(ModelTpmRateLimitTable.id, ids), eq(ModelTpmRateLimitTable.interval, yyyyMMddHHmm))), + .where( + and( + inArray(ModelTpmRateLimitTable.id, ids), + inArray(ModelTpmRateLimitTable.interval, [currInterval, prevInterval]), + ), + ), ) // convert to map of model to count return data.reduce( (acc, curr) => { - acc[curr.id] = curr.count + acc[curr.id] = Math.max(acc[curr.id] ?? 0, curr.count) return acc }, {} as Record, @@ -39,7 +48,7 @@ export function createModelTpmLimiter(providers: { id: string; model: string; tp await Database.use((tx) => tx .insert(ModelTpmRateLimitTable) - .values({ id, interval: yyyyMMddHHmm, count: usage }) + .values({ id, interval: currInterval, count: usage }) .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmRateLimitTable.count} + ${usage}` } }), ) }, From ae25278edaed91a9d385977df579160be830142b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:10:18 +0530 Subject: [PATCH 089/812] test(session): update go retry fixture (#26312) --- packages/opencode/test/session/retry.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9bb5e48652..0b67294796 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -263,6 +263,9 @@ describe("session.retry.retryable", () => { message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, statusCode: 429, + responseHeaders: { + "retry-after": "19380", + }, responseBody: JSON.stringify({ type: "error", error: { @@ -271,8 +274,7 @@ describe("session.retry.retryable", () => { }, metadata: { workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: 19_380, + limitName: "5 hour", }, }), }).toObject(), From a43d3e0e1ee9bbc6f5a6ed3a069c4ac3ec6c0d6f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 8 May 2026 14:19:36 +0530 Subject: [PATCH 090/812] feat(websearch): add parallel provider rollout (#26227) --- packages/core/src/flag/flag.ts | 1 + packages/opencode/src/cli/cmd/run.ts | 9 +- .../tui/feature-plugins/system/session-v2.tsx | 12 +- .../src/cli/cmd/tui/routes/session/index.tsx | 7 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- .../opencode/src/command/template/review.txt | 2 +- .../src/tool/{mcp-exa.ts => mcp-websearch.ts} | 35 +++++- packages/opencode/src/tool/registry.ts | 9 +- packages/opencode/src/tool/websearch.ts | 103 +++++++++++++++--- packages/opencode/src/tool/websearch.txt | 6 +- packages/opencode/test/tool/websearch.test.ts | 92 ++++++++++++++++ packages/ui/src/components/message-part.tsx | 37 ++++++- .../ui/src/components/tool-error-card.tsx | 4 +- 13 files changed, 276 insertions(+), 44 deletions(-) rename packages/opencode/src/tool/{mcp-exa.ts => mcp-websearch.ts} (63%) create mode 100644 packages/opencode/test/tool/websearch.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800..f55c14bd05 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -77,6 +77,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a05b273e44..5c38c2871f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { WebSearchTool } from "../../tool/websearch" +import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { ShellTool } from "../../tool/shell" @@ -148,7 +148,7 @@ function edit(info: ToolProps) { function websearch(info: ToolProps) { inline({ icon: "◈", - title: `Exa Web Search "${info.input.query}"`, + title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, }) } @@ -469,7 +469,10 @@ export const RunCommand = effectCmd({ } inline({ icon: "✗", - title: `${part.tool} failed`, + title: + part.tool === "websearch" + ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` + : `${part.tool} failed`, }) UI.error(part.state.error) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 0d899a8bae..8fca0de0c8 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { webSearchProviderLabel } from "@/tool/websearch" import path from "path" import stripAnsi from "strip-ansi" import type { @@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { - + )} @@ -400,7 +403,7 @@ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; sub ) } -function AssistantTool(props: { part: SessionMessageAssistantTool }) { +function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { const input = createMemo(() => toolInputRecord(props.part.state.input)) const toolprops = { get input() { @@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) { get output() { return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) }, + sessionID: props.sessionID, part: props.part, } return ( @@ -469,6 +473,7 @@ type ToolProps = { input: Record metadata: Record output?: string + sessionID: string part: SessionMessageAssistantTool } @@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( - Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d2b50c32f8..af70f83711 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -45,7 +45,7 @@ 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 { WebSearchTool } from "@/tool/websearch" +import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" @@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number } + const metadata = props.metadata as { numResults?: number; provider?: unknown } return ( - Exa Web Search "{props.input.query}" ({metadata.numResults} results) + {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} + ({metadata.numResults} results) ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5e7e80b66a..fd4c96d124 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" +import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◈", - title: `Exa Web Search "${query}"`, + title: `${webSearchProviderLabel(data.provider)} "${query}"`, body: ( diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e7f..43c6738577 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -85,7 +85,7 @@ Use these to inform your review: - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. -- **Exa Web Search** - Research best practices if you're unsure about a pattern. +- **Web Search** - Research best practices if you're unsure about a pattern. If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-websearch.ts similarity index 63% rename from packages/opencode/src/tool/mcp-exa.ts rename to packages/opencode/src/tool/mcp-websearch.ts index af9a3390e3..208924cba5 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,10 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = process.env.EXA_API_KEY +export const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ @@ -18,11 +19,23 @@ const McpResult = Schema.Struct({ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) -const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { if (!line.startsWith("data: ")) continue - const data = yield* decode(line.substring(6)) - if (data.result.content[0]?.text) return data.result.content[0].text + const data = yield* parsePayload(line.substring(6)) + if (data) return data } return undefined }) @@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), @@ -48,14 +68,17 @@ const McpRequest = (args: Schema.Struct) => export const call = ( http: HttpClient.HttpClient, + url: string, tool: string, args: Schema.Struct, value: Schema.Struct.Type, timeout: Duration.Input, + headers?: Record, ) => Effect.gen(function* () { - const request = yield* HttpClientRequest.post(URL).pipe( + const request = yield* HttpClientRequest.post(url).pipe( HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), HttpClientRequest.schemaBodyJson(McpRequest(args))({ jsonrpc: "2.0" as const, id: 1 as const, @@ -69,5 +92,5 @@ export const call = ( Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), ) const body = yield* response.text - return yield* parseSse(body) + return yield* parseResponse(body) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc7..b288bf7ae5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -49,6 +49,13 @@ import { Permission } from "@/permission" const log = Log.create({ service: "tool.registry" }) +export function webSearchEnabled( + providerID: ProviderID, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel +} + type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef @@ -284,7 +291,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return webSearchEnabled(input.providerID) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25..0218ecbe3b 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,11 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" +import * as McpWebSearch from "./mcp-websearch" import DESCRIPTION from "./websearch.txt" +import { Flag } from "@opencode-ai/core/flag/flag" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({ }), }) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + yield* ctx.ask({ permission: "websearch", patterns: [params.query], @@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define( livecrawl: params.livecrawl, type: params.type, contextMaxCharacters: params.contextMaxCharacters, + provider, }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = yield* callProvider(http, provider, params, ctx) return { output: result ?? "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, + title: `${title}: ${params.query}`, + metadata: { provider }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 551c0f3b59..ad5238cbd5 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,12 +1,12 @@ -- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data - Supports configurable result counts and returns the content from the most relevant websites - Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) - - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) - Configurable context length for optimal LLM integration - Domain filtering and advanced search options available diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts new file mode 100644 index 0000000000..477fe2b428 --- /dev/null +++ b/packages/opencode/test/tool/websearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { parseResponse } from "../../src/tool/mcp-websearch" +import { + selectWebSearchProvider, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" +import { ProviderID } from "../../src/provider/schema" +import { webSearchEnabled } from "../../src/tool/registry" + +const SESSION_ID = "ses_0196aabbccddeeff001122334455" + +describe("websearch provider", () => { + test("selects a stable provider per session", () => { + expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID)) + }) + + test("supports an operational override", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel" + expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" + expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } + }) + + test("routes to Exa when the Exa flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa") + }) + + test("routes to Parallel when the Parallel flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") + }) + + test("is only enabled for opencode or explicit websearch provider flags", () => { + expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + }) + + test("uses branded labels", () => { + expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") + expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(undefined)).toBe("Web Search") + }) + + test("uses the provider API model id for Parallel analytics", () => { + expect( + webSearchModelName({ + model: { + id: "claude-opus-4-7", + api: { id: "claude-opus-4.7" }, + }, + }), + ).toBe("claude-opus-4.7") + }) +}) + +describe("websearch MCP response parser", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "search results", + }, + ], + }, + }) + + test("parses plain JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") + }) + + test("parses SSE JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) + + test("ignores non-JSON SSE data frames", async () => { + await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c36a52f81e..92b6e95acc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -317,7 +317,17 @@ function taskAgent( } } -export function getToolInfo(tool: string, input: any = {}): ToolInfo { +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function getToolInfo( + tool: string, + input: any = {}, + metadata: Record | undefined = {}, +): ToolInfo { const i18n = useI18n() switch (tool) { case "read": @@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "websearch": return { icon: "window-cursor", - title: i18n.t("ui.tool.websearch"), + title: webSearchProviderLabel(metadata?.provider), subtitle: input.query, } case "task": { @@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) + const info = getToolInfo( + part.tool, + part.state.input ?? {}, + "metadata" in part.state ? part.state.metadata : undefined, + ) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -744,7 +758,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input) + const info = getToolInfo( + part.tool, + input, + "metadata" in part.state ? part.state.metadata : undefined, + ) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1224,6 +1242,7 @@ export interface ToolProps { input: Record metadata: Record tool: string + sessionID?: string output?: string status?: string hideDetails?: boolean @@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { { const value = props.input.query if (typeof value !== "string") return "" return value }) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( , "children" | "variant"> { tool: string error: string + title?: string defaultOpen?: boolean subtitle?: string href?: string @@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) { }) const open = () => state.open const copied = () => state.copied - const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) + const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { + if (split.title) return split.title const map: Record = { read: "ui.tool.read", list: "ui.tool.list", From edbc02855d2d359e93cc6241c2114a2e464f29ee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 08:50:42 +0000 Subject: [PATCH 091/812] chore: generate --- packages/opencode/test/tool/websearch.test.ts | 10 ++++------ packages/ui/src/components/message-part.tsx | 12 ++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts index 477fe2b428..591b385fdc 100644 --- a/packages/opencode/test/tool/websearch.test.ts +++ b/packages/opencode/test/tool/websearch.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { parseResponse } from "../../src/tool/mcp-websearch" -import { - selectWebSearchProvider, - webSearchModelName, - webSearchProviderLabel, -} from "../../src/tool/websearch" +import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch" import { ProviderID } from "../../src/provider/schema" import { webSearchEnabled } from "../../src/tool/registry" @@ -83,7 +79,9 @@ describe("websearch MCP response parser", () => { }) test("parses SSE JSON-RPC responses", async () => { - await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe( + "search results", + ) }) test("ignores non-JSON SSE data frames", async () => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 92b6e95acc..d9771671a6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -758,11 +758,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo( - part.tool, - input, - "metadata" in part.state ? part.state.metadata : undefined, - ) + const info = getToolInfo(part.tool, input, "metadata" in part.state ? part.state.metadata : undefined) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1365,11 +1361,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { Date: Fri, 8 May 2026 12:06:30 +0200 Subject: [PATCH 092/812] chore: reduce alerts threshold --- infra/monitoring.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index 313e6c1dd4..1b5d097c21 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -45,7 +45,7 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, ], - formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 500), DIV($FAILED, $TOTAL), 0)" }], + formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, }).json } @@ -86,9 +86,9 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, ], formulas: [ - { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 250), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, + { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, ], - timeRange: 1800, + timeRange: 900, }).json } @@ -100,7 +100,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { queryJson: modelHttpErrorsQuery("go"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -119,7 +119,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { queryJson: modelHttpErrorsQuery("zen"), alertType: "on_change", frequency: 300, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -138,7 +138,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", { queryJson: providerHttpErrorsQuery("go"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -157,7 +157,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", { queryJson: providerHttpErrorsQuery("zen"), alertType: "on_change", frequency: 600, - thresholds: [{ op: ">=", value: 0.9, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }], recipients: [ { id: webhookRecipient.id, @@ -184,7 +184,7 @@ new honeycomb.Trigger("IncreasedFreeTierRequests", { }).json, alertType: "on_change", frequency: 900, - thresholds: [{ op: ">=", value: 60, exceededLimit: 1 }], + thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }], baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }], recipients: [ { From 7f2b5ee8c29bfb16aeace26402b688d2ece8af25 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 8 May 2026 12:17:14 +0200 Subject: [PATCH 093/812] feat(opencode): add interactive split-footer mode to run (#23557) --- bun.lock | 30 +- package.json | 6 +- packages/opencode/src/cli/cmd/run.ts | 790 +++++---- packages/opencode/src/cli/cmd/run/demo.ts | 1281 +++++++++++++++ .../opencode/src/cli/cmd/run/entry.body.ts | 194 +++ .../src/cli/cmd/run/footer.command.tsx | 647 ++++++++ .../opencode/src/cli/cmd/run/footer.menu.tsx | 290 ++++ .../src/cli/cmd/run/footer.permission.tsx | 478 ++++++ .../src/cli/cmd/run/footer.prompt.tsx | 1108 +++++++++++++ .../src/cli/cmd/run/footer.question.tsx | 582 +++++++ .../src/cli/cmd/run/footer.subagent.tsx | 192 +++ packages/opencode/src/cli/cmd/run/footer.ts | 893 ++++++++++ .../opencode/src/cli/cmd/run/footer.view.tsx | 719 ++++++++ .../opencode/src/cli/cmd/run/keymap.shared.ts | 154 ++ packages/opencode/src/cli/cmd/run/otel.ts | 119 ++ .../src/cli/cmd/run/permission.shared.ts | 256 +++ .../opencode/src/cli/cmd/run/prompt.shared.ts | 328 ++++ .../src/cli/cmd/run/question.shared.ts | 340 ++++ .../opencode/src/cli/cmd/run/runtime.boot.ts | 214 +++ .../src/cli/cmd/run/runtime.lifecycle.ts | 308 ++++ .../opencode/src/cli/cmd/run/runtime.queue.ts | 293 ++++ .../src/cli/cmd/run/runtime.shared.ts | 17 + .../opencode/src/cli/cmd/run/runtime.stdin.ts | 37 + packages/opencode/src/cli/cmd/run/runtime.ts | 793 +++++++++ .../src/cli/cmd/run/scrollback.shared.ts | 92 ++ .../src/cli/cmd/run/scrollback.surface.ts | 391 +++++ .../src/cli/cmd/run/scrollback.writer.tsx | 330 ++++ .../opencode/src/cli/cmd/run/session-data.ts | 970 +++++++++++ .../src/cli/cmd/run/session.shared.ts | 196 +++ packages/opencode/src/cli/cmd/run/splash.ts | 302 ++++ .../src/cli/cmd/run/stream.transport.ts | 1008 ++++++++++++ packages/opencode/src/cli/cmd/run/stream.ts | 175 ++ .../opencode/src/cli/cmd/run/subagent-data.ts | 746 +++++++++ packages/opencode/src/cli/cmd/run/theme.ts | 599 +++++++ packages/opencode/src/cli/cmd/run/tool.ts | 1460 +++++++++++++++++ packages/opencode/src/cli/cmd/run/trace.ts | 94 ++ packages/opencode/src/cli/cmd/run/types.ts | 317 ++++ .../src/cli/cmd/run/variant.shared.ts | 213 +++ packages/opencode/src/cli/cmd/tui/attach.ts | 2 +- .../src/cli/cmd/tui/component/spinner.tsx | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 6 +- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../opencode/test/cli/run/entry.body.test.ts | 483 ++++++ .../opencode/test/cli/run/footer.menu.test.ts | 43 + .../test/cli/run/footer.view.test.tsx | 273 +++ .../test/cli/run/permission.shared.test.ts | 144 ++ .../test/cli/run/prompt.shared.test.ts | 132 ++ .../test/cli/run/question.shared.test.ts | 115 ++ .../test/cli/run/runtime.boot.test.ts | 303 ++++ .../test/cli/run/runtime.queue.test.ts | 318 ++++ .../test/cli/run/runtime.stdin.test.ts | 71 + .../test/cli/run/scrollback.surface.test.ts | 883 ++++++++++ .../test/cli/run/session-data.test.ts | 422 +++++ .../test/cli/run/session.shared.test.ts | 247 +++ packages/opencode/test/cli/run/stream.test.ts | 55 + .../test/cli/run/stream.transport.test.ts | 1062 ++++++++++++ .../test/cli/run/subagent-data.test.ts | 328 ++++ packages/opencode/test/cli/run/theme.test.ts | 122 ++ .../test/cli/run/variant.shared.test.ts | 214 +++ packages/plugin/package.json | 6 +- 60 files changed, 21850 insertions(+), 347 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run/demo.ts create mode 100644 packages/opencode/src/cli/cmd/run/entry.body.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.command.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.menu.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.permission.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.prompt.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.question.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.subagent.tsx create mode 100644 packages/opencode/src/cli/cmd/run/footer.ts create mode 100644 packages/opencode/src/cli/cmd/run/footer.view.tsx create mode 100644 packages/opencode/src/cli/cmd/run/keymap.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/otel.ts create mode 100644 packages/opencode/src/cli/cmd/run/permission.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/prompt.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/question.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.boot.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.queue.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.stdin.ts create mode 100644 packages/opencode/src/cli/cmd/run/runtime.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.surface.ts create mode 100644 packages/opencode/src/cli/cmd/run/scrollback.writer.tsx create mode 100644 packages/opencode/src/cli/cmd/run/session-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/session.shared.ts create mode 100644 packages/opencode/src/cli/cmd/run/splash.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.transport.ts create mode 100644 packages/opencode/src/cli/cmd/run/stream.ts create mode 100644 packages/opencode/src/cli/cmd/run/subagent-data.ts create mode 100644 packages/opencode/src/cli/cmd/run/theme.ts create mode 100644 packages/opencode/src/cli/cmd/run/tool.ts create mode 100644 packages/opencode/src/cli/cmd/run/trace.ts create mode 100644 packages/opencode/src/cli/cmd/run/types.ts create mode 100644 packages/opencode/src/cli/cmd/run/variant.shared.ts create mode 100644 packages/opencode/test/cli/run/entry.body.test.ts create mode 100644 packages/opencode/test/cli/run/footer.menu.test.ts create mode 100644 packages/opencode/test/cli/run/footer.view.test.tsx create mode 100644 packages/opencode/test/cli/run/permission.shared.test.ts create mode 100644 packages/opencode/test/cli/run/prompt.shared.test.ts create mode 100644 packages/opencode/test/cli/run/question.shared.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.boot.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.queue.test.ts create mode 100644 packages/opencode/test/cli/run/runtime.stdin.test.ts create mode 100644 packages/opencode/test/cli/run/scrollback.surface.test.ts create mode 100644 packages/opencode/test/cli/run/session-data.test.ts create mode 100644 packages/opencode/test/cli/run/session.shared.test.ts create mode 100644 packages/opencode/test/cli/run/stream.test.ts create mode 100644 packages/opencode/test/cli/run/stream.transport.test.ts create mode 100644 packages/opencode/test/cli/run/subagent-data.test.ts create mode 100644 packages/opencode/test/cli/run/theme.test.ts create mode 100644 packages/opencode/test/cli/run/variant.shared.test.ts diff --git a/bun.lock b/bun.lock index 2f21ed7d54..3e73e0c236 100644 --- a/bun.lock +++ b/bun.lock @@ -486,9 +486,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.4", - "@opentui/keymap": ">=0.2.4", - "@opentui/solid": ">=0.2.4", + "@opentui/core": ">=0.2.5", + "@opentui/keymap": ">=0.2.5", + "@opentui/solid": ">=0.2.5", }, "optionalPeers": [ "@opentui/core", @@ -667,9 +667,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1594,23 +1594,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="], + "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="], + "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], - "@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="], + "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index 15d96e131c..f2258ab698 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.4", - "@opentui/keymap": "0.2.4", - "@opentui/solid": "0.2.4", + "@opentui/core": "0.2.5", + "@opentui/keymap": "0.2.5", + "@opentui/solid": "0.2.5", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 5c38c2871f..bca89c3cab 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,3 +1,16 @@ +// CLI entry point for `opencode run`. +// +// Handles three modes: +// 1. Non-interactive (default): sends a single prompt, streams events to +// stdout, and exits when the session goes idle. +// 2. Interactive local (`--interactive`): boots the split-footer direct mode +// with an in-process server (no external HTTP). +// 3. Interactive attach (`--interactive --attach`): connects to a running +// opencode server and runs interactive mode against it. +// +// Also supports `--command` for slash-command execution, `--format json` for +// raw event streaming, `--continue` / `--session` for session resumption, +// and `--fork` for forking before continuing. import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" @@ -9,38 +22,39 @@ import { ServerAuth } from "@/server/auth" import { EOL } from "os" 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/provider" -import { Agent } from "../../agent/agent" -import { Permission } from "../../permission" -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 { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" -import { TaskTool } from "../../tool/task" -import { SkillTool } from "../../tool/skill" -import { ShellTool } from "../../tool/shell" -import { ShellID } from "../../tool/shell/id" -import { TodoWriteTool } from "../../tool/todo" -import { Locale } from "@/util/locale" +import { Agent } from "@/agent/agent" +import { Permission } from "@/permission" +import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -type ToolProps = { - input: Tool.InferParameters - metadata: Tool.InferMetadata - part: ToolPart +const runtimeTask = import("./run/runtime") +type ModelInput = Parameters[0]["model"] + +function pick(value: string | undefined): ModelInput | undefined { + if (!value) return undefined + const [providerID, ...rest] = value.split("/") + return { + providerID, + modelID: rest.join("/"), + } as ModelInput } -function props(part: ToolPart): ToolProps { - const state = part.state - return { - input: state.input as Tool.InferParameters, - metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, - part, +function resolveRunInput(value?: string, piped?: string): string | undefined { + if (!value) { + return piped } + + if (!piped) { + return value + } + + return value + "\n" + piped +} + +type FilePart = { + type: "file" + url: string + filename: string + mime: string } type Inline = { @@ -49,6 +63,12 @@ type Inline = { description?: string } +type SessionInfo = { + id: string + title?: string + directory?: string +} + function inline(info: Inline) { const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) @@ -62,145 +82,40 @@ function block(info: Inline, output?: string) { UI.empty() } -function fallback(part: ToolPart) { - const state = part.state - const input = "input" in state ? state.input : undefined - const title = - ("title" in state && state.title ? state.title : undefined) || - (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") - inline({ - icon: "⚙", - title: `${part.tool} ${title}`, - }) +async function tool(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + if (next.mode === "block") { + block(next, next.body) + return + } + + inline(next) + } catch { + inline({ + icon: "\u2699", + title: part.tool, + }) + } } -function glob(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Glob "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.count - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function grep(info: ToolProps) { - const root = info.input.path ?? "" - const title = `Grep "${info.input.pattern}"` - const suffix = root ? `in ${normalizePath(root)}` : "" - const num = info.metadata.matches - const description = - num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` - inline({ - icon: "✱", - title, - ...(description && { description }), - }) -} - -function read(info: ToolProps) { - const file = normalizePath(info.input.filePath) - const pairs = Object.entries(info.input).filter(([key, value]) => { - if (key === "filePath") return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined - inline({ - icon: "→", - title: `Read ${file}`, - ...(description && { description }), - }) -} - -function write(info: ToolProps) { - block( - { - icon: "←", - title: `Write ${normalizePath(info.input.filePath)}`, - }, - info.part.state.status === "completed" ? info.part.state.output : undefined, - ) -} - -function webfetch(info: ToolProps) { - inline({ - icon: "%", - title: `WebFetch ${info.input.url}`, - }) -} - -function edit(info: ToolProps) { - const title = normalizePath(info.input.filePath) - const diff = info.metadata.diff - block( - { - icon: "←", - title: `Edit ${title}`, - }, - diff, - ) -} - -function websearch(info: ToolProps) { - inline({ - icon: "◈", - title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, - }) -} - -function task(info: ToolProps) { - const input = info.part.state.input - const status = info.part.state.status - const subagent = - typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown" - const agent = Locale.titlecase(subagent) - const desc = - typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined - const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓" - const name = desc ?? `${agent} Task` - inline({ - icon, - title: name, - description: desc ? `${agent} Agent` : undefined, - }) -} - -function skill(info: ToolProps) { - inline({ - icon: "→", - title: `Skill "${info.input.name}"`, - }) -} - -function shell(info: ToolProps) { - const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined - block( - { - icon: "$", - title: `${info.input.command}`, - }, - output, - ) -} - -function todo(info: ToolProps) { - block( - { - icon: "#", - title: "Todos", - }, - info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), - ) -} - -function normalizePath(input?: string) { - if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input +async function toolError(part: ToolPart) { + try { + const { toolInlineInfo } = await import("./run/tool") + const next = toolInlineInfo(part) + inline({ + icon: "✗", + title: `${next.title} failed`, + ...(next.description && { description: next.description }), + }) + return + } catch { + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + } } export const RunCommand = effectCmd({ @@ -296,38 +211,98 @@ export const RunCommand = effectCmd({ .option("thinking", { type: "boolean", describe: "show thinking blocks", + }) + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", default: false, }) .option("dangerously-skip-permissions", { type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, + }) + .option("demo", { + type: "boolean", + default: false, + describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { + const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") + const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false) + const die = (message: string): never => { + UI.error(message) + process.exit(1) + } + const dieInteractive = (error: unknown): never => { + if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) { + die(error.message) + } + + throw error + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir + if (args.interactive && args.command) { + die("--interactive cannot be used with --command") + } + + if (args.demo && !args.interactive) { + die("--demo requires --interactive") + } + + if (args.interactive && args.format === "json") { + die("--interactive cannot be used with --format json") + } + + if (args.interactive && !process.stdout.isTTY) { + die("--interactive requires a TTY stdout") + } + + if (args.interactive) { try { - process.chdir(args.dir) + resolveInteractiveStdin().cleanup?.() + } catch (error) { + dieInteractive(error) + } + } + + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const directory = (() => { + if (!args.dir) return args.attach ? undefined : root + if (args.attach) return args.dir + + try { + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = args.attach + ? ServerAuth.headers({ password: args.password, username: args.username }) + : undefined + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -344,9 +319,11 @@ export const RunCommand = effectCmd({ } } - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + message = resolveRunInput(message, piped) ?? "" + const initialInput = resolveRunInput(rawMessage, piped) - if (message.trim().length === 0 && !args.command) { + if (message.trim().length === 0 && !args.command && !args.interactive) { UI.error("You must provide a message or a command") process.exit(1) } @@ -356,23 +333,25 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] + const rules: Permission.Ruleset = args.interactive + ? [] + : [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] function title() { if (args.title === undefined) return @@ -380,19 +359,83 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await sdk.session + .get({ + sessionID: args.session, + }) + .catch(() => undefined) - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + if (!current?.data) { + UI.error("Session not found") + process.exit(1) + } + + if (args.fork) { + const forked = await sdk.session.fork({ + sessionID: args.session, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, + } + } + + return { + id: current.data.id, + title: current.data.title, + directory: current.data.directory, + } } - if (baseID) return baseID + const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + + if (base && args.fork) { + const forked = await sdk.session.fork({ + sessionID: base.id, + }) + const id = forked.data?.id + if (!id) { + return + } + + return { + id, + title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, + } + } + + if (base) { + return { + id: base.id, + title: base.title, + directory: base.directory, + } + } const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id + const result = await sdk.session.create({ + title: name, + permission: rules, + }) + const id = result.data?.id + if (!id) { + return + } + + return { + id, + title: result.data?.title ?? name, + directory: result.data?.directory, + } } async function share(sdk: OpencodeClient, sessionID: string) { @@ -410,43 +453,159 @@ export const RunCommand = effectCmd({ } } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } + async function createFreshSession( + sdk: OpencodeClient, + input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, + ): Promise { + const result = await sdk.session.create({ + title: args.title !== undefined && args.title !== "" ? args.title : undefined, + agent: input.agent, + model: input.model + ? { + providerID: input.model.providerID, + id: input.model.modelID, + variant: input.variant, + } + : undefined, + permission: rules, + }) + const id = result.data?.id + if (!id) { + throw new Error("Failed to create session") } + void share(sdk, id).catch(() => {}) + return { + id, + title: result.data?.title, + } + } + + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + + async function localAgent() { + if (!args.agent) return undefined + const name = args.agent + + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + if (entry.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return name + } + + async function attachAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + const name = args.agent + + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name + } + + async function pickAgent(sdk: OpencodeClient) { + if (!args.agent) return undefined + if (args.attach) { + return attachAgent(sdk) + } + + return localAgent() + } + + async function execute(sdk: OpencodeClient) { + const sess = await session(sdk) + if (!sess?.id) { + UI.error("Session not found") + process.exit(1) + } + const sessionID = sess.id + function emit(type: string, data: Record) { if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + process.stdout.write( + JSON.stringify({ + type, + timestamp: Date.now(), + sessionID, + ...data, + }) + EOL, + ) return true } return false } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() + let error: string | undefined for await (const event of events.stream) { if ( event.type === "message.updated" && + event.properties.sessionID === sessionID && event.properties.info.role === "assistant" && args.format !== "json" && toggles.get("start") !== true @@ -464,16 +623,10 @@ export const RunCommand = effectCmd({ if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { - tool(part) + await tool(part) continue } - inline({ - icon: "✗", - title: - part.tool === "websearch" - ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` - : `${part.tool} failed`, - }) + await toolError(part) UI.error(part.state.error) } @@ -484,7 +637,7 @@ export const RunCommand = effectCmd({ args.format !== "json" ) { if (toggles.get(part.id) === true) continue - task(props(part)) + await tool(part) toggles.set(part.id, true) } @@ -509,7 +662,7 @@ export const RunCommand = effectCmd({ UI.empty() } - if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (part.type === "reasoning" && part.time?.end && thinking) { if (emit("reasoning", { part })) continue const text = part.text.trim() if (!text) continue @@ -549,7 +702,7 @@ export const RunCommand = effectCmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -559,7 +712,7 @@ export const RunCommand = effectCmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -567,114 +720,113 @@ export const RunCommand = effectCmd({ } } } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + const agent = await pickAgent(client) - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + await share(client, sessionID) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined - } - - const agent = modes.find((a) => a.name === name) - if (!agent) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - - if (agent.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - - return name - } - - const entry = await Effect.runPromise(agentSvc.get(name)) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, + if (!args.interactive) { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { + console.error(e) + process.exit(1) }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + + if (args.command) { + await client.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + return + } + + const model = pick(args.model) + await client.session.prompt({ sessionID, agent, model, variant: args.variant, parts: [...files, { type: "text", text: message }], }) + return + } + + const model = pick(args.model) + const { runInteractiveMode } = await runtimeTask + try { + await runInteractiveMode({ + sdk: client, + directory: cwd, + sessionID, + sessionTitle: sess.title, + resume: Boolean(args.session || args.continue) && !args.fork, + agent, + model, + variant: args.variant, + files, + initialInput, + createSession: createFreshSession, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) + } + return + } + + if (args.interactive && !args.attach && !args.session && !args.continue) { + const model = pick(args.model) + const { runInteractiveLocalMode } = await runtimeTask + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + + try { + return await runInteractiveLocalMode({ + directory: directory ?? root, + fetch: fetchFn, + resolveAgent: localAgent, + session, + share, + createSession: createFreshSession, + agent: args.agent, + model, + variant: args.variant, + files, + initialInput, + thinking, + demo: args.demo, + }) + } catch (error) { + dieInteractive(error) } } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + const sdk = attachSDK(directory) return await execute(sdk) } const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const { Server } = await import("@/server/server") const request = new Request(input, init) return Server.Default().app.fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ + baseUrl: "http://opencode.internal", + fetch: fetchFn, + directory, + }) await execute(sdk) }) }), diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts new file mode 100644 index 0000000000..195ef6f496 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -0,0 +1,1281 @@ +// Demo mode for testing direct interactive mode without a real SDK. +// +// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic +// SDK events that feed through the real reducer and footer pipeline. This +// lets you test scrollback formatting, permission UI, question UI, and tool +// snapshots without making actual model calls. Pass a demo slash command as +// the initial interactive message to trigger a preview immediately. +// +// Slash commands: +// /permission [kind] → triggers a permission request variant +// /question [kind] → triggers a question request variant +// /fmt → emits a specific tool/text type (text, reasoning, bash, +// write, edit, patch, task, todo, question, error, mix) +// +// Demo mode also handles permission and question replies locally, completing +// or failing the synthetic tool parts as appropriate. +import path from "path" +import type { Event, ToolPart } from "@opencode-ai/sdk/v2" +import { createSessionData, reduceSessionData, type SessionData } from "./session-data" +import { writeSessionOutput } from "./stream" +import type { + FooterApi, + PermissionReply, + QuestionReject, + QuestionReply, + RunPrompt, + StreamCommit, +} from "./types" + +const KINDS = [ + "markdown", + "table", + "text", + "reasoning", + "bash", + "write", + "edit", + "patch", + "task", + "todo", + "question", + "error", + "mix", +] +const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const +const QUESTIONS = ["multi", "single", "checklist", "custom"] as const + +type PermissionKind = (typeof PERMISSIONS)[number] +type QuestionKind = (typeof QUESTIONS)[number] + +function permissionKind(value: string | undefined): PermissionKind | undefined { + const next = (value || "edit").toLowerCase() + return PERMISSIONS.find((item) => item === next) +} + +function questionKind(value: string | undefined): QuestionKind | undefined { + const next = (value || "multi").toLowerCase() + return QUESTIONS.find((item) => item === next) +} + +const SAMPLE_MARKDOWN = [ + "# Direct Mode Demo", + "", + "This is a realistic assistant response for direct-mode formatting checks.", + "It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.", + "", + "## Summary", + "", + "- Restored the final markdown flush so the last block is committed on idle.", + "- Switched markdown scrollback commits back to top-level block boundaries.", + "- Added footer-level regression coverage for split-footer rendering.", + "", + "## Status", + "", + "| Area | Before | After | Notes |", + "| --- | --- | --- | --- |", + "| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |", + "| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |", + "| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |", + "", + "> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.", + "", + "```ts", + "const result = { markdown: true, tables: 2, stable: true }", + "```", + "", + "## Files", + "", + "| File | Change |", + "| --- | --- |", + "| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |", + "| `footer.ts` | Keep active surfaces across footer-height-only resizes |", + "| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |", + "", + "Next step: run `/fmt table` if you want a tighter table-only sample.", +].join("\n") + +const SAMPLE_TABLE = [ + "# Table Sample", + "", + "| Kind | Example | Notes |", + "| --- | --- | --- |", + "| Pipe | `A\\|B` | Escaped pipes should stay in one cell |", + "| Unicode | `漢字` | Wide characters should remain aligned |", + "| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |", + "| Status | done | Final row should still appear after idle |", +].join("\n") + +type Ref = { + msg: string + part: string + call: string + tool: string + input: Record + start: number +} + +type Ask = { + ref: Ref +} + +type Perm = { + ref: Ref + done: { + title: string + output: string + metadata?: Record + } +} + +type Permit = { + ref: Ref + permission: string + patterns: string[] + metadata?: Record + always: string[] + done: Perm["done"] +} + +type State = { + id: string + thinking: boolean + data: SessionData + footer: FooterApi + limits: () => Record + msg: number + part: number + call: number + perm: number + ask: number + perms: Map + asks: Map +} + +type Input = { + sessionID: string + thinking: boolean + limits: () => Record + footer: FooterApi +} + +function note(footer: FooterApi, text: string): void { + footer.append({ + kind: "system", + text, + phase: "start", + source: "system", + }) +} + +function clearSubagent(footer: FooterApi): void { + footer.event({ + type: "stream.subagent", + state: { + tabs: [], + details: {}, + permissions: [], + questions: [], + }, + }) +} + +function showSubagent( + state: State, + input: { + sessionID: string + partID: string + callID: string + label: string + description: string + status: "running" | "completed" | "error" + title?: string + toolCalls?: number + commits: StreamCommit[] + }, +) { + state.footer.event({ + type: "stream.subagent", + state: { + tabs: [ + { + sessionID: input.sessionID, + partID: input.partID, + callID: input.callID, + label: input.label, + description: input.description, + status: input.status, + title: input.title, + toolCalls: input.toolCalls, + lastUpdatedAt: Date.now(), + }, + ], + details: { + [input.sessionID]: { + sessionID: input.sessionID, + commits: input.commits, + }, + }, + permissions: [], + questions: [], + }, + }) +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!signal) { + setTimeout(resolve, ms) + return + } + + if (signal.aborted) { + resolve() + return + } + + const done = () => { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + + const timer = setTimeout(() => { + signal.removeEventListener("abort", done) + resolve() + }, ms) + + signal.addEventListener("abort", done, { once: true }) + }) +} + +function split(text: string): string[] { + if (text.length <= 48) { + return [text] + } + + const size = Math.ceil(text.length / 3) + return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)] +} + +function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string { + state[key] += 1 + return `demo_${prefix}_${state[key]}` +} + +function feed(state: State, event: Event): void { + const out = reduceSessionData({ + data: state.data, + event, + sessionID: state.id, + thinking: state.thinking, + limits: state.limits(), + }) + state.data = out.data + writeSessionOutput( + { + footer: state.footer, + }, + out, + ) +} + +function open(state: State): string { + const id = take(state, "msg", "msg") + feed(state, { + type: "message.updated", + properties: { + sessionID: state.id, + info: { + id, + sessionID: state.id, + role: "assistant", + time: { + created: Date.now(), + }, + parentID: `user_${id}`, + modelID: "demo", + providerID: "demo", + mode: "demo", + agent: "demo", + path: { + cwd: process.cwd(), + root: process.cwd(), + }, + cost: 0.001, + tokens: { + input: 120, + output: 320, + reasoning: 80, + cache: { + read: 0, + write: 0, + }, + }, + }, + }, + } as Event) + return id +} + +async function emitText(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "text", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise { + const msg = open(state) + const part = take(state, "part", "part") + const start = Date.now() + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: "", + time: { + start, + }, + }, + }, + } as Event) + + let next = "" + for (const item of split(body)) { + if (signal?.aborted) { + return + } + + next += item + feed(state, { + type: "message.part.delta", + properties: { + sessionID: state.id, + messageID: msg, + partID: part, + field: "text", + delta: item, + }, + } as Event) + await wait(45, signal) + } + + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: part, + sessionID: state.id, + messageID: msg, + type: "reasoning", + text: next, + time: { + start, + end: Date.now(), + }, + }, + }, + } as Event) +} + +function make(state: State, tool: string, input: Record): Ref { + return { + msg: open(state), + part: take(state, "part", "part"), + call: take(state, "call", "call"), + tool, + input, + start: Date.now(), + } +} + +function startTool(state: State, ref: Ref, metadata: Record = {}): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "running", + input: ref.input, + metadata, + time: { + start: ref.start, + }, + }, + }, + }, + } as Event) +} + +function askPermission(state: State, item: Permit): void { + startTool(state, item.ref) + + const id = take(state, "perm", "perm") + state.perms.set(id, { + ref: item.ref, + done: item.done, + }) + + feed(state, { + type: "permission.asked", + properties: { + id, + sessionID: state.id, + permission: item.permission, + patterns: item.patterns, + metadata: item.metadata ?? {}, + always: item.always, + tool: { + messageID: item.ref.msg, + callID: item.ref.call, + }, + }, + } as Event) +} + +function doneTool( + state: State, + ref: Ref, + output: { + title: string + output: string + metadata?: Record + }, +): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "completed", + input: ref.input, + output: output.output, + title: output.title, + metadata: output.metadata ?? {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function failTool(state: State, ref: Ref, error: string): void { + feed(state, { + type: "message.part.updated", + properties: { + sessionID: state.id, + time: Date.now(), + part: { + id: ref.part, + sessionID: state.id, + messageID: ref.msg, + type: "tool", + callID: ref.call, + tool: ref.tool, + state: { + status: "error", + input: ref.input, + error, + metadata: {}, + time: { + start: ref.start, + end: Date.now(), + }, + }, + }, + }, + } as Event) +} + +function emitError(state: State, text: string): void { + const event = { + id: `session.error:${state.id}:${Date.now()}`, + type: "session.error", + properties: { + sessionID: state.id, + error: { + name: "UnknownError", + data: { + message: text, + }, + }, + }, + } satisfies Event + feed(state, event) +} + +async function emitBash(state: State, signal?: AbortSignal): Promise { + const ref = make(state, "bash", { + command: "git status", + workdir: process.cwd(), + description: "Show git status", + }) + startTool(state, ref) + await wait(70, signal) + doneTool(state, ref, { + title: "git status", + output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`, + metadata: { + exitCode: 0, + }, + }) +} + +function emitWrite(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "write", { + filePath: file, + content: "export const demo = 42\n", + }) + doneTool(state, ref, { + title: "write", + output: "", + metadata: {}, + }) +} + +function emitEdit(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "edit", { + filePath: file, + }) + doneTool(state, ref, { + title: "edit", + output: "", + metadata: { + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + }, + }) +} + +function emitPatch(state: State): void { + const file = path.join(process.cwd(), "src", "demo-format.ts") + const ref = make(state, "apply_patch", { + patchText: "*** Begin Patch\n*** End Patch", + }) + doneTool(state, ref, { + title: "apply_patch", + output: "", + metadata: { + files: [ + { + type: "update", + filePath: file, + relativePath: "src/demo-format.ts", + diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n", + deletions: 1, + }, + { + type: "add", + filePath: path.join(process.cwd(), "README-demo.md"), + relativePath: "README-demo.md", + diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n", + deletions: 0, + }, + ], + }, + }) +} + +function emitTask(state: State): void { + const ref = make(state, "task", { + description: "Scan run/* for reducer touchpoints", + subagent_type: "explore", + }) + doneTool(state, ref, { + title: "Reducer touchpoints found", + output: "", + metadata: { + toolcalls: 4, + sessionId: "sub_demo_1", + }, + }) + const part = { + id: "sub_demo_tool_1", + type: "tool", + sessionID: "sub_demo_1", + messageID: "sub_demo_msg_tool", + callID: "sub_demo_call_1", + tool: "read", + state: { + status: "running", + input: { + filePath: "packages/opencode/src/cli/cmd/run/stream.ts", + offset: 1, + limit: 200, + }, + time: { + start: Date.now(), + }, + }, + } satisfies ToolPart + showSubagent(state, { + sessionID: "sub_demo_1", + partID: ref.part, + callID: ref.call, + label: "Explore", + description: "Scan run/* for reducer touchpoints", + status: "completed", + title: "Reducer touchpoints found", + toolCalls: 4, + commits: [ + { + kind: "user", + text: "Scan run/* for reducer touchpoints", + phase: "start", + source: "system", + }, + { + kind: "reasoning", + text: "Thinking: tracing reducer and footer boundaries", + phase: "progress", + source: "reasoning", + messageID: "sub_demo_msg_reasoning", + partID: "sub_demo_reasoning_1", + }, + { + kind: "tool", + text: "running read", + phase: "start", + source: "tool", + messageID: "sub_demo_msg_tool", + partID: "sub_demo_tool_1", + tool: "read", + part, + }, + { + kind: "assistant", + text: "Footer updates flow through stream.ts into RunFooter", + phase: "progress", + source: "assistant", + messageID: "sub_demo_msg_text", + partID: "sub_demo_text_1", + }, + ], + }) +} + +function emitTodo(state: State): void { + const ref = make(state, "todowrite", { + todos: [ + { + content: "Trigger permission UI", + status: "completed", + }, + { + content: "Trigger question UI", + status: "in_progress", + }, + { + content: "Tune tool formatting", + status: "pending", + }, + ], + }) + doneTool(state, ref, { + title: "todowrite", + output: "", + metadata: {}, + }) +} + +function emitQuestionTool(state: State): void { + const ref = make(state, "question", { + questions: [ + { + header: "Style", + question: "Which output style do you want to inspect?", + options: [ + { label: "Diff", description: "Show diff block" }, + { label: "Code", description: "Show code block" }, + ], + multiple: false, + }, + { + header: "Extras", + question: "Pick extra rows", + options: [ + { label: "Usage", description: "Add usage row" }, + { label: "Duration", description: "Add duration row" }, + ], + multiple: true, + custom: true, + }, + ], + }) + doneTool(state, ref, { + title: "question", + output: "", + metadata: { + answers: [["Diff"], ["Usage", "custom-note"]], + }, + }) +} + +function emitPermission(state: State, kind: PermissionKind = "edit"): void { + const root = process.cwd() + const file = path.join(root, "src", "demo-format.ts") + + if (kind === "bash") { + const command = "git status --short" + const ref = make(state, "bash", { + command, + workdir: root, + description: "Inspect worktree changes", + }) + askPermission(state, { + ref, + permission: "bash", + patterns: [command], + always: ["*"], + done: { + title: "git status --short", + output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`, + metadata: { + exitCode: 0, + }, + }, + }) + return + } + + if (kind === "read") { + const target = path.join(root, "package.json") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 80, + }) + askPermission(state, { + ref, + permission: "read", + patterns: [target], + always: [target], + done: { + title: "read", + output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"), + metadata: {}, + }, + }) + return + } + + if (kind === "task") { + const ref = make(state, "task", { + description: "Inspect footer spacing across direct-mode prompts", + subagent_type: "explore", + }) + askPermission(state, { + ref, + permission: "task", + patterns: ["explore"], + always: ["*"], + done: { + title: "Footer spacing checked", + output: "", + metadata: { + toolcalls: 3, + sessionId: "sub_demo_perm_1", + }, + }, + }) + return + } + + if (kind === "external") { + const dir = path.join(path.dirname(root), "demo-shared") + const target = path.join(dir, "README.md") + const ref = make(state, "read", { + filePath: target, + offset: 1, + limit: 40, + }) + askPermission(state, { + ref, + permission: "external_directory", + patterns: [`${dir}/**`], + metadata: { + parentDir: dir, + filepath: target, + }, + always: [`${dir}/**`], + done: { + title: "read", + output: `1: # External demo\n2: Shared preview file\nPath: ${target}`, + metadata: {}, + }, + }) + return + } + + if (kind === "doom") { + const ref = make(state, "task", { + description: "Retry the formatter after repeated failures", + subagent_type: "general", + }) + askPermission(state, { + ref, + permission: "doom_loop", + patterns: ["*"], + always: ["*"], + done: { + title: "Retry allowed", + output: "Continuing after repeated failures.\n", + metadata: {}, + }, + }) + return + } + + const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n" + const ref = make(state, "edit", { + filePath: file, + filepath: file, + diff, + }) + askPermission(state, { + ref, + permission: "edit", + patterns: [file], + always: [file], + done: { + title: "edit", + output: "", + metadata: { + diff, + }, + }, + }) +} + +function emitQuestion(state: State, kind: QuestionKind = "multi"): void { + const questions = (() => { + if (kind === "single") { + return [ + { + header: "Mode", + question: "Which footer should be the reference for spacing checks?", + options: [ + { label: "Permission", description: "Inspect the permission footer" }, + { label: "Question", description: "Keep this question footer open" }, + { label: "Prompt", description: "Return to the normal composer" }, + ], + multiple: false, + custom: false, + }, + ] + } + + if (kind === "checklist") { + return [ + { + header: "Checks", + question: "Select the direct-mode cases you want to inspect next", + options: [ + { label: "Diff", description: "Show an edit diff in the footer" }, + { label: "Task", description: "Show a structured task summary" }, + { label: "Todo", description: "Show a todo snapshot" }, + { label: "Error", description: "Show an error transcript row" }, + ], + multiple: true, + custom: false, + }, + ] + } + + if (kind === "custom") { + return [ + { + header: "Reply", + question: "What custom answer should appear in the footer preview?", + options: [ + { label: "Short note", description: "Keep the answer to one line" }, + { label: "Wrapped note", description: "Use a longer answer to test wrapping" }, + ], + multiple: false, + custom: true, + }, + ] + } + + return [ + { + header: "Layout", + question: "Which footer view should stay active while testing?", + options: [ + { label: "Prompt", description: "Return to prompt" }, + { label: "Question", description: "Keep question open" }, + ], + multiple: false, + }, + { + header: "Rows", + question: "Pick formatting previews", + options: [ + { label: "Diff", description: "Emit edit diff" }, + { label: "Task", description: "Emit task card" }, + { label: "Todo", description: "Emit todo card" }, + ], + multiple: true, + custom: true, + }, + ] + })() + + const ref = make(state, "question", { questions }) + startTool(state, ref) + + const id = take(state, "ask", "ask") + state.asks.set(id, { ref }) + + feed(state, { + type: "question.asked", + properties: { + id, + sessionID: state.id, + questions, + tool: { + messageID: ref.msg, + callID: ref.call, + }, + }, + } as Event) +} + +async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise { + if (kind === "text") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "markdown" || kind === "md") { + await emitText(state, body || SAMPLE_MARKDOWN, signal) + return true + } + + if (kind === "table") { + await emitText(state, body || SAMPLE_TABLE, signal) + return true + } + + if (kind === "reasoning") { + await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal) + return true + } + + if (kind === "bash") { + await emitBash(state, signal) + return true + } + + if (kind === "write") { + emitWrite(state) + return true + } + + if (kind === "edit") { + emitEdit(state) + return true + } + + if (kind === "patch") { + emitPatch(state) + return true + } + + if (kind === "task") { + emitTask(state) + return true + } + + if (kind === "todo") { + emitTodo(state) + return true + } + + if (kind === "question") { + emitQuestionTool(state) + return true + } + + if (kind === "error") { + emitError(state, body || "demo error event") + return true + } + + if (kind === "mix") { + await emitText(state, SAMPLE_MARKDOWN, signal) + await wait(50, signal) + await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal) + await wait(50, signal) + await emitBash(state, signal) + emitWrite(state) + emitEdit(state) + emitPatch(state) + emitTask(state) + emitTodo(state) + emitQuestionTool(state) + emitError(state, "demo mixed scenario error") + return true + } + + return false +} + +function intro(state: State): void { + note( + state.footer, + [ + "Demo slash commands enabled for interactive mode.", + `- /permission [kind] (${PERMISSIONS.join(", ")})`, + `- /question [kind] (${QUESTIONS.join(", ")})`, + `- /fmt (${KINDS.join(", ")})`, + "Examples:", + "- /permission bash", + "- /question custom", + "- /fmt markdown", + "- /fmt table", + "- /fmt text your custom text", + ].join("\n"), + ) +} + +export function createRunDemo(input: Input) { + const state: State = { + id: input.sessionID, + thinking: input.thinking, + data: createSessionData(), + footer: input.footer, + limits: input.limits, + msg: 0, + part: 0, + call: 0, + perm: 0, + ask: 0, + perms: new Map(), + asks: new Map(), + } + + const start = async (): Promise => { + intro(state) + } + + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() + const list = text.split(/\s+/) + const cmd = list[0] || "" + + clearSubagent(state.footer) + + if (cmd === "/help") { + intro(state) + return true + } + + if (cmd === "/permission") { + const kind = permissionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`) + return true + } + + emitPermission(state, kind) + return true + } + + if (cmd === "/question") { + const kind = questionKind(list[1]) + if (!kind) { + note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`) + return true + } + + emitQuestion(state, kind) + return true + } + + if (cmd === "/fmt") { + const kind = (list[1] || "").toLowerCase() + const body = list.slice(2).join(" ") + if (!kind) { + note(state.footer, `Pick a kind: ${KINDS.join(", ")}`) + return true + } + + const ok = await emitFmt(state, kind, body, signal) + if (ok) { + return true + } + + note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`) + return true + } + + return false + } + + const permission = (input: PermissionReply): boolean => { + const item = state.perms.get(input.requestID) + if (!item || !input.reply) { + return false + } + + state.perms.delete(input.requestID) + const event = { + id: `permission.replied:${input.requestID}:${Date.now()}`, + type: "permission.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + reply: input.reply, + }, + } satisfies Event + feed(state, event) + + if (input.reply === "reject") { + failTool(state, item.ref, input.message || "permission rejected") + return true + } + + doneTool(state, item.ref, item.done) + return true + } + + const questionReply = (input: QuestionReply): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask || !input.answers) { + return false + } + + state.asks.delete(input.requestID) + const event = { + id: `question.replied:${input.requestID}:${Date.now()}`, + type: "question.replied", + properties: { + sessionID: state.id, + requestID: input.requestID, + answers: input.answers, + }, + } satisfies Event + feed(state, event) + doneTool(state, ask.ref, { + title: "question", + output: "", + metadata: { + answers: input.answers, + }, + }) + return true + } + + const questionReject = (input: QuestionReject): boolean => { + const ask = state.asks.get(input.requestID) + if (!ask) { + return false + } + + state.asks.delete(input.requestID) + feed(state, { + type: "question.rejected", + properties: { + sessionID: state.id, + requestID: input.requestID, + }, + } as Event) + failTool(state, ask.ref, "question rejected") + return true + } + + return { + start, + prompt, + permission, + questionReply, + questionReject, + } +} diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts new file mode 100644 index 0000000000..bb058e8a37 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -0,0 +1,194 @@ +import { toolEntryBody } from "./tool" +import type { RunEntryBody, StreamCommit } from "./types" + +export type EntryFlags = { + startOnNewLine: boolean + trailingNewline: boolean +} + +export const RUN_ENTRY_NONE: RunEntryBody = { + type: "none", +} + +export function cleanRunText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +function textBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "text", + content, + } +} + +function codeBody(content: string, filetype?: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "code", + content, + filetype, + } +} + +function markdownBody(content: string): RunEntryBody { + if (!content) { + return RUN_ENTRY_NONE + } + + return { + type: "markdown", + content, + } +} + +function userBody(raw: string): RunEntryBody { + if (!raw.trim()) { + return RUN_ENTRY_NONE + } + + const lead = raw.match(/^\n+/)?.[0] ?? "" + const body = lead ? raw.slice(lead.length) : raw + return textBody(`${lead}› ${body}`) +} + +function reasoningBody(raw: string): RunEntryBody { + const clean = raw.replace(/\[REDACTED\]/g, "") + if (!clean) { + return RUN_ENTRY_NONE + } + + const lead = clean.match(/^\n+/)?.[0] ?? "" + const body = lead ? clean.slice(lead.length) : clean + const mark = "Thinking:" + if (body.startsWith(mark)) { + return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") + } + + return codeBody(clean, "markdown") +} + +function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { + return textBody(phase === "progress" ? raw : raw.trim()) +} + +export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.kind === "user") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + if (commit.kind === "tool") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "assistant" || commit.kind === "reasoning") { + if (commit.phase === "progress") { + return { + startOnNewLine: false, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } + } + + if (commit.kind === "error") { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + + return { + startOnNewLine: true, + trailingNewline: true, + } +} + +export function entryDone(commit: StreamCommit): boolean { + if (commit.kind === "assistant" || commit.kind === "reasoning") { + return commit.phase === "final" + } + + if (commit.kind === "tool") { + return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed") + } + + return true +} + +export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean { + if (commit.phase !== "progress") { + return false + } + + if (body.type === "none") { + return false + } + + if (commit.kind === "tool") { + return commit.toolState !== "completed" + } + + return commit.kind === "assistant" || commit.kind === "reasoning" +} + +export function entryBody(commit: StreamCommit): RunEntryBody { + const raw = cleanRunText(commit.text) + + if (commit.kind === "user") { + return userBody(raw) + } + + if (commit.kind === "tool") { + return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE + } + + if (commit.kind === "assistant") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE + } + + return markdownBody(raw) + } + + if (commit.kind === "reasoning") { + if (commit.phase === "start") { + return RUN_ENTRY_NONE + } + + if (commit.phase === "final") { + return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE + } + + return reasoningBody(raw) + } + + return systemBody(raw, commit.phase) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx new file mode 100644 index 0000000000..9e964d02bc --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -0,0 +1,647 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/core" +import { useKeyboard, type JSX } from "@opentui/solid" +import fuzzysort from "fuzzysort" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" +import { formatBindings } from "./keymap.shared" +import type { RunFooterTheme } from "./theme" +import type { FooterKeybinds, RunCommand, RunInput, RunProvider } from "./types" + +type PanelEntry = RunFooterMenuItem & { + category: string + keywords?: string +} + +type CommandEntry = + | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "variant.cycle" }) + | (PanelEntry & { action: "variant.list" }) + | (PanelEntry & { action: "slash"; name: string }) + | (PanelEntry & { action: "exit" }) + +type ModelEntry = PanelEntry & { + providerID: string + modelID: string + providerName: string + current: boolean +} + +type VariantEntry = PanelEntry & { + variant: string | undefined + current: boolean +} + +type MenuState = ReturnType + +const PANEL_PAD = 2 +const PANEL_LIST_ROWS = 10 +export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + 6 +const PANEL_PAGE = PANEL_LIST_ROWS - 1 +const PANEL_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "┃", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} +const PANEL_BOTTOM_BORDER = { + ...PANEL_BORDER, + vertical: "╹", +} +const HALF_BLOCK_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: "▀", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} + +function countLabel(count: number, total: number, query: string) { + if (!query.trim()) { + return `${total}` + } + + return `${count}/${total}` +} + +function categoryRank(category: string) { + if (category === "Project Commands") { + return 0 + } + + if (category === "MCP Commands") { + return 1 + } + + return 2 +} + +function handleKey(input: { + event: KeyEvent + menu: MenuState + field: () => InputRenderable | undefined + setQuery: (value: string) => void + select: () => void + close: () => void +}) { + const name = input.event.name.toLowerCase() + const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super + + if (name === "escape" || (ctrl && name === "c")) { + input.event.preventDefault() + input.close() + return + } + + if (name === "up" || (ctrl && name === "p")) { + input.event.preventDefault() + input.menu.move(-1) + return + } + + if (name === "down" || (ctrl && name === "n")) { + input.event.preventDefault() + input.menu.move(1) + return + } + + if (name === "pageup") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() - PANEL_PAGE) + return + } + + if (name === "pagedown") { + input.event.preventDefault() + input.menu.reveal(input.menu.selected() + PANEL_PAGE) + return + } + + if (name === "home") { + input.event.preventDefault() + input.menu.reveal(0) + return + } + + if (name === "end") { + input.event.preventDefault() + input.menu.reveal(Number.POSITIVE_INFINITY) + return + } + + if (name === "return") { + input.event.preventDefault() + input.select() + return + } + + if (ctrl && name === "u") { + input.event.preventDefault() + input.setQuery("") + input.field()?.setText("") + } +} + +function match(query: string, entries: T[]) { + const text = query.trim() + if (!text) { + return entries + } + + return fuzzysort + .go(text, entries, { keys: ["display", "category", "description", "keywords"] }) + .map((item) => item.obj) +} + +function PanelShell(props: { + id: string + title: string + countVisible?: boolean + query: string + count: number + total: number + placeholder: string + theme: Accessor + inputRef: (input: InputRenderable) => void + onQuery: (query: string) => void + children: JSX.Element +}) { + return ( + + + + + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + + ) : null} + + + esc + + + + + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + + + + + ) +} + +export function RunCommandMenuBody(props: { + theme: Accessor + commands: Accessor + variants: Accessor + keybinds: FooterKeybinds + onClose: () => void + onModel: () => void + onVariant: () => void + onVariantCycle: () => void + onCommand: (name: string) => void + onNew: () => void + onExit: () => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => { + const builtins = ["new"] + return [ + { + action: "model", + category: "Suggested", + display: "Switch model", + }, + { + action: "variant.cycle", + category: "Suggested", + display: "Variant cycle", + footer: formatBindings(props.keybinds.variantCycle, props.keybinds.leader), + keywords: "variant cycle", + }, + ...(props.variants().length > 0 + ? [ + { + action: "variant.list" as const, + category: "Suggested", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ...(props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, + ] + }) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: CommandEntry) => { + if (item.action === "model") { + props.onModel() + return + } + + if (item.action === "variant.cycle") { + props.onVariantCycle() + return + } + + if (item.action === "variant.list") { + props.onVariant() + return + } + + if (item.action === "exit") { + props.onExit() + return + } + + if (item.name === "new") { + props.onNew() + return + } + + props.onCommand(item.name) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} + +export function RunVariantSelectBody(props: { + theme: Accessor + variants: Accessor + current: Accessor + onClose: () => void + onSelect: (variant: string | undefined) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => [ + { + category: "", + display: "Default", + description: props.current() === undefined ? "current" : undefined, + keywords: "default", + variant: undefined, + current: props.current() === undefined, + }, + ...props.variants().map((variant) => ({ + category: "", + display: variant, + description: props.current() === variant ? "current" : undefined, + keywords: variant, + variant, + current: props.current() === variant, + })), + ]) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: VariantEntry) => { + props.onSelect(item.variant) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty="No results found" + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + /> + + ) +} + +export function RunModelSelectBody(props: { + theme: Accessor + providers: Accessor + current: Accessor + onClose: () => void + onSelect: (model: NonNullable) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.providers() ?? []) + .flatMap((provider) => + Object.entries(provider.models) + .filter(([, model]) => model.status !== "deprecated") + .map(([modelID, model]) => { + const title = model.name ?? modelID + const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID + const footer = current + ? "current" + : model.cost?.input === 0 && provider.id === "opencode" + ? "Free" + : title !== modelID + ? modelID + : undefined + return { + providerID: provider.id, + modelID, + providerName: provider.name, + category: provider.name, + display: title, + footer, + keywords: `${provider.id} ${provider.name} ${modelID} ${title} ${footer ?? ""}`, + current, + } + }), + ) + .sort((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) { + return provider + } + + const name = a.providerName.localeCompare(b.providerName) + if (name !== 0) { + return name + } + + return a.display.localeCompare(b.display) + }), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const pick = (item: ModelEntry) => { + props.onSelect({ providerID: item.providerID, modelID: item.modelID }) + } + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + pick(item) + } + + createEffect(() => { + query() + menu.reset() + }) + + createEffect(() => { + if (query().trim()) { + return + } + + const index = items().findIndex((item) => item.current) + if (index !== -1) { + menu.reveal(index) + } + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.providers() ? "No results found" : "Models loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={!query().trim()} + /> + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx new file mode 100644 index 0000000000..7a3332165b --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -0,0 +1,290 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes } from "@opentui/core" +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" +import { transparent, type RunFooterTheme } from "./theme" + +export const FOOTER_MENU_ROWS = 8 + +export type RunFooterMenuItem = { + display: string + description?: string + category?: string + footer?: string +} + +type RunFooterMenuRow = + | { type: "header"; label: string } + | { type: "item"; item: RunFooterMenuItem; index: number } + | { type: "spacer" } + +function maxOffset(count: number, limit: number) { + return Math.max(0, count - limit) +} + +function previewMargin(limit: number) { + return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2))) +} + +function revealOffset(value: number, input: { count: number; limit: number; selected: number }) { + const max = maxOffset(input.count, input.limit) + if (input.selected < value) { + return Math.min(max, input.selected) + } + + if (input.selected >= value + input.limit) { + return Math.min(max, input.selected - input.limit + 1) + } + + return Math.min(max, value) +} + +function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) { + const max = maxOffset(input.count, input.limit) + const margin = previewMargin(input.limit) + if (input.dir < 0 && input.selected < value + margin) { + return Math.max(0, Math.min(max, input.selected - margin)) + } + + if (input.dir > 0 && input.selected > value + input.limit - margin - 1) { + return Math.min(max, input.selected - input.limit + margin + 1) + } + + return Math.min(max, value) +} + +export function createFooterMenuState(input: { count: Accessor; limit?: number }) { + const [selected, setSelected] = createSignal(0) + const [offset, setOffset] = createSignal(0) + const limit = () => input.limit ?? FOOTER_MENU_ROWS + const rows = createMemo(() => Math.max(1, Math.min(limit(), input.count()))) + + const reveal = (index: number) => { + const count = input.count() + if (count === 0) { + setSelected(0) + setOffset(0) + return + } + + const next = Math.max(0, Math.min(count - 1, index)) + setSelected(next) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next })) + } + + const reset = () => { + setSelected(0) + setOffset(0) + } + + createEffect(() => { + const count = input.count() + if (count === 0) { + reset() + return + } + + if (selected() >= count) { + setSelected(count - 1) + } + + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() })) + }) + + const move = (dir: -1 | 1) => { + const count = input.count() + if (count === 0) { + reset() + return + } + + const next = Math.max(0, Math.min(count - 1, selected() + dir)) + setSelected(next) + setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir })) + } + + return { + selected, + offset, + rows, + reveal, + reset, + move, + } +} + +export function RunFooterMenu(props: { + id?: string + theme: Accessor + items: Accessor + selected: Accessor + offset: Accessor + rows: Accessor + limit?: number + empty?: string + border?: boolean + paddingLeft?: number + paddingRight?: number + grouped?: boolean +}) { + const limit = () => props.limit ?? FOOTER_MENU_ROWS + const border = () => props.border ?? true + const [groupOffset, setGroupOffset] = createSignal(0) + let previous = -1 + const groupedRows = createMemo(() => { + const all: RunFooterMenuRow[] = [] + let category = "" + props.items().forEach((item, index) => { + if (item.category && item.category !== category) { + if (all.length > 0) { + all.push({ type: "spacer" }) + } + + category = item.category + all.push({ type: "header", label: item.category }) + } + + all.push({ type: "item", item, index }) + }) + return all + }) + + createEffect(() => { + if (!props.grouped) { + return + } + + const all = groupedRows() + const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) + if (all.length === 0 || selected === -1) { + setGroupOffset(0) + previous = props.selected() + return + } + + const dir = + props.selected() === previous + 1 ? 1 + : props.selected() === previous - 1 ? -1 + : undefined + setGroupOffset((value) => + dir + ? moveOffset(value, { count: all.length, limit: limit(), selected, dir }) + : revealOffset(value, { count: all.length, limit: limit(), selected }), + ) + previous = props.selected() + }) + + const rows = createMemo(() => { + if (!props.grouped) { + return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ + type: "item", + item, + index: index + props.offset(), + })) + } + + const all = groupedRows() + const start = Math.max(0, Math.min(groupOffset(), all.length - limit())) + return all.slice(start, start + limit()) + }) + const descriptionColumn = createMemo(() => { + const width = Math.max(0, ...props.items().filter((item) => item.description).map((item) => Bun.stringWidth(item.display))) + return width === 0 ? 0 : width + 2 + }) + const descriptionPad = (item: RunFooterMenuItem) => { + if (!item.description) { + return "" + } + + return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) + } + return ( + + {rows().length === 0 ? ( + + {border() ? ( + + ┃ + + ) : undefined} + + + {props.empty ?? "No matching items"} + + + + ) : ( + rows().map((row) => { + if (row.type === "spacer") { + return + } + + if (row.type === "header") { + return ( + + + {row.label} + + + ) + } + + const active = () => row.index === props.selected() + const inset = () => (active() ? 1 : 0) + return ( + + {border() ? ( + + ┃ + + ) : undefined} + + + + + {row.item.display} + {row.item.description ? ( + + {descriptionPad(row.item)} + {row.item.description} + + ) : undefined} + + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} + + + + + ) + }) + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx new file mode 100644 index 0000000000..b38c2da9d1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -0,0 +1,478 @@ +// Permission UI body for the direct-mode footer. +// +// Renders inside the footer when the reducer pushes a FooterView of type +// "permission". Uses a three-stage state machine (permission.shared.ts): +// +// permission → shows the request with Allow once / Always / Reject buttons +// always → confirmation step before granting permanent access +// reject → text field for the rejection message +// +// Keyboard: left/right to select, enter to confirm, esc to reject. +// The diff view (when available) uses the same diff component as scrollback +// tool snapshots. +/** @jsxImportSource @opentui/solid */ +import type { TextareaRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { + createPermissionBodyState, + permissionAlwaysLines, + permissionCancel, + permissionEscape, + permissionHover, + permissionInfo, + permissionLabel, + permissionOptions, + permissionReject, + permissionRun, + permissionShift, + type PermissionOption, +} from "./permission.shared" +import { toolFiletype } from "./tool" +import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" +import type { PermissionReply, RunDiffStyle } from "./types" + +function buttons( + list: PermissionOption[], + selected: PermissionOption, + theme: RunFooterTheme, + disabled: boolean, + onHover: (option: PermissionOption) => void, + onSelect: (option: PermissionOption) => void, +) { + return ( + + + {(option) => ( + { + if (!disabled) onHover(option) + }} + onMouseUp={() => { + if (!disabled) onSelect(option) + }} + > + {permissionLabel(option)} + + )} + + + ) +} + +function RejectField(props: { + theme: RunFooterTheme + text: string + disabled: boolean + onChange: (text: string) => void + onConfirm: () => void + onCancel: () => void +}) { + let area: TextareaRenderable | undefined + + createEffect(() => { + if (!area || area.isDestroyed) { + return + } + + if (area.plainText !== props.text) { + area.setText(props.text) + area.cursorOffset = props.text.length + } + + queueMicrotask(() => { + if (!area || area.isDestroyed || props.disabled) { + return + } + area.focus() + }) + }) + + return ( +