Compare commits

..

27 Commits

Author SHA1 Message Date
Kit Langton 7af31118da fix(tui): render submitted prompts optimistically 2026-07-13 20:44:51 +00:00
James Long 748b0d8836 refactor(tui): remove subtle syntax styles (#36746) 2026-07-13 16:14:56 -04:00
Kit Langton ed1636e3bd fix(tui): clamp dialog selection after options shrink (#36712) 2026-07-13 15:59:04 -04:00
Dax Raad 90a87b2a0c refactor(tui): remove legacy sync context 2026-07-13 15:46:25 -04:00
Kit Langton af8480b2e2 fix(tui): pin queued compaction below output (#36738) 2026-07-13 15:45:27 -04:00
Kit Langton 2096adae12 refactor(tui): remove unused subagent formatters (#36732) 2026-07-13 15:20:03 -04:00
Kit Langton 41349ff20a fix(tui): run dialog actions without selection (#36711) 2026-07-13 15:19:40 -04:00
Kit Langton a2f5a0df5d fix(core): compact after tool settlement (#36718) 2026-07-13 15:17:45 -04:00
Kit Langton 3bc924252b fix(www): generate collections before typecheck (#36730) 2026-07-13 15:07:45 -04:00
Kit Langton 8c1808ab74 fix(tui): standardize OpenCode product casing (#36717) 2026-07-13 15:01:48 -04:00
Dax Raad 685f887771 docs: clarify embedded sdk roadmap 2026-07-13 14:54:40 -04:00
Dax Raad 9db5073eab fix(plugin): retain legacy kv api shape 2026-07-13 12:57:31 -04:00
Dax Raad 775fce0177 refactor(tui): consolidate settings in cli config 2026-07-13 12:55:54 -04:00
Dax Raad 4e74f77e44 delete context.md 2026-07-13 11:49:34 -04:00
Dax Raad b980f74ed9 refactor(tui): reuse dialog select for settings 2026-07-13 11:16:05 -04:00
opencode-agent[bot] 2987c30238 revert(tui): restore list highlights (#36680)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:29:44 -04:00
opencode-agent[bot] 50a1add183 fix(tui): open model selector at top (#36679)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 10:20:15 -04:00
Dax Raad 3533047421 sync 2026-07-13 10:19:04 -04:00
opencode-agent[bot] 823bc20427 fix(tui): use gray list highlights (#36669)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:15:30 -04:00
opencode-agent[bot] 339fd50cb9 fix(tui): preserve modal focus across dialog replacement (#36656)
Co-authored-by: Simon Klee <52795+simonklee@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 14:54:59 +02:00
Aiden Cline 828a4b3f15 fix(codemode): align array callback behavior (#36584) 2026-07-13 00:34:18 -05:00
Dax Raad a43de3f86d tui: remove legacy V1 CLI TUI integration to simplify startup 2026-07-13 01:05:14 -04:00
Dax Raad fe3b2e8f90 sync 2026-07-13 00:50:51 -04:00
Dax Raad 20af1f5e79 refactor(tui): compact settings values 2026-07-13 03:57:14 +00:00
Kit Langton 8daf912fbf feat(tui): add settings dialog 2026-07-13 03:57:14 +00:00
opencode-agent[bot] 3d545f960b fix(tui): restore clicked reverted prompt (#36567)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-12 21:51:50 -05:00
Kit Langton 58201a32c1 fix: standardize MCP server copy (#36598) 2026-07-12 22:23:59 -04:00
132 changed files with 34648 additions and 1786 deletions
-245
View File
@@ -1,245 +0,0 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**Model Context**:
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
_Avoid_: System Context
**Instructions**:
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
_Avoid_: Session Context
**Instruction Source**:
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**InstructionEntry**:
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**Instruction State**:
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
**Instruction Update**:
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
_Avoid_: Correction, stored prose, raw text diff
**Initial Instructions**:
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Instruction Epoch**:
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
**Instruction Values**:
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
**Unavailable Instruction Source**:
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Step**:
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
**Assistant Turn**:
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
**Settlement**:
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
**Execution**:
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
+969 -15
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:www": "bun run --cwd packages/www dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
@@ -102,6 +103,8 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
+4 -4
View File
@@ -65,8 +65,8 @@ export const dict = {
"command.message.next.description": "Go to the next user message",
"command.model.choose": "Choose model",
"command.model.choose.description": "Select a different model",
"command.mcp.toggle": "Toggle MCPs",
"command.mcp.toggle.description": "Toggle MCPs",
"command.mcp.toggle": "Manage MCP servers",
"command.mcp.toggle.description": "Enable or disable MCP servers",
"command.agent.cycle": "Cycle agent",
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
@@ -307,9 +307,9 @@ export const dict = {
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
"dialog.mcp.title": "MCPs",
"dialog.mcp.title": "MCP servers",
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
"dialog.mcp.empty": "No MCPs configured",
"dialog.mcp.empty": "No MCP servers configured",
"dialog.lsp.empty": "LSPs auto-detected from file types",
"dialog.plugins.empty": "Plugins configured in opencode.json",
-2
View File
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
this.themes.splice(index, 1)
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
public close(): void {
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
void resolveRunTheme(this.renderer).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
return
}
+1 -5
View File
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([])
}
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
if (commit.kind === "reasoning") {
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
}
export function entrySyntax(theme: RunTheme): SyntaxStyle {
return syntax(theme.block.syntax)
}
+3 -3
View File
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
}
active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(theme)
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
? new CodeRenderable(surface.renderContext, {
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
wrapMode: "word",
drawUnstyledText: false,
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
})
: new MarkdownRenderable(surface.renderContext, {
content: "",
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
streaming: true,
internalBlockMode: "top-level",
+1 -1
View File
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
const syntax = createMemo(() => entrySyntax(theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
+1 -44
View File
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
subtleSyntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
)
}
function blend(color: RGBA, bg: RGBA): RGBA {
if (color.a >= 1) {
return color
}
return RGBA.fromValues(
bg.r + (color.r - bg.r) * color.a,
bg.g + (color.g - bg.g) * color.a,
bg.b + (color.b - bg.b) * color.a,
1,
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
if (!style) {
return undefined
}
return SyntaxStyle.fromStyles(
Object.fromEntries(
[...style.getAllStyles()].map(([name, value]) => [
name,
{
...value,
fg: value.fg ? blend(value.fg, bg) : value.fg,
bg: value.bg ? blend(value.bg, bg) : value.bg,
},
]),
),
)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
@@ -502,10 +469,7 @@ function map(
scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
subtleSyntax?.destroy()
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
@@ -566,7 +530,6 @@ function map(
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
} catch {
return RUN_THEME_FALLBACK
}
+23 -4
View File
@@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "kv.json"),
JSON.stringify({
theme_mode_lock: "light",
attention_sound_pack: "custom.pack",
diff_wrap_mode: "none",
diff_viewer_show_file_tree: false,
diff_viewer_single_patch: true,
diff_viewer_view: "split",
terminal_title_enabled: false,
file_context_enabled: false,
paste_summary_enabled: false,
sidebar: "hide",
scrollbar_visible: true,
thinking_mode: "show",
exploration_grouping: false,
tips_hidden: true,
dismissed_getting_started: true,
animations_enabled: false,
skipped_version: "9.9.9",
which_key_layout: "overlay",
}),
)
@@ -56,12 +70,17 @@ test("migrates tui and kv config into cli.json", async () => {
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
diffs: { view: "unified" },
prompt: { paste: "full" },
session: { grouping: "none" },
hints: { tips: false },
attention: { sound_pack: "custom.pack" },
diffs: { wrap: "none", tree: false, single: true, view: "split" },
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { tips: false, onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
-1
View File
@@ -171,7 +171,6 @@ ultimate source of truth.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
## Strings
+22 -21
View File
@@ -727,16 +727,16 @@ const invokeArrayMethod = <R>(
}
return undefined
case "reduce": {
let accumulator: unknown
let start: number
if (args.length >= 2) {
accumulator = args[1]
start = 0
} else {
if (length === 0)
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
accumulator = target[0]
start = 1
let start = 0
let accumulator = args[1]
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length)
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start += 1
}
for (let index = start; index < length; index += 1) {
if (!(index in target)) continue
@@ -745,16 +745,16 @@ const invokeArrayMethod = <R>(
return accumulator
}
case "reduceRight": {
let accumulator: unknown
let start: number
if (args.length >= 2) {
accumulator = args[1]
start = length - 1
} else {
if (length === 0)
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
accumulator = target[length - 1]
start = length - 2
let start = length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0)
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node).as(
"TypeError",
)
accumulator = target[start]
start -= 1
}
for (let index = start; index >= 0; index -= 1) {
if (!(index in target)) continue
@@ -764,7 +764,8 @@ const invokeArrayMethod = <R>(
}
case "findLast":
for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([target[index], index, target])) return target[index]
const item = target[index]
if (yield* apply([item, index, target])) return item
}
return undefined
case "findLastIndex":
@@ -26,9 +26,11 @@
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-1.js
* - test/built-ins/Array/prototype/forEach/15.4.4.18-7-2.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-5.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-9-1.js
* - test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-5.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-1.js
* - test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js
* - test/built-ins/Array/prototype/flatMap/depth-always-one.js
@@ -210,6 +212,11 @@ const cases = [
code: `let calls = 0; const result = [1].reduce(() => { calls += 1; return 2 }); return [result, calls]`,
expected: [1, 0],
},
{
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-9-c-ii-20.js",
code: `let accessed = false; const result = [11].reduce((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
expected: [true, true],
},
{
path: "test/built-ins/Array/prototype/reduce/15.4.4.21-10-1.js",
code: `const input = [1, 2, 3, 4, 5]; input.reduce(() => 1); return input`,
@@ -225,6 +232,11 @@ const cases = [
code: `let calls = 0; const result = [1].reduceRight(() => { calls += 1; return 2 }); return [result, calls]`,
expected: [1, 0],
},
{
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-9-c-ii-20.js",
code: `let accessed = false; const result = [11].reduceRight((previous) => { accessed = true; return previous === undefined }, undefined); return [result, accessed]`,
expected: [true, true],
},
{
path: "test/built-ins/Array/prototype/reduceRight/15.4.4.22-10-1.js",
code: `const input = [1, 2, 3, 4, 5]; input.reduceRight(() => 1); return input`,
@@ -323,3 +335,46 @@ describe("Test262 Array callback adaptations", () => {
})
}
})
describe("Array callback regressions", () => {
test("reduce and reduceRight find the first present element", async () => {
expect(
await value(`
const left = []
left[2] = 3
const right = []
right[0] = 4
right[3] = 1
right.pop()
return [left.reduce((a, b) => a + b), right.reduceRight((a, b) => a + b)]
`),
).toEqual([3, 4])
})
test("reduce and reduceRight reject arrays containing only holes", async () => {
expect(
await value(`
const values = []
values[2] = 1
values.pop()
let left
let right
try { values.reduce((a, b) => a + b) } catch (error) { left = error.name }
try { values.reduceRight((a, b) => a + b) } catch (error) { right = error.name }
return [left, right]
`),
).toEqual(["TypeError", "TypeError"])
})
test("findLast returns the value observed before predicate mutation", async () => {
expect(
await value(`
const values = [1]
return values.findLast((item, index, array) => {
array[index] = 2
return true
})
`),
).toBe(1)
})
})
+1 -1
View File
@@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
+20 -6
View File
@@ -305,13 +305,27 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
tool: {
// The tool domain is scoped registration, not State, so the host adapts the draft to register calls directly.
transform: (callback) =>
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
}),
)
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: (name, callback) => {
if (name === "execute.before") {
return toolHooks.hook.before((event) => {
+1 -5
View File
@@ -154,11 +154,7 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) =>
draft.add(tool.name, fromPromiseTool(tool), {
...(tool.namespace !== undefined ? { namespace: tool.namespace } : {}),
...(tool.codemode !== undefined ? { codemode: tool.codemode } : {}),
}),
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
}),
),
),
+2 -1
View File
@@ -543,7 +543,8 @@ const layer = Layer.effect(
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
+2 -2
View File
@@ -29,8 +29,8 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
`namespace`, which flattens native model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults
true; `codemode: false` keeps the tool on the provider's native tool list).
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
+8 -8
View File
@@ -39,7 +39,7 @@ type CollectedFiles = {
interface Registration {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
readonly group?: string
}
export const create = (registrations: ReadonlyMap<string, Registration>) => {
@@ -56,19 +56,19 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
if (registration.namespace === undefined) {
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.namespace
const branch = tools[namespace]
if (branch && Tool.isDefinition(branch)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (branch) {
if (Object.hasOwn(branch, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
branch[path] = value
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
+14 -10
View File
@@ -13,10 +13,10 @@ import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
/**
* Registry namespace and permission action names for MCP tools.
* Registry group and permission action names for MCP tools.
*/
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -32,11 +32,11 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const byServer = new Map<string, Record<string, Tool.AnyTool>>()
const groups = new Map<string, Record<string, Tool.AnyTool>>()
for (const tool of yield* mcp.tools()) {
const record = byServer.get(tool.server) ?? {}
const group = groups.get(tool.server) ?? {}
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
record[tool.name] = Tool.withPermission(
group[tool.name] = Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
@@ -102,12 +102,16 @@ export const layer = Layer.effectDiscard(
}),
name(tool.server, tool.name),
)
byServer.set(tool.server, record)
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(byServer, ([server, record]) => tools.register(record, { namespace: server }), {
discard: true,
}).pipe(Scope.provide(next), Effect.orDie)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+3 -3
View File
@@ -54,7 +54,7 @@ const registryLayer = Layer.effect(
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
readonly group?: string
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -129,7 +129,7 @@ const registryLayer = Layer.effect(
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
const entries = registrationEntries(tools, options?.group)
if (entries.length === 0) return
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
@@ -148,7 +148,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
group: entry.group,
codemode,
},
},
+18 -5
View File
@@ -46,11 +46,24 @@ export const registerToolPlugin = <R>(plugin: {
const context = host({
tool: {
transform: (callback) =>
Effect.forEach(
Tool.fromDraft(callback),
({ name, tool, options }) => tools.register({ [name]: tool }, options),
{ discard: true },
).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly options?: Tool.RegisterOptions
}> = []
callback({
add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) })
},
})
yield* Effect.forEach(
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
}),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+4 -39
View File
@@ -274,7 +274,7 @@ describe("PluginV2", () => {
}),
)
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
it.effect("groups tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@@ -286,13 +286,13 @@ describe("PluginV2", () => {
execute: () => Effect.succeed({ ok: true }),
})
const plugin = EffectPlugin.define({
id: "namespaced-tools",
id: "grouped-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"), { codemode: false })
draft.add("look/up", tool("Lookup"), { namespace: "context 7", codemode: false })
draft.add("search", tool("Search"), { namespace: "context 7" })
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
draft.add("search", tool("Search"), { group: "context 7" })
})
.pipe(Effect.orDie),
})
@@ -307,41 +307,6 @@ describe("PluginV2", () => {
}),
)
it.effect("accepts flat Effect draft declarations with namespace", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = EffectPlugin.define({
id: "flat-tools",
effect: (ctx) =>
ctx.tool
.transform((draft) => {
draft.add({
name: "send",
namespace: "slack",
description: "Send a Slack message",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ sent: Schema.Boolean }),
execute: () => Effect.succeed({ sent: true }),
})
draft.add({
name: "edit",
codemode: false,
description: "Edit a file",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
})
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual(["edit", "execute"])
}),
)
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
+1 -1
View File
@@ -135,7 +135,7 @@ describe("fromPromise", () => {
await ctx.tool.transform((tools) => {
tools.add({
name: "hello",
codemode: false,
options: { codemode: false },
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
+2 -2
View File
@@ -1614,14 +1614,14 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
reply.text("Active complete", "text-active"),
reply.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
reply.text("Steer complete", "text-steer"),
reply.text("Queue complete", "text-queue"),
+4 -5
View File
@@ -312,13 +312,12 @@ export default Plugin.define({
})
```
Unsupported characters in tool and namespace names are normalized to underscores.
Unsupported characters in tool and group names are normalized to underscores.
The resulting exposed key must begin with a letter and contain at most 64
letters, digits, underscores, or hyphens. Set registration fields on the
declaration or options with `{ namespace, codemode }`:
letters, digits, underscores, or hyphens. Set `options` on the declaration to
configure registration with `{ group, codemode }`:
- `namespace` prefixes the exposed tool name (and becomes the CodeMode path
segment).
- `group` prefixes and groups the exposed tool name.
- `codemode` defaults to `true` and makes the tool available through the
`execute` CodeMode tool. Set `codemode: false` to expose it directly to the
provider.
+14 -5
View File
@@ -1,12 +1,21 @@
---
title: "SDK"
description: "Embed an OpenCode host in an Effect application."
description: "Embed OpenCode directly in your application."
---
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
OpenCode server and routes API calls through its HTTP router in memory. It opens
no HTTP listener and adds no network hop between the client and server.
We're working on a general-purpose SDK for embedding OpenCode directly inside
your application. The regular SDK is coming soon.
An Effect-native version is available now for applications built with Effect.
Its current documentation is below. For other applications, run OpenCode as a
server and use the [TypeScript client](/build/client) in the meantime.
## Effect
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
[network client](/build/client), it assembles the OpenCode server and routes API
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
<Warning>
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
+1 -3
View File
@@ -159,7 +159,6 @@ for (const item of targets) {
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
@@ -185,13 +184,12 @@ for (const item of targets) {
windows: {},
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
entrypoints: ["./src/index.ts", parserWorker, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"),
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MODELS_DEV: generated.modelsData,
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
-100
View File
@@ -1,100 +0,0 @@
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
description: "directory to run in",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
handler: async (args) => {
const directory = (() => {
if (!args.dir) return undefined
try {
process.chdir(args.dir)
return process.cwd()
} catch {
// If the directory doesn't exist locally (remote attach), pass it through.
return args.dir
}
})()
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
try {
await validateSession({
url: args.url,
sessionID: args.session,
directory,
headers,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
continue: args.continue,
sessionID: args.session,
fork: args.fork,
},
}),
)
},
})
-198
View File
@@ -1,198 +0,0 @@
import { cmd } from "@/cli/cmd/cmd"
import { Rpc } from "@/util/rpc"
import { type rpc } from "../tui/worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { ServerAuth } from "@/server/auth"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
declare global {
const OPENCODE_WORKER_PATH: string
}
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("../tui/worker.ts", import.meta.url)
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("auto", {
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
default: false,
})
.option("yolo", {
type: "boolean",
hidden: true,
default: false,
})
.option("dangerously-skip-permissions", {
type: "boolean",
hidden: true,
default: false,
}),
handler: async (args) => {
const unguard = win32InstallCtrlCGuard()
try {
const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
} catch {
UI.error("Failed to change directory to " + next)
return
}
const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
const client = Rpc.client<typeof rpc>(worker)
const reload = () => {
client.call("reload", undefined).catch(() => {})
}
process.on("SIGUSR2", reload)
let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch(() => {})
worker.terminate()
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
const headers = external ? ServerAuth.headers() : undefined
const url = (await client.call("server", network)).url
try {
await validateSession({
url,
sessionID: args.session,
directory: cwd,
headers,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
try {
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
api: OpenCode.make({ baseUrl: url, headers }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
auto: args.auto || args.yolo || args["dangerously-skip-permissions"],
},
}),
)
} finally {
await stop()
}
} finally {
try {
unguard?.()
} catch {}
}
process.exit(0)
},
})
// scratch
-8
View File
@@ -1,8 +0,0 @@
import { run as runTui, type TuiInput } from "@opencode-ai/tui"
import { Global } from "@opencode-ai/core/global"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect } from "effect"
export function run(input: TuiInput) {
return runTui(input).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}
-54
View File
@@ -1,54 +0,0 @@
import { Server } from "@/server/server"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
Heap.start()
const onUnhandledRejection = (_error: unknown) => {}
const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
},
async reload() {
await AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
yield* cfg.invalidate()
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
}),
)
},
async shutdown() {
await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
process.off("unhandledRejection", onUnhandledRejection)
process.off("uncaughtException", onUncaughtException)
},
}
Rpc.listen(rpc)
-4
View File
@@ -18,9 +18,7 @@ import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/attach"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
@@ -82,8 +80,6 @@ const cli = yargs(args)
.command(AcpCommand)
.command(McpCommand)
.command(V2ServeCommand)
.command(TuiThreadCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
+1 -8
View File
@@ -42,7 +42,7 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { createCommandShim } from "@opencode-ai/tui/plugin/command-shim"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Effect } from "effect"
import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
import { createPluginRuntime, type PluginRuntime } from "@opencode-ai/tui/plugin/runtime"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
@@ -1121,11 +1121,4 @@ async function load(input: {
}
}
export function createLegacyTuiPluginHost(): TuiPluginHost {
return {
start: init,
dispose,
}
}
export * as TuiPluginRuntime from "./runtime"
-2
View File
@@ -1,5 +1,4 @@
import yargs from "yargs"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers"
@@ -29,5 +28,4 @@ const cli = yargs(hideBin(process.argv))
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
})
.command(V2ServeCommand)
.command(TuiThreadCommand)
.parse()
@@ -136,14 +136,14 @@ test("theme swaps restyle active reasoning without resetting the stream", async
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: previousSyntax,
syntax: previousSyntax,
},
}
const next = {
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: nextSyntax,
syntax: nextSyntax,
},
}
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
@@ -67,9 +67,7 @@ test("returns syntax styles and indexed splash colors", async () => {
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
@@ -82,7 +80,6 @@ test("returns syntax styles and indexed splash colors", async () => {
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
@@ -103,7 +100,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
expectIndexed(theme.block.warning)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
@@ -119,9 +115,7 @@ test("uses refreshed background brightness when cached renderer mode is stale",
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
} finally {
stale.block.syntax?.destroy()
stale.block.subtleSyntax?.destroy()
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
}
})
@@ -138,9 +132,7 @@ test("keeps renderer mode when refreshed default background is unavailable", asy
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
} finally {
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
dark.block.syntax?.destroy()
dark.block.subtleSyntax?.destroy()
}
})
@@ -1,11 +0,0 @@
import { describe, expect, test } from "bun:test"
describe("tui attach", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
})
@@ -1,89 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
describe("tui thread", () => {
test("loads the TUI integration lazily", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
test("forwards the CLI environment to the TUI worker", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/)
})
async function check(project?: string) {
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
try {
await fs.symlink(tmp.path, link, type)
expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path)
} finally {
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
test("uses the real cwd when PWD points at a symlink", async () => {
await check()
})
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
test("resolves a relative project from PWD when cwd differs", async () => {
await using pwd = await tmpdir({ git: true })
await using cwd = await tmpdir({ git: true })
expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path)
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
})
test("preserves boolean negation for existing options", async () => {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
.exitProcess(false)
.parse(["--mdns", "--no-mdns"])
expect(args.mdns).toBe(false)
})
cliIt.live("rejects removed top-level mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed run mini flag", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["run", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
})
+3
View File
@@ -358,6 +358,7 @@ export type TuiTheme = {
readonly ready: boolean
}
/** @deprecated Persistent TUI KV storage is not supported in V2. */
export type TuiKV = {
get: <Value = unknown>(key: string, fallback?: Value) => Value
set: (key: string, value: unknown) => void
@@ -434,6 +435,7 @@ type TuiConfigView = {
sidebar?: "auto" | "hide"
scrollbar?: boolean
thinking?: "show" | "hide"
markdown?: "source" | "rendered"
grouping?: "auto" | "none"
}
hints?: { tips?: boolean; onboarding?: boolean }
@@ -628,6 +630,7 @@ export type TuiPluginApi = {
dialog: TuiDialogStack
}
readonly tuiConfig: Frozen<TuiConfigView>
/** @deprecated Persistent TUI KV storage is not supported in V2. */
kv: TuiKV
state: TuiState
theme: TuiTheme
+4 -68
View File
@@ -123,17 +123,6 @@ export function make(config: Config<any, any, any> | DynamicConfig): AnyTool {
return makeTyped(config)
}
/**
* Split a flat tool declaration into its registration name, opaque Tool value,
* and registration options. Narrowing on `jsonSchema` selects the matching
* constructor, so no cast is needed to build the Tool from the flat union.
*/
export function fromFlat(flat: FlatDefinition<any, any, any> | FlatDynamicDefinition) {
const { name, namespace, codemode, ...config } = flat
const tool = "jsonSchema" in config ? makeDynamic(config) : makeTyped(config)
return { name, tool, options: { namespace, codemode } satisfies RegisterOptions }
}
function makeTyped<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
@@ -223,14 +212,14 @@ export const validateName = (name: string) =>
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, namespace?: string) =>
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>, group?: string) =>
Object.entries(tools).map(([name, tool]) => {
const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = namespace?.replace(/[^a-zA-Z0-9_-]/g, "_")
const parent = group?.replace(/[^a-zA-Z0-9_-]/g, "_")
return {
key: parent === undefined ? normalized : `${parent}_${normalized}`,
name: normalized,
namespace: parent,
group: parent,
tool,
}
})
@@ -282,68 +271,15 @@ export interface ToolExecuteAfterEvent {
}
export interface RegisterOptions {
/** Dotted CodeMode path prefix, e.g. "slack.admin". */
readonly namespace?: string
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export type FlatDefinition<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: Config<Input, Output, Structured>["toStructuredOutput"]
readonly execute: Config<Input, Output, Structured>["execute"]
readonly toModelOutput?: Config<Input, Output, Structured>["toModelOutput"]
}
export type FlatDynamicDefinition = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: DynamicConfig["execute"]
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(tool: FlatDefinition<Input, Output, Structured>): void
add(tool: FlatDynamicDefinition): void
add(name: string, tool: AnyTool, options?: RegisterOptions): void
}
export type Registration = {
readonly name: string
readonly tool: AnyTool
readonly options?: RegisterOptions
}
/** Run a draft callback and collect the tools it declared, in registration order. */
export function fromDraft(callback: (draft: ToolDraft) => void) {
const registrations: Array<Registration> = []
const add = (
nameOrTool: string | FlatDefinition<any, any, any> | FlatDynamicDefinition,
tool?: AnyTool,
options?: RegisterOptions,
) =>
registrations.push(typeof nameOrTool === "string" ? { name: nameOrTool, tool: tool!, options } : fromFlat(nameOrTool))
callback({ add })
return registrations
}
export interface ToolHooks {
readonly "execute.before": ToolExecuteBeforeEvent
readonly "execute.after": ToolExecuteAfterEvent
+8 -4
View File
@@ -16,8 +16,7 @@ export type Definition<
Structured extends SchemaType<any> = Output,
> = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly options?: RegisterOptions
readonly description: string
readonly input: Input
readonly output: Output
@@ -38,8 +37,7 @@ export type Definition<
export type DynamicDefinition = {
readonly name: string
readonly namespace?: string
readonly codemode?: boolean
readonly options?: RegisterOptions
readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
@@ -69,6 +67,12 @@ export interface ToolExecuteAfterEvent {
outputPaths?: ReadonlyArray<string>
}
export interface RegisterOptions {
readonly group?: string
/** Defaults to true. False exposes the tool directly to the provider. */
readonly codemode?: boolean
}
export interface ToolDraft {
add<
Input extends SchemaType<any>,
-2
View File
@@ -19,12 +19,10 @@
"./context/args": "./src/context/args.tsx",
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx",
"./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx",
"./context/sync": "./src/context/sync.tsx",
"./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
+121 -176
View File
@@ -44,7 +44,6 @@ import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
@@ -53,6 +52,7 @@ import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -70,13 +70,11 @@ import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { isDefaultTitle } from "./util/session"
import { KVProvider, useKV } from "./context/kv"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { TuiConfigV1 } from "./config/v1"
import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@@ -138,7 +136,6 @@ const appBindingCommands = [
"diff.open",
"app.debug",
"app.console",
"app.heap_snapshot",
"terminal.suspend",
"terminal.title.toggle",
"app.toggle.animations",
@@ -154,8 +151,7 @@ export type TuiInput = {
reload?: () => Promise<void>
}
args: Args
config: Config.Interface | TuiConfigV1.Resolved
onSnapshot?: () => Promise<string[]>
config: Config.Interface
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
| {
@@ -183,60 +179,12 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
function isVersionGreater(left: string, right: string) {
const parse = (value: string) => {
const [core, prerelease] = value.replace(/^v/, "").split("-", 2)
return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease }
}
const a = parse(left)
const b = parse(right)
for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) {
const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0)
if (difference) return difference > 0
}
if (a.prerelease === b.prerelease) return false
if (!a.prerelease) return true
if (!b.prerelease) return false
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
}
function fromV1(config: TuiConfigV1.Resolved): Config.Info {
return {
theme: config.theme ? { name: config.theme } : undefined,
plugins: config.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
leader: { timeout: config.leader_timeout },
scroll:
config.scroll_speed === undefined && config.scroll_acceleration === undefined
? undefined
: { speed: config.scroll_speed, acceleration: config.scroll_acceleration?.enabled },
attention: config.attention,
diffs: config.diff_style === undefined ? undefined : { view: config.diff_style === "stacked" ? "unified" : "auto" },
mouse: config.mouse,
}
}
function isConfigInterface(config: Config.Interface | TuiConfigV1.Resolved): config is Config.Interface {
return "get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
}
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const log = input.log ?? (() => {})
const global = yield* Global.Service
const configInput = input.config
const loaded = yield* Effect.gen(function* () {
if (isConfigInterface(configInput)) {
return {
service: configInput,
info: yield* Effect.tryPromise(() => configInput.get()),
legacy: undefined,
}
}
return { service: undefined, info: fromV1(configInput), legacy: configInput }
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
terminalSuspend: process.platform !== "win32",
})
const config = Config.resolve(loaded.info, { terminalSuspend: process.platform !== "win32" })
if (loaded.legacy) config.keybinds = loaded.legacy.keybinds
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@@ -373,72 +321,66 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={loaded.service}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={createOpencodeClient({ ...options, directory })}
api={api}
reconnect={reconnect}
reload={input.server.reload}
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
pluginConfig={loaded.legacy ?? config}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={createOpencodeClient({ ...options, directory })}
api={api}
reconnect={reconnect}
reload={input.server.reload}
>
<PermissionProvider>
<ProjectProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
pluginHost={input.pluginHost}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</PluginRuntimeProvider>
</RouteProvider>
</ToastProvider>
</ConfigProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
@@ -470,24 +412,21 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
function App(props: {
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
pluginConfig: any
pair?: DialogPairCredentials
}) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const config = useConfig().data
const configState = useConfig()
const config = configState.data
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
@@ -496,7 +435,7 @@ function App(props: {
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config, kv })
const attention = createTuiAttention({ renderer, config, update: configState.update })
const clipboard = useClipboard()
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
@@ -522,7 +461,7 @@ function App(props: {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCPs to view details.",
message: "Open MCP servers to view details.",
})
}
})
@@ -533,12 +472,11 @@ function App(props: {
tuiConfig: config,
dialog,
keymap,
kv,
route,
routes: pluginRuntime.routes,
event,
sdk,
sync,
project,
data,
theme: themeState,
toast,
@@ -551,7 +489,6 @@ function App(props: {
props.pluginHost
.start({
api,
config: props.pluginConfig,
runtime: pluginRuntime,
dispose: () => attention.dispose(),
})
@@ -587,10 +524,12 @@ function App(props: {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", true),
)
const terminalTitleEnabled = () => config.terminal?.title ?? true
const pasteSummaryEnabled = () => config.prompt?.paste !== "full"
createEffect(() => {
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
})
// Update terminal window title based on current route and session
createEffect(() => {
@@ -785,7 +724,7 @@ function App(props: {
},
{
name: "mcp.list",
title: "MCP Servers",
title: "MCP servers",
category: "Agent",
slashName: "mcps",
run: () => {
@@ -849,6 +788,15 @@ function App(props: {
},
category: "Integration",
},
{
name: "opencode.settings",
title: "Open settings",
slashName: "settings",
run: () => {
dialog.replace(() => <DialogConfig />)
},
category: "System",
},
{
name: "opencode.status",
title: "View status",
@@ -907,6 +855,7 @@ function App(props: {
{
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
hidden: true,
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -916,6 +865,7 @@ function App(props: {
{
name: "theme.mode.lock",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
hidden: true,
run: () => {
if (locked()) unlock()
else lock()
@@ -967,20 +917,6 @@ function App(props: {
dialog.clear()
},
},
{
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()
},
},
{
name: "terminal.suspend",
title: "Suspend terminal",
@@ -997,41 +933,57 @@ function App(props: {
name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
category: "System",
hidden: true,
run: () => {
setTerminalTitleEnabled((prev) => {
const next = !prev
kv.set("terminal_title_enabled", next)
if (!next) renderer.setTerminalTitle("")
return next
})
const next = !terminalTitleEnabled()
if (!next) renderer.setTerminalTitle("")
void configState
.update((draft) => {
draft.terminal = { ...draft.terminal, title: next }
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "app.toggle.animations",
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
title: (config.animations ?? true) ? "Disable animations" : "Enable animations",
category: "System",
hidden: true,
run: () => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
void configState
.update((draft) => {
draft.animations = !(config.animations ?? true)
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "app.toggle.file_context",
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
category: "System",
hidden: true,
run: () => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
void configState
.update((draft) => {
draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) }
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "app.toggle.diffwrap",
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
category: "System",
hidden: true,
run: () => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
void configState
.update((draft) => {
draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" }
})
.catch(toast.error)
dialog.clear()
},
},
@@ -1039,12 +991,13 @@ function App(props: {
name: "app.toggle.paste_summary",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
category: "System",
hidden: true,
run: () => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
void configState
.update((draft) => {
draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
})
.catch(toast.error)
dialog.clear()
},
},
@@ -1142,21 +1095,13 @@ function App(props: {
event.on("installation.update-available", async (evt) => {
const version = evt.data.version
const skipped = kv.get("skipped_version")
if (skipped && !isVersionGreater(version, skipped)) return
const choice = await DialogConfirm.show(
dialog,
`Update Available`,
`A new release v${version} is available. Would you like to update now?`,
"skip",
"later",
)
if (choice === false) {
kv.set("skipped_version", version)
return
}
if (choice !== true) return
toast.show({
+10 -5
View File
@@ -38,9 +38,8 @@ type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const DEFAULT_TITLE = "opencode"
const DEFAULT_TITLE = "OpenCode"
const DEFAULT_PACK_ID = "opencode.default"
const KV_SOUND_PACK = "attention_sound_pack"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
const BUILTIN_PACK: RegisteredSoundPack = {
@@ -114,6 +113,8 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"]
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
@@ -134,8 +135,7 @@ export function createTuiAttention(input: {
input.renderer.on("blur", onBlur)
function configuredPackID() {
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
return activePackID ?? stored ?? input.config.attention.sound_pack
return activePackID ?? input.config.attention.sound_pack
}
function currentPack() {
@@ -234,7 +234,12 @@ export function createTuiAttention(input: {
const pack = packs.get(id)
if (!pack) return false
activePackID = pack.id
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
if (options?.persist)
void input
.update?.((draft) => {
draft.attention = { ...draft.attention, sound_pack: pack.id }
})
.catch(() => {})
return true
},
current() {
@@ -0,0 +1,298 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { useTheme } from "../context/theme"
import { DialogSelect } from "../ui/dialog-select"
import { useToast } from "../ui/toast"
type Setting = {
title: string
category: string
path: string[]
default: unknown
values?: readonly unknown[]
labels?: readonly string[]
step?: number
min?: number
max?: number
format?: (value: unknown) => string
}
const settings: Setting[] = [
{
title: "Theme",
category: "Appearance",
path: ["theme", "name"],
default: "opencode",
},
{
title: "Color mode",
category: "Appearance",
path: ["theme", "mode"],
default: "system",
values: ["system", "dark", "light"],
},
{
title: "Animations",
category: "Appearance",
path: ["animations"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Tips",
category: "Appearance",
path: ["hints", "tips"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Sidebar",
category: "Session",
path: ["session", "sidebar"],
default: "auto",
values: ["hide", "auto"],
},
{
title: "Scrollbar",
category: "Session",
path: ["session", "scrollbar"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Thinking",
category: "Session",
path: ["session", "thinking"],
default: "hide",
values: ["hide", "show"],
},
{
title: "Markdown",
category: "Session",
path: ["session", "markdown"],
default: "rendered",
values: ["source", "rendered"],
},
{
title: "Grouping",
category: "Session",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
},
{
title: "Layout",
category: "Diffs",
path: ["diffs", "view"],
default: "auto",
values: ["auto", "split", "unified"],
},
{
title: "Wrapping",
category: "Diffs",
path: ["diffs", "wrap"],
default: "word",
values: ["none", "word"],
},
{
title: "File tree",
category: "Diffs",
path: ["diffs", "tree"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Single patch",
category: "Diffs",
path: ["diffs", "single"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Scroll speed",
category: "Input",
path: ["scroll", "speed"],
default: 3,
step: 0.25,
min: 0.25,
max: 10,
format: (value) => Number(value).toFixed(2),
},
{
title: "Acceleration",
category: "Input",
path: ["scroll", "acceleration"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Mouse",
category: "Input",
path: ["mouse"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Editor context",
category: "Input",
path: ["prompt", "editor"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Large pastes",
category: "Input",
path: ["prompt", "paste"],
default: "compact",
values: ["compact", "full"],
},
{
title: "Leader timeout",
category: "Input",
path: ["leader", "timeout"],
default: 2000,
step: 250,
min: 250,
max: 10000,
format: (value) => `${value} ms`,
},
{
title: "Attention",
category: "Alerts",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Notifications",
category: "Alerts",
path: ["attention", "notifications"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
{
title: "Volume",
category: "Alerts",
path: ["attention", "volume"],
default: 0.4,
step: 0.1,
min: 0,
max: 1,
format: (value) => `${Math.round(Number(value) * 100)}%`,
},
{
title: "Window title",
category: "Terminal",
path: ["terminal", "title"],
default: true,
values: [false, true],
labels: ["off", "on"],
},
]
export function DialogConfig() {
const config = useConfig()
const toast = useToast()
const themeState = useTheme()
const [selected, setSelected] = createSignal(0)
const [saving, setSaving] = createSignal(false)
const value = (setting: Setting) => {
const current = setting.path.reduce<unknown>((result, key) => {
if (!result || typeof result !== "object") return undefined
return (result as Record<string, unknown>)[key]
}, config.data)
if (setting.path.join(".") === "theme.name") return current ?? themeState.selected
return current ?? setting.default
}
const values = (setting: Setting) =>
setting.path.join(".") === "theme.name"
? Object.keys(themeState.all()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
: setting.values
const display = (setting: Setting) => {
const current = value(setting)
if (setting.format) return setting.format(current)
const index = setting.values?.indexOf(current)
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
}
const options = createMemo(() =>
settings.map((setting, index) => ({
title: setting.title,
category: setting.category,
footer: display(setting),
value: index,
})),
)
async function change(direction: number, index = selected()) {
if (saving()) return
const setting = settings[index]
const current = value(setting)
const choices = values(setting)
const next = choices
? choices[(choices.indexOf(current) + direction + choices.length) % choices.length]
: Math.min(setting.max!, Math.max(setting.min!, Number(current) + direction * setting.step!))
if (next === current) return
setSaving(true)
await config
.update((draft) => {
const parent = setting.path.slice(0, -1).reduce<Record<string, unknown>>((result, key) => {
if (!result[key] || typeof result[key] !== "object") result[key] = {}
return result[key] as Record<string, unknown>
}, draft)
parent[setting.path.at(-1)!] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<DialogSelect
title="Settings"
options={options()}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(1, option.value)}
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
key: "left",
desc: "Previous value",
group: "Settings",
cmd: () => void change(-1),
},
{
key: "right",
desc: "Next value",
group: "Settings",
cmd: () => void change(1),
},
]}
/>
)
}
+2 -2
View File
@@ -92,7 +92,7 @@ export function DialogMcp() {
when={detail()}
fallback={
<DialogSelect
title="MCPs"
title="MCP servers"
options={options()}
current={focused()}
preserveSelection
@@ -152,7 +152,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
MCP / {props.server.name}
MCP server: {props.server.name}
</text>
<text fg={theme.textMuted} onMouseUp={props.onBack}>
esc back
@@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
{
command: "model.dialog.provider",
title: connected() ? "Connect integration" : "View all integrations",
selection: "none",
onTrigger() {
dialog.replace(() => (
<DialogIntegration
@@ -154,6 +155,7 @@ export function DialogModel(props: { providerID?: string }) {
skipFilter={true}
title={title()}
current={local.model.current()}
focusCurrent={false}
/>
)
}
@@ -345,6 +345,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: () => void create(),
},
{
@@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.refresh",
title: "refresh",
selection: "none",
onTrigger: () => void refetch(),
},
]
+4 -2
View File
@@ -22,9 +22,11 @@ export function DialogStatus() {
esc
</text>
</box>
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP servers</text>}>
<box>
<text fg={theme.text}>{mcp().length} MCP Servers</text>
<text fg={theme.text}>
{mcp().length} MCP server{mcp().length === 1 ? "" : "s"}
</text>
<For each={mcp()}>
{(item) => (
<box flexDirection="row" gap={1}>
@@ -108,7 +108,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
{/* Headline */}
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text attributes={TextAttributes.BOLD} fg={colors.text}>
opencode crashed
OpenCode crashed
</text>
<Show when={showSubtext()}>
<text fg={colors.muted}>An unexpected error stopped the session.</text>
@@ -192,7 +192,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>opencode {InstallationVersion}</text>
<text fg={colors.muted}>OpenCode {InstallationVersion}</text>
</box>
</Show>
</box>
@@ -211,7 +211,7 @@ function buildIssueURL(message: string, stack: string) {
url.searchParams.set("terminal", describeTerminal())
url.searchParams.set(
"reproduce",
"Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
"Reported automatically from the OpenCode crash screen. If you can, describe what you were doing when it crashed.",
)
// Budget the stack against the fully URL-encoded length (not the raw length) so
@@ -220,7 +220,7 @@ function buildIssueURL(message: string, stack: string) {
// so measuring url.toString() is both correct and safe on any input.
const MAX_URL_LENGTH = 6000
const marker = "\n... (truncated)"
const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
setBody(stack)
+40 -17
View File
@@ -43,7 +43,6 @@ import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
@@ -54,6 +53,7 @@ import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { contextUsage } from "../../util/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
registerOpencodeSpinner()
@@ -177,11 +177,10 @@ export function Prompt(props: PromptProps) {
const exit = useExit()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true)
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
const editorContext = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection() : undefined
@@ -1005,8 +1004,10 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const submittedPrompt = structuredClone(unwrap(store.prompt))
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
let cleared = false
if (store.mode === "shell") {
move.startSubmit()
@@ -1099,31 +1100,53 @@ export function Prompt(props: PromptProps) {
return false
}
}
const messageID = SessionMessage.ID.create()
data.session.message.optimistic.add(sessionID, {
id: messageID,
type: "user",
text: inputText,
time: { created: Date.now() },
})
history.append({
...submittedPrompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
props.onSubmit?.()
input.clear()
cleared = true
const error = await sdk.api.session
.prompt({
sessionID,
id: messageID,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
files: submittedPrompt.files,
agents: submittedPrompt.agents,
})
.then(
() => undefined,
(error) => error,
)
if (error) {
data.session.message.optimistic.remove(sessionID, messageID)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false
}
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
props.onSubmit?.()
if (!cleared) {
history.append({
...submittedPrompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
props.onSubmit?.()
}
// temporary hack to make sure the message is sent
if (!props.sessionID) {
@@ -1135,7 +1158,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
}
input.clear()
if (!cleared) input.clear()
if (finishMoveProgress) move.finishSubmit()
return true
}
@@ -1191,7 +1214,7 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", true)
config.prompt?.paste !== "full"
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
@@ -1495,7 +1518,7 @@ export function Prompt(props: PromptProps) {
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<Show when={config.animations ?? true} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
+3 -3
View File
@@ -1,6 +1,6 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useKV } from "../context/kv"
import { useConfig } from "../config"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { registerOpencodeSpinner } from "./register-spinner"
@@ -11,10 +11,10 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { theme } = useTheme()
const kv = useKV()
const config = useConfig().data
const color = () => props.color ?? theme.textMuted
return (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}> {props.children}</text>}>
<Show when={config.animations ?? true} fallback={<text fg={color()}> {props.children}</text>}>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
+6 -1
View File
@@ -118,6 +118,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
hints: Schema.optional(
@@ -195,7 +198,9 @@ export function ConfigProvider(props: {
setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true })))
return info
}
return <ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
return (
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
)
}
export function useConfig() {
+27 -5
View File
@@ -384,9 +384,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
event.data.inputID,
])
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
const item: SessionMessageInfo =
event.data.input.type === "user"
? {
id: event.data.inputID,
@@ -399,8 +397,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "synthetic",
...event.data.input.data,
time: { created: event.created },
},
)
}
const position = index.get(event.data.inputID)
if (position === undefined) return message.append(draft, index, item)
draft[position] = item
})
break
case "session.instructions.updated":
@@ -928,6 +928,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const position = messageIndex.get(sessionID)?.get(messageID)
return position === undefined ? undefined : messages?.[position]
},
optimistic: {
add(sessionID: string, item: Extract<SessionMessageInfo, { type: "user" }>) {
if (!store.session.input[sessionID]?.includes(item.id))
setStore("session", "input", sessionID, [...(store.session.input[sessionID] ?? []), item.id])
message.update(sessionID, (draft, index) => message.append(draft, index, item))
},
remove(sessionID: string, messageID: string) {
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== messageID),
)
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
if (position === undefined) return
draft.splice(position, 1)
index.clear()
draft.forEach((item, itemIndex) => index.set(item.id, itemIndex))
})
},
},
async refresh(sessionID: string) {
const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
-107
View File
@@ -1,107 +0,0 @@
import { createEffect, createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { Flock } from "@opencode-ai/core/util/flock"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
import { useConfigOptional, type Config } from "../config"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: (props: { config?: Config.Info }) => {
const config = props.config ?? useConfigOptional()?.data
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
const lock = `tui-kv:${file}`
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
// Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve()
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => {
const values: Record<string, any> = { ...x }
Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
if (value === undefined) delete values[key]
else values[key] = value
})
setStore(values)
})
.catch((error) => {
console.error("Failed to read KV state", { error })
})
.finally(() => {
setReady(true)
})
createEffect(() => {
if (!ready() || !config) return
Object.entries(configValues(config)).forEach(([key, value]) => {
if (value === undefined) setStore(key, undefined)
else setStore(key, value)
})
})
const result = {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
get(key: string, defaultValue?: any) {
return store[key] ?? defaultValue
},
set(key: string, value: any) {
setStore(key, value)
const snapshot = structuredClone(unwrap(store))
write = write
.then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)))
.catch((error) => {
console.error("Failed to write KV state", { error })
})
},
}
return result
},
})
function configValues(config: Config.Info) {
const values: Record<string, any> = {}
if (config.theme?.name !== undefined) values.theme = config.theme.name
if (config.theme?.mode !== undefined) {
values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
values.theme_mode = undefined
}
if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
if (config.diffs?.view !== undefined)
values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
if (config.animations !== undefined) values.animations_enabled = config.animations
return values
}
-94
View File
@@ -1,94 +0,0 @@
import type {
Agent,
Command,
Config,
FormatterStatus,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
PermissionRequest,
Provider,
QuestionRequest,
Session,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useProject } from "./project"
export const {
context: SyncContext,
use: useSync,
provider: SyncProvider,
} = createSimpleContext({
name: "Sync",
init: () => {
const project = useProject()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
agent: Agent[]
command: Command[]
permission: Record<string, PermissionRequest[]>
question: Record<string, QuestionRequest[]>
config: Config
session: Session[]
session_diff: Record<string, FileDiffInfo[]>
message: Record<string, Message[]>
part: Record<string, Part[]>
lsp: LspStatus[]
mcp: Record<string, McpStatus>
mcp_resource: Record<string, McpResource>
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
status: "complete",
provider: [],
agent: [],
command: [],
permission: {},
question: {},
config: {},
session: [],
session_diff: {},
message: {},
part: {},
lsp: [],
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
})
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
return true
},
get path() {
return project.instance.path()
},
session: {
get(_sessionID: string) {
return undefined as Session | undefined
},
query() {
return {} as { scope?: "project"; path?: string }
},
async refresh() {},
status(_sessionID: string) {
return "idle" as const
},
async sync(_sessionID: string) {},
},
async bootstrap(_input: { fatal?: boolean } = {}) {},
}
},
})
+32 -25
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
@@ -22,7 +21,6 @@ import {
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useKV } from "./kv"
import { useConfig } from "../config"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
@@ -64,7 +62,6 @@ export {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
@@ -76,7 +73,6 @@ export {
upsertTheme,
type Theme,
type ThemeJson,
type SyntaxStyleOverrides,
} from "../theme"
const THEME_REFRESH_DELAYS = [250, 1000] as const
@@ -103,8 +99,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer()
const config = useConfig().data
const kv = useKV()
const configState = useConfig()
const config = configState.data
const themes = props.source ?? themeSource
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
@@ -113,12 +109,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const lock = pick(config.theme?.mode)
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
draft.mode = mode
draft.lock = lock
const active = config.theme?.name ?? kv.get("theme", "opencode")
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
}),
@@ -129,6 +124,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (theme) setStore("active", theme)
})
createEffect(() => {
const mode = config.theme?.mode
if (mode === "dark" || mode === "light") {
pin(mode, false)
return
}
if (mode === "system" && store.lock !== undefined) free(false)
})
function syncCustomThemes() {
return themes
.discover()
@@ -200,23 +204,31 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
function apply(mode: "dark" | "light") {
if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
refreshSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
function pin(mode: "dark" | "light" = store.mode, persist = true) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
if (!persist) return
void configState
.update((draft) => {
draft.theme = { ...draft.theme, mode }
})
.catch(() => {})
}
function free() {
function free(persist = true) {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
kv.set("theme_mode", undefined)
refreshSystemTheme(renderer.themeMode ?? store.mode)
if (!persist) return
void configState
.update((draft) => {
draft.theme = { ...draft.theme, mode: "system" }
})
.catch(() => {})
}
const handle = (mode: "dark" | "light") => {
@@ -256,20 +268,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) return resolveTheme(theme, store.mode)
}
return resolveTheme(store.themes.opencode, store.mode)
})
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
return {
theme: new Proxy(values(), {
@@ -284,7 +288,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
all: allThemes,
has: hasTheme,
syntax,
subtleSyntax,
mode: () => store.mode,
locked: () => store.lock !== undefined,
lock: () => pin(store.mode),
@@ -293,7 +296,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
void configState
.update((draft) => {
draft.theme = { ...draft.theme, name: theme }
})
.catch(() => {})
return true
},
get ready() {
-47
View File
@@ -1,6 +1,3 @@
import { createMemo, type Setter } from "solid-js"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
@@ -16,52 +13,8 @@ export function reasoningSummary(text: string) {
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value)
}
// Cycle order matches the slash command: show → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show"
}
export function useThinkingMode() {
const kv = useKV()
// Capture pre-state before `kv.signal` seeds a default, so we can detect
// first-time users with a legacy `thinking_visibility` boolean and migrate.
// The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
// an updater.
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
else setStored(() => next)
}
// Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "hide" default (collapsed thinking).
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => {
const value = stored()
return isThinkingMode(value) ? value : "hide"
})
return {
mode,
set,
}
}
+10 -2
View File
@@ -5,10 +5,12 @@ import { Tips } from "./tips-view"
import { useBindings } from "../../keymap"
import { useData } from "../../context/data"
import { hasConnectedProvider } from "../../util/connected-provider"
import { useConfig } from "../../config"
const id = "internal:home-tips"
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
const config = useConfig()
useBindings(() => ({
commands: [
{
@@ -16,8 +18,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
title: props.hidden ? "Show tips" : "Hide tips",
category: "System",
namespace: "palette",
hidden: true,
run() {
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
void config
.update((draft) => {
draft.hints = { ...draft.hints, tips: props.hidden }
})
.catch(() => {})
props.api.ui.dialog.clear()
},
},
@@ -40,7 +47,8 @@ const tui: TuiPlugin = async (api) => {
slots: {
home_bottom() {
const data = useData()
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
const config = useConfig().data
const hidden = createMemo(() => !(config.hints?.tips ?? true))
const first = createMemo(() => api.state.session.count() === 0)
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
const show = createMemo(() => (!first() || !connected()) && !hidden())
@@ -4,18 +4,20 @@ import { createMemo, Show } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { FilePath } from "../../ui/file-path"
import { useConfig } from "../../config"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; directory: string }) {
const paths = useTuiPaths()
const config = useConfig()
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const done = createMemo(() => !(config.data.hints?.onboarding ?? true))
const show = createMemo(() => !has() && !done())
const location = createMemo(() => {
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
@@ -44,7 +46,16 @@ function View(props: { api: TuiPluginApi; directory: string }) {
<text fg={theme().text}>
<b>Getting started</b>
</text>
<text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
<text
fg={theme().textMuted}
onMouseDown={() =>
void config
.update((draft) => {
draft.hints = { ...draft.hints, onboarding: false }
})
.catch(() => {})
}
>
</text>
</box>
@@ -19,6 +19,7 @@ import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import {
allExpandedFileTreeDirectories,
buildFileTree,
@@ -41,9 +42,6 @@ const MIN_SPLIT_WIDTH = 100
const FILE_TREE_WIDTH = 32
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
const VCS_DIFF_CONTEXT_LINES = 12
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
const KV_VIEW = "diff_viewer_view"
type DiffMode = "working" | "branch" | "last-turn"
type DiffViewerFocus = "patches" | "files"
type DiffView = "split" | "unified"
@@ -92,6 +90,7 @@ function diffSourceLabel(mode: DiffMode) {
function DiffViewer(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const sdk = useSDK()
const config = useConfig()
const themeState = useTheme()
const theme = () => props.api.theme.current
const params = () =>
@@ -135,11 +134,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})
const files = createMemo(() => diff() ?? [])
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
)
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
@@ -148,7 +145,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
if (props.api.tuiConfig.diffs?.view === "split") return "split"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(config.data.diffs?.view))
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
const fileTree = createMemo(() => buildFileTree(files()))
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
@@ -623,23 +620,33 @@ function DiffViewer(props: { api: TuiPluginApi }) {
name: "diff.toggle_file_tree",
title: "Toggle diff viewer file tree",
category: "VCS",
hidden: true,
run() {
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
setFileTreeEnabled(next)
props.api.kv.set(KV_SHOW_FILE_TREE, next)
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, tree: next }
})
.catch(() => {})
},
},
{
name: "diff.single_patch",
title: "Toggle single patch view",
category: "VCS",
hidden: true,
run() {
setSelectedHunk(undefined)
if (!singlePatch()) {
ensureHighlightedPatchFile()
setSinglePatch(true)
props.api.kv.set(KV_SINGLE_PATCH, true)
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, single: true }
})
.catch(() => {})
scrollSinglePatchToTop()
return
}
@@ -653,7 +660,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
)
if (fileIndex !== undefined) selectPatchFile(fileIndex)
setSinglePatch(false)
props.api.kv.set(KV_SINGLE_PATCH, false)
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, single: false }
})
.catch(() => {})
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
},
},
@@ -669,12 +680,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
name: "diff.toggle_view",
title: "Toggle diff viewer split or unified view",
category: "VCS",
hidden: true,
run() {
if (!splitAvailable()) return
setSelectedHunk(undefined)
const next = view() === "split" ? "unified" : "split"
setViewOverride(next)
props.api.kv.set(KV_VIEW, next)
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, view: next }
})
.catch(() => {})
},
},
{
@@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) {
{
title: "install",
command: "dialog.plugins.install",
selection: "none",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
@@ -22,8 +22,6 @@ const command = {
} as const
const LAYER_PRIORITY = 900
const KV_LAYOUT = "which_key_layout"
const KV_PENDING_PREVIEW = "which_key_pending_preview"
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
const scrollCommands = [
command.scrollUp,
@@ -531,8 +529,8 @@ function WhichKeyPanel(props: {
const tui: TuiPlugin = async (api) => {
const [pinned, setPinned] = createSignal(false)
const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
const [mode, setMode] = createSignal(layout("dock"))
const [pendingPreview, setPendingPreview] = createSignal(false)
api.keymap.registerLayer({
priority: LAYER_PRIORITY,
@@ -554,7 +552,6 @@ const tui: TuiPlugin = async (api) => {
run() {
setMode((value) => {
const next = value === "dock" ? "overlay" : "dock"
api.kv.set(KV_LAYOUT, next)
return next
})
},
@@ -566,7 +563,6 @@ const tui: TuiPlugin = async (api) => {
category: "System",
run() {
setPendingPreview((value) => {
api.kv.set(KV_PENDING_PREVIEW, !value)
return !value
})
},
+26 -37
View File
@@ -3,12 +3,11 @@ import type { Config } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync"
import type { useData } from "../context/data"
import type { useProject } from "../context/project"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
@@ -26,12 +25,11 @@ type Input = {
tuiConfig: Config.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute>
routes: PluginRoutes
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
@@ -97,57 +95,51 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return true
},
get config() {
return sync.data.config
return {}
},
get provider() {
return sync.data.provider
return []
},
get path() {
return sync.path
return project.instance.path()
},
get vcs() {
if (!sync.data.vcs) return
return {
branch: sync.data.vcs.branch,
default_branch: sync.data.vcs.default_branch,
}
return undefined
},
session: {
count() {
return data.session.list().length
},
get(sessionID) {
return sync.session.get(sessionID)
get(_sessionID) {
return undefined
},
diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }],
)
diff(_sessionID) {
return []
},
messages(sessionID) {
return sync.data.message[sessionID] ?? []
messages(_sessionID) {
return []
},
status(sessionID) {
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(sessionID) {
return sync.data.permission[sessionID] ?? []
permission(_sessionID) {
return []
},
question(sessionID) {
return sync.data.question[sessionID] ?? []
question(_sessionID) {
return []
},
},
part(messageID) {
return sync.data.part[messageID] ?? []
part(_messageID) {
return []
},
lsp() {
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
return []
},
mcp() {
return (data.location.mcp.server.list() ?? [])
@@ -292,17 +284,14 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
return input.tuiConfig
},
kv: {
get(key, fallback) {
return input.kv.get(key, fallback)
},
set(key, value) {
input.kv.set(key, value)
},
get ready() {
return input.kv.ready
get(_key, fallback) {
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
return fallback
},
set() {},
ready: true,
},
state: stateApi(input.sync, input.data),
state: stateApi(input.project, input.data),
get client() {
return input.sdk.client
},
-1
View File
@@ -60,7 +60,6 @@ export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
config: any
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
@@ -6,8 +6,13 @@ import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import type { PromptInfo } from "../../prompt/history"
export function DialogMessage(props: { messageID: string; sessionID: string }) {
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
@@ -22,9 +27,26 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: async (dialog) => {
await sdk.api.session
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
onSelect: (dialog) => {
const value = message()
if (value?.type === "user") {
props.setPrompt?.({
text: value.text,
files: value.files?.map((file) => ({
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention ? { ...file.mention } : undefined,
})),
agents: value.agents?.map((agent) => ({
name: agent.name,
mention: agent.mention ? { ...agent.mention } : undefined,
})),
pasted: [],
})
}
void sdk.api.session.revert
.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},
+48 -34
View File
@@ -23,7 +23,7 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -54,7 +54,6 @@ import { filetype } from "../../util/filetype"
import parsers from "../../parsers-config"
import { errorMessage } from "../../util/error"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useEpilogue } from "../../context/epilogue"
@@ -66,7 +65,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
@@ -122,6 +121,7 @@ const context = createContext<{
sessionID: string
thinkingMode: () => ThinkingMode
showThinking: () => boolean
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
@@ -147,8 +147,8 @@ export function Session() {
const data = useData()
const project = useProject()
const paths = useTuiPaths()
const config = useConfig().data
const kv = useKV()
const configState = useConfig()
const config = configState.data
const { theme } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
@@ -194,15 +194,14 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
const showThinking = createMemo(() => true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true)
const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -462,7 +461,11 @@ export function Session() {
run: () => {
batch(() => {
const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto"))
void configState
.update((draft) => {
draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" }
})
.catch(toast.error)
setSidebarOpen(!isVisible)
})
dialog.clear()
@@ -476,12 +479,17 @@ export function Session() {
})(),
value: "session.toggle.thinking",
category: "Session",
hidden: true,
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
run: () => {
thinking.set(nextThinkingMode(thinkingMode()))
void configState
.update((draft) => {
draft.session = { ...draft.session, thinking: nextThinkingMode(thinkingMode()) }
})
.catch(toast.error)
dialog.clear()
},
},
@@ -489,8 +497,13 @@ export function Session() {
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
category: "Session",
hidden: true,
run: () => {
setShowScrollbar((prev) => !prev)
void configState
.update((draft) => {
draft.session = { ...draft.session, scrollbar: !showScrollbar() }
})
.catch(toast.error)
dialog.clear()
},
},
@@ -498,8 +511,13 @@ export function Session() {
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
value: "session.toggle.exploration_grouping",
category: "Session",
hidden: true,
run: () => {
setGroupExploration((prev) => !prev)
void configState
.update((draft) => {
draft.session = { ...draft.session, grouping: groupExploration() ? "none" : "auto" }
})
.catch(toast.error)
dialog.clear()
},
},
@@ -855,6 +873,7 @@ export function Session() {
sessionID: route.sessionID,
thinkingMode,
showThinking,
markdownMode,
groupExploration,
diffWrapMode,
models,
@@ -1287,7 +1306,6 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
const ctx = use()
const kv = useKV()
const { theme, syntax } = useTheme()
const status = () => props.message.status
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
@@ -1300,7 +1318,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<Switch>
<Match when={status() === "running"}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}></text>}>
<Show when={ctx.config.animations ?? true} fallback={<text fg={color()}></text>}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</Show>
</Match>
@@ -1320,7 +1338,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
internalBlockMode="top-level"
content={content()}
tableOptions={{ style: "grid" }}
conceal={false}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdownText}
bg={theme.background}
/>
@@ -1468,6 +1486,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
const dialog = useDialog()
const renderer = useRenderer()
const promptRef = usePromptRef()
return (
<Show when={props.message.text.trim() || files().length}>
@@ -1486,7 +1505,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
paddingTop={1}
paddingBottom={1}
@@ -1691,7 +1716,7 @@ function ReasoningPart(props: {
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -1711,8 +1736,6 @@ function ReasoningPart(props: {
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
@@ -1738,7 +1761,7 @@ function ReasoningPart(props: {
streaming={true}
syntaxStyle={syntax()}
content={summary().body}
conceal={false}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
/>
</box>
@@ -1804,7 +1827,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={false}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdownText}
bg={theme.background}
/>
@@ -2373,10 +2396,6 @@ function Subagent(props: ToolProps) {
)
}
export function formatSubagentToolcalls(count: number) {
return `${count} toolcall${count === 1 ? "" : "s"}`
}
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
}
@@ -2385,11 +2404,6 @@ export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
export function formatCompletedSubagentDetail(toolcalls: number, duration: string) {
if (toolcalls === 0) return duration
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] {
+3 -1
View File
@@ -177,7 +177,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
const index = rows.findIndex(
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
)
return index === -1 ? rows.length : index
}
-27
View File
@@ -90,7 +90,6 @@ export type Theme = {
_hasSelectedListItemText: boolean
}
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@@ -557,32 +556,6 @@ export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
if (rule.style.foreground) {
const fg = rule.style.foreground
return {
...rule,
style: {
...rule.style,
...override,
foreground: RGBA.fromInts(
Math.round(fg.r * 255),
Math.round(fg.g * 255),
Math.round(fg.b * 255),
Math.round(theme.thinkingOpacity * 255),
),
},
}
}
return rule
}),
)
}
function getSyntaxRules(theme: Theme) {
return [
{
+40 -25
View File
@@ -36,14 +36,7 @@ export interface DialogSelectProps<T> {
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
actions?: DialogSelectAction<T>[]
footerHints?: {
title: string
label: string
@@ -51,8 +44,27 @@ export interface DialogSelectProps<T> {
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
focusCurrent?: boolean
}
type DialogSelectActionBase<T> = {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
}
type DialogSelectAction<T> =
| (DialogSelectActionBase<T> & {
selection?: "required"
onTrigger: (option: DialogSelectOption<T>) => void
})
| (DialogSelectActionBase<T> & {
selection: "none"
onTrigger: () => void
})
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
@@ -104,6 +116,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.current,
(current) => {
if (props.focusCurrent === false) return
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
@@ -220,7 +233,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection) return
if (!props.preserveSelection) {
const next = Math.min(store.selected, flat().length - 1)
if (next >= 0 && next !== store.selected) setStore("selected", next)
return
}
if (resetSelection && store.filter.length > 0) {
const option = flat()[0]
if (!option) return
@@ -229,7 +246,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return
}
if (!selection) {
if (props.current !== undefined) {
if (props.focusCurrent !== false && props.current !== undefined) {
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
if (index >= 0) {
setStore("selected", index)
@@ -279,7 +296,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setTimeout(() => {
if (filter.length > 0) {
moveTo(0, true, false)
} else if (current) {
} else if (current && props.focusCurrent !== false) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
moveTo(currentIndex, true)
@@ -348,7 +365,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "keyboard")
const index = focusedAction()
if (index !== undefined) {
triggerAction(actionItems()[index])
trigger(actionItems()[index])
return
}
const option = selected()
@@ -439,14 +456,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
name: item.command,
title: item.title,
category: "Dialog",
run() {
if (props.locked) return
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
run: () => trigger(item),
})),
],
bindings: [
@@ -502,10 +512,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
function triggerAction(item: VisibleAction | undefined) {
if (props.locked) return
if (!item || !isActionItem(item) || isActionDisabled(item)) return
function trigger(item: Action | undefined) {
if (props.locked || !item || isActionDisabled(item)) return
setStore("input", "keyboard")
if (item.selection === "none") {
item.onTrigger()
return
}
const option = selected()
if (!option) return
item.onTrigger(option)
@@ -516,7 +529,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function isActionDisabled(item: Action) {
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
const option = selected()
if (item.selection !== "none" && !option) return true
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
}
function isActionFocused(item: VisibleAction) {
@@ -543,7 +558,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
flexDirection="row"
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => triggerAction(item)}
onMouseUp={() => trigger(item)}
>
<text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
+1
View File
@@ -90,6 +90,7 @@ function init() {
let focus: Renderable | null
function refocus() {
setTimeout(() => {
if (store.stack.length > 0) return
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable) {
@@ -1,70 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/context/args"
import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync"
import { PermissionProvider } from "../../../../src/context/permission"
import { ExitProvider } from "../../../../src/context/exit"
import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler, state?: string) {
const events = createEventStream()
const calls = createFetch(override, events)
let sync!: ReturnType<typeof useSync>
let project!: ReturnType<typeof useProject>
let kv!: ReturnType<typeof useKV>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
function Probe() {
const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
onMount(() => {
sync = ctx.sync
project = ctx.project
kv = ctx.kv
done()
})
return <box />
}
const app = await testRender(() => (
<TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider>
<KVProvider>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<PermissionProvider>
<ProjectProvider>
<ExitProvider exit={() => {}}>
<SyncProvider>
<Probe />
</SyncProvider>
</ExitProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</KVProvider>
</ArgsProvider>
</TestTuiContexts>
))
await ready
await wait(() => sync.status === "complete")
return { app, emit: events.emit, kv, project, sync, session: calls.session }
}
@@ -1,24 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { mount } from "./sync-fixture"
test("legacy sync is an inert compatibility context", async () => {
const { app, session, sync } = await mount()
try {
expect(sync.status).toBe("complete")
expect(sync.ready).toBe(true)
expect(sync.data.session).toEqual([])
expect(sync.data.message).toEqual({})
expect(sync.data.provider).toEqual([])
expect(sync.session.get("ses_test")).toBeUndefined()
await sync.bootstrap()
await sync.session.refresh()
await sync.session.sync("ses_test")
expect(session).toEqual([])
} finally {
app.renderer.destroy()
}
})
+84
View File
@@ -653,6 +653,75 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("shows optimistic prompts immediately and reconciles admission", async () => {
const events = createEventStream()
const sessionID = "session-optimistic"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
const messageID = SessionMessage.ID.create()
data.session.message.optimistic.add(sessionID, {
id: messageID,
type: "user",
text: "Steer now",
time: { created: 1 },
})
const optimistic = data.session.message.get(sessionID, messageID)
expect(optimistic?.type).toBe("user")
expect(optimistic?.type === "user" ? optimistic.text : undefined).toBe("Steer now")
expect(data.session.input.has(sessionID, messageID)).toBe(true)
emitEvent(events, {
id: EventV2.ID.create(),
created: 2,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: messageID,
input: { type: "user", data: { text: "Steer now", metadata: { admitted: true } }, delivery: "steer" },
},
})
await wait(() => data.session.message.get(sessionID, messageID)?.metadata?.admitted === true)
expect(data.session.message.list(sessionID)).toHaveLength(1)
const failedID = SessionMessage.ID.create()
data.session.message.optimistic.add(sessionID, {
id: failedID,
type: "user",
text: "Fails",
time: { created: 3 },
})
data.session.message.optimistic.remove(sessionID, failedID)
expect(data.session.message.get(sessionID, failedID)).toBeUndefined()
expect(data.session.input.has(sessionID, failedID)).toBe(false)
} finally {
app.renderer.destroy()
}
})
test("removes committed revert messages from local state", async () => {
const events = createEventStream()
const sessionID = "session-revert"
@@ -1202,6 +1271,21 @@ test("restores queued compaction from durable pending input", async () => {
{ type: "compaction-queued", inputID: "message-compaction-later" },
])
emitEvent(events, {
id: "evt_text_ended",
created: 2,
type: "session.text.ended",
durable: durable(sessionID, 5),
data: {
sessionID,
assistantMessageID: "message-assistant",
ordinal: 0,
text: "Active output",
},
})
await wait(() => rows.some((row) => row.type === "part"))
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
emitEvent(events, {
id: "evt_compaction_started",
created: 2,
@@ -26,12 +26,10 @@ async function mountPrompt(input: {
}) {
const state = path.join(input.root, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")
const [
{ DialogProvider },
{ DialogPrompt },
{ KVProvider },
{ ThemeProvider },
{ ConfigProvider },
{ ToastProvider },
@@ -39,7 +37,6 @@ async function mountPrompt(input: {
] = await Promise.all([
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-prompt"),
import("../../../src/context/kv"),
import("../../../src/context/theme"),
import("../../../src/config"),
import("../../../src/ui/toast"),
@@ -67,15 +64,13 @@ async function mountPrompt(input: {
>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={resolvedConfig}>
<KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</KVProvider>
<ThemeProvider mode="dark">
<ToastProvider>
<DialogProvider>
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
@@ -0,0 +1,264 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function renderSelect(
root: string,
options: DialogSelectOption<string>[],
onGlobal: () => void,
onRow: (option: DialogSelectOption<string>) => void,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<DialogSelect
title="Items"
options={options}
actions={[
{
command: "dialog.move_session.delete",
title: "delete",
onTrigger: onRow,
},
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: onGlobal,
},
]}
/>
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Items"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider, useDialog },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
const selected: string[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions
onCleanup(off)
function Fixture() {
const dialog = useDialog()
onMount(() =>
dialog.replace(() => (
<DialogSelect
title="Mutable options"
options={options()}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
)),
)
return null
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 24, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
}
test("dialog actions run without options while row actions still require a selection", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[],
() => global++,
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("d", { ctrl: true })
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("footer actions run when filtering leaves no selected row", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => global++,
(option) => rows.push(option.value),
)
try {
for (const key of "missing") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("No results found"))
app.mockInput.pressKey("d", { ctrl: true })
app.mockInput.pressTab()
app.mockInput.pressEnter()
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("row actions receive the selected option", async () => {
await using tmp = await tmpdir()
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => {},
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("d", { ctrl: true })
expect(rows).toEqual(["alpha"])
} finally {
app.renderer.destroy()
}
})
test("selects the new final option immediately after removing the selected final option", async () => {
await using tmp = await tmpdir()
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options)
try {
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "second")
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "third")
select.replaceOptions(options.slice(0, -1))
await select.app.waitForFrame((frame) => !frame.includes("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["second"])
} finally {
select.app.renderer.destroy()
}
})
test("selects a repopulated option after removing the only option", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }])
try {
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No results found"))
select.app.mockInput.pressEnter()
expect(select.selected).toEqual([])
select.replaceOptions([{ title: "replacement", value: "replacement" }])
await select.app.waitForFrame((frame) => frame.includes("replacement"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["replacement"])
} finally {
select.app.renderer.destroy()
}
})
@@ -4,7 +4,6 @@ import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { KVProvider } from "../../../src/context/kv"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
@@ -182,9 +181,7 @@ function withTheme(component: () => JSX.Element) {
return (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider>
</KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
)
@@ -5,7 +5,6 @@ import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/c
import { testRender, useRenderer } from "@opentui/solid"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { Session } from "@opencode-ai/sdk/v2"
import { KVProvider } from "../../../src/context/kv"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { SDKProvider } from "../../../src/context/sdk"
@@ -174,11 +173,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
</ThemeProvider>
</KVProvider>
<ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</SDKProvider>
+5 -9
View File
@@ -7,7 +7,6 @@ import path from "node:path"
import { onCleanup } from "solid-js"
import { ClipboardProvider } from "../../../src/context/clipboard"
import type { FormWithLocation } from "../../../src/context/data"
import { KVProvider } from "../../../src/context/kv"
import { SDKProvider } from "../../../src/context/sdk"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
@@ -21,7 +20,6 @@ import { createApi, createClient, createEventStream, createFetch } from "../../f
async function mountForm(root: string, width = 80) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
await Bun.write(path.join(state, "kv.json"), "{}")
const replies: unknown[] = []
const copied: string[] = []
@@ -78,13 +76,11 @@ async function mountForm(root: string, width = 80) {
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
<KVProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</KVProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<FormPrompt form={form} />
</ToastProvider>
</ThemeProvider>
</SDKProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
@@ -2,10 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatCompletedSubagentDetail,
formatSubagentRetry,
formatSubagentTitle,
formatSubagentToolcalls,
InlineToolRow,
parseApplyPatchFiles,
parseDiagnostics,
@@ -182,13 +180,6 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
})
test("formats completed subagent toolcall details", () => {
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
@@ -207,5 +198,4 @@ describe("TUI inline tool wrapping", () => {
test("snapshots expanded tool errors under the tool text", async () => {
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
})
-10
View File
@@ -11,7 +11,6 @@ type Opts = {
}
export function createTuiPluginApi(opts: Opts = {}) {
const values = new Map<string, unknown>()
const color = RGBA.fromInts(200, 200, 200)
const dialog = { clear() {}, replace() {}, setSize() {}, size: "medium" as const, depth: 0, open: false }
return {
@@ -19,15 +18,6 @@ export function createTuiPluginApi(opts: Opts = {}) {
client: opts.client,
event: opts.event,
keymap: opts.keymap,
kv: {
get(name: string, fallback?: unknown) {
return values.has(name) ? values.get(name) : fallback
},
set(name: string, value: unknown) {
values.set(name, value)
},
ready: true,
},
state: { session: { get: () => undefined, ...opts.state?.session } },
theme: { current: new Proxy({}, { get: () => color }) },
tuiConfig: createTuiResolvedConfig(),
@@ -292,7 +292,7 @@ The `mcp debug` command shows the current auth status, tests HTTP connectivity,
## Manage
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you can manage them through the OpenCode config like any other tool.
Tools from your MCP servers are available in OpenCode alongside built-in tools. You can manage them through the OpenCode config like any other tool.
---
@@ -319,7 +319,7 @@ This means that you can enable or disable them globally.
}
```
We can also use a glob pattern to disable all matching MCPs.
We can also use a glob pattern to disable all matching MCP tools.
```json title="opencode.json" {14}
{
@@ -340,7 +340,7 @@ We can also use a glob pattern to disable all matching MCPs.
}
```
Here we are using the glob pattern `my-mcp*` to disable all MCPs.
Here we are using the glob pattern `my-mcp*` to disable all matching MCP tools.
---
+6
View File
@@ -0,0 +1,6 @@
.source
.tanstack
.wrangler
dist
node_modules
worker-configuration.d.ts
+22
View File
@@ -0,0 +1,22 @@
# OpenCode website
The server-rendered `opencode.ai` website. It uses TanStack Start on Cloudflare Workers and serves the V2 documentation with Fumadocs.
## Development
From this directory, run:
```bash
bun dev
```
The site opens at `http://localhost:3000`; documentation is available at `http://localhost:3000/docs`.
## Verification
```bash
bun typecheck
bun run build
```
The existing `packages/web`, `packages/docs`, and `packages/stats/app` deployments remain in place until their routes have been migrated and verified.
+284
View File
@@ -0,0 +1,284 @@
---
title: "Agents"
description: ""
---
Agents combine a system prompt, model preference, tool permissions, and display
metadata into a reusable assistant profile. OpenCode includes agents for common
workflows, and you can override them or add your own in configuration or
Markdown files.
## Built-in agents
| Agent | Mode | Purpose |
| --- | --- | --- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default, sensitive environment-file reads ask for approval, and access outside the workspace asks for approval. |
| **Plan** (`plan`) | `primary` | Planning agent. File edits are denied except for OpenCode plan files. Shell commands are not generally denied. |
| **General** (`general`) | `subagent` | General-purpose research and multi-step work. It has broad tool access but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Read-only code and web exploration using `read`, `glob`, `grep`, `webfetch`, and `websearch`. |
OpenCode also has hidden `compaction`, `title`, and `summary` system agents.
They run internal maintenance tasks and are not selectable. There is no built-in
`scout` agent in V2.
You can override a built-in agent with an entry of the same ID. Set
`disabled: true` to remove one.
## Default agent
Set the primary agent used when a session has not selected one:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer"
}
```
The configured agent must exist, must not have `mode: "subagent"`, and must not
be hidden. If it is unavailable, OpenCode falls back to `build`, then to the
first visible agent that can run as a primary agent. This selection does not
rewrite the agent already stored on an existing session.
## Modes
An agent's `mode` controls where it can run:
| Mode | Behavior |
| --- | --- |
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. |
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
| `all` | Can be used either way. This is the default for a custom agent when `mode` is omitted. |
In the TUI, press <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> to cycle
through visible primary and `all` agents, or use `/agents` to choose one.
Subagents run in child sessions with fresh context. A primary agent can invoke
one with the `subagent` tool, either in the foreground or in the background.
You can also `@` mention a visible subagent to ask the current agent to delegate
work to it:
```text
@explore find where authentication errors are handled
```
The parent agent's `subagent` permission controls which agents it may launch.
The child currently uses its own configured permissions, not a restricted copy
of the parent's permissions.
## Configure agents
### Markdown files
The recommended file locations are:
```text
~/.config/opencode/agents/<name>.md
.opencode/agents/<name>.md
```
OpenCode discovers project `.opencode` directories from the current directory
up to the project root. The path below `agents/` becomes the agent ID, so
`.opencode/agents/team/reviewer.md` defines `team/reviewer`.
Frontmatter uses the same fields as an entry under `agents`. The Markdown body
becomes `system`:
```md title=".opencode/agents/reviewer.md"
---
description: Reviews changes without modifying files
mode: subagent
model: anthropic/claude-sonnet-4-5#high
color: warning
steps: 8
permissions:
- action: edit
resource: "*"
effect: deny
- action: shell
resource: "*"
effect: deny
---
Review for correctness, security, regressions, and missing tests.
List findings in severity order with file and line references.
```
### JSON or JSONC
Use the `agents` field in any [OpenCode configuration file](/docs/config):
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
"agents": {
"reviewer": {
"description": "Reviews changes for correctness, security, and missing tests",
"mode": "all",
"model": "anthropic/claude-sonnet-4-5#high",
"system": "Review the current changes. Report findings before any summary.",
"color": "warning",
"steps": 8,
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" }
]
},
"build": {
"permissions": [
{ "action": "shell", "resource": "git push *", "effect": "ask" }
]
}
}
}
```
Agent definitions merge in configuration order. Later scalar fields replace
earlier values, request maps merge by key, and permission rules are appended.
Global `permissions` are applied to every agent before its agent-specific rules,
so a later agent rule can refine a global rule.
## Options
### `description`
Explains the agent's purpose. It is optional, but strongly recommended for
subagents because OpenCode includes it in the subagent catalog shown to the
model.
### `mode`
Accepts `primary`, `subagent`, or `all`. The default is `all`.
### `model`
Selects a model using `provider/model` with an optional `#variant`:
```jsonc
{
"agents": {
"reviewer": {
"model": "anthropic/claude-sonnet-4-5#high"
}
}
}
```
The equivalent expanded form is:
```jsonc
{
"agents": {
"reviewer": {
"model": {
"providerID": "anthropic",
"model": "claude-sonnet-4-5",
"variant": "high"
}
}
}
}
```
The TUI uses this as the preferred model when the agent is selected. A child
session uses its subagent's configured model, or inherits the parent session's
model when none is configured. In the API, the session's selected model is
stored separately; creating or switching a primary session with only an agent
ID does not itself change that session model.
### `system`
Sets the agent's system prompt. A non-empty value replaces OpenCode's
provider-specific base prompt for that agent. Project instructions, skills,
references, and other instruction sources are still added separately.
For a Markdown agent, use the document body instead of a `system` frontmatter
field.
### `permissions`
Permissions are an ordered array of rules:
```jsonc
{
"agents": {
"orchestrator": {
"permissions": [
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "explore", "effect": "allow" },
{ "action": "shell", "resource": "git *", "effect": "ask" }
]
}
}
}
```
Each rule has:
| Field | Meaning |
| --- | --- |
| `action` | Tool or permission action, with `*` wildcards supported. |
| `resource` | The path, command, agent ID, or other resource matched by the action. Wildcards are supported. |
| `effect` | `allow`, `ask`, or `deny`. |
The last matching rule wins. Important V2 action names include `shell` for
shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
agents. Other tools generally use their tool name, such as `read`, `glob`,
`grep`, `webfetch`, `websearch`, and `skill`.
<Tip>
Put broad wildcard rules first and exceptions afterward. For example, deny
all subagents first, then allow `explore`.
</Tip>
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
expanded.
### `steps`
Sets a positive maximum number of model steps. On the final allowed step,
OpenCode removes tools and asks the model to summarize its work in text. New
user input resets the allowance.
### `hidden`
When `true`, removes the agent from normal selectors, `@` autocomplete, and the
subagent catalog advertised to models. It is a visibility setting, not a
security boundary.
### `color`
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one
of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`.
### `disabled`
When `true`, removes the agent definition at that point in configuration
loading. This works for built-in and custom agents.
### `request`
The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
```jsonc
{
"agents": {
"reviewer": {
"request": {
"headers": { "x-agent": "reviewer" },
"body": { "temperature": 0.1 }
}
}
}
}
```
<Warning>
The current V2 session runner preserves these overlays on the agent
definition but does not yet apply them to model requests. Configure effective
request settings on the provider, model, or model variant instead. Do not use
legacy top-level agent fields such as `temperature`, `top_p`, `prompt`,
`permission`, `tools`, `disable`, or `maxSteps` in new V2 configuration.
</Warning>
@@ -0,0 +1,168 @@
---
title: "Attachments"
description: ""
---
OpenCode can add local context to a prompt as text or image media. Current V2
sessions make these attachment types visible to the model:
| Input | Model receives |
| --- | --- |
| UTF-8 text file | The filename and decoded text |
| Directory | A non-recursive listing of its immediate files and directories |
| PNG, JPEG, GIF, or WebP | Image media |
SVG files are treated as text, not image media. PDF, AVIF, BMP, audio, video,
and other binary prompt attachments are not currently included in the model
request. Some clients may let you select a PDF, but V2 does not yet make that
PDF visible to the model.
<Warning>
Use a model that supports image input before attaching an image. OpenCode
passes supported image media to the selected provider, but the provider and
model still enforce their own formats, dimensions, file counts, and size
limits. A text-only model may reject the request.
</Warning>
## Add attachments
### TUI
Type `@` followed by a filename and select the result to attach a project file.
This is the preferred way to add source code and other text files:
```text
Explain the error handling in @src/server.ts
```
Paste an image from the clipboard with the configured paste key, `Ctrl+V` by
default. You can also drag a supported image into a terminal that exposes the
dropped file path to the TUI. The TUI reads PNG, JPEG, GIF, and WebP as image
attachments; a dropped SVG is inserted as text.
### Desktop and web
Use **Attach file**, paste, or drag and drop. Attach UTF-8 text or a PNG, JPEG,
GIF, or WebP image. The desktop file picker limits one selection to 20 MiB in
total; the server also applies the per-attachment limit described below.
### CLI
Pass `--file` or `-f` to `opencode2 run`. Repeat the flag for multiple files:
```bash
opencode2 run -f src/server.ts -f screenshot.png "Explain the failure"
```
The run command accepts at most 100 file flags and reads at most 10 MiB per
file. Use it for text files and the four supported image formats; other binary
files do not become model context.
### API
The V2 prompt and command payloads accept a `files` array. Each item requires a
`uri` and can include `name` and `description`:
```bash
opencode2 api post /api/session/ses_example/prompt --data '{
"text": "Review this file",
"files": [
{
"uri": "file:///home/me/project/src/server.ts",
"name": "server.ts",
"description": "Request handler"
}
]
}'
```
Use an absolute `file:` URL for a file available to the server, or an inline
data URL:
```json
{
"text": "What is wrong with this layout?",
"files": [
{
"uri": "data:image/png;base64,<base64-data>",
"name": "layout.png"
}
]
}
```
HTTP and HTTPS attachment URLs are not supported. OpenCode materializes each
attachment before admitting the prompt and rejects invalid URLs, unreadable
paths, non-files other than directories, and attachments over 20 MiB decoded.
For a text `file:` URL, optional positive `start` and `end` query parameters
select one-based lines:
```text
file:///home/me/project/src/server.ts?start=20&end=60
```
The server infers the media type from the bytes. A supplied filename or data
URL media type does not make an unsupported binary format model-visible.
## Configure image processing
Configure image normalization in `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"attachments": {
"image": {
"auto_resize": true,
"max_width": 2000,
"max_height": 2000,
"max_base64_bytes": 5242880
}
}
}
```
All fields are optional:
| Field | Default | Behavior |
| --- | ---: | --- |
| `auto_resize` | `true` | Resize an image that exceeds any configured limit. If `false`, reject it. |
| `max_width` | `2000` | Maximum width in pixels. Must be a positive integer. |
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
<Note>
In the current V2 runtime, these settings apply to image media produced by
the built-in `read` tool. Images attached directly through the TUI, desktop,
web, CLI, or API bypass this normalization. Resize direct attachments before
adding them if the provider requires smaller media.
</Note>
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
ingest at most 20 MiB of source image bytes. It decodes the image and compares
its width, height, and encoded Base64 length with all three configured limits.
When `auto_resize` is `true`, OpenCode preserves the aspect ratio, scales the
image down to the dimension limits, and tries progressively smaller PNG and
JPEG encodings until the Base64 limit is met. The resulting media type can
therefore change to PNG or JPEG. If no encoding fits, the tool call fails.
When `auto_resize` is `false`, exceeding any limit fails the tool call without
modifying the image. An image that cannot be decoded also fails. If the image
resizer cannot be loaded, the `read` tool returns the
original image instead, so these settings are processing limits rather than an
upload or security boundary.
## Limits and provider behavior
- Direct prompt attachments are limited to 20 MiB decoded per item by the V2
server. Client-specific limits can be lower.
- `max_base64_bytes` counts the encoded Base64 characters in bytes, not the
decoded file size and not the complete `data:` URL.
- Text attachments are inserted into the prompt as text and do not require a
multimodal model. Large text read through the `read` tool has separate
paging and truncation limits.
- Image attachments use provider-native image input. Provider errors can still
occur when OpenCode's limits pass but the selected model's limits do not.
- PDFs and other unsupported binary prompt attachments should be converted to
text or supported images before attaching them.
@@ -0,0 +1,163 @@
---
title: "Commands"
description: ""
---
Custom commands turn a named prompt template into a slash command. Type the
command in the TUI, followed by any arguments:
```text
/review src/auth
```
## Configure with Markdown
OpenCode discovers `.md` command files in `commands/` directories:
```text
~/.config/opencode/commands/ # Global
.opencode/commands/ # Project
```
Files may be nested; for example, `.opencode/commands/team/review.md` defines
`/team/review`. Files with other extensions, including `.mdx`, are not
discovered.
```md title=".opencode/commands/review.md"
---
description: Review code for correctness and missing tests
agent: plan
model: anthropic/claude-sonnet-4-5#high
---
Review $ARGUMENTS. Report bugs first, then missing tests.
```
The file body, with surrounding whitespace removed, is the command template.
JSON and Markdown commands share one registry. Project definitions take
precedence over global definitions, and a later definition can override a
built-in or earlier command with the same name. Changes are reloaded
automatically.
Run it with:
```text
/review src/auth
```
## Configure with JSON
Add commands under the `commands` key in any OpenCode JSON or JSONC
[configuration file](/docs/config). Each entry's key is the command name and
`template` is required.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"commands": {
"review": {
"description": "Review code for correctness and missing tests",
"template": "Review $ARGUMENTS. Report bugs first, then missing tests.",
"agent": "plan",
"model": "anthropic/claude-sonnet-4-5#high"
}
}
}
```
## Fields
| Field | Required | Behavior |
| --- | --- | --- |
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
| `description` | No | Text shown with the command in autocomplete. |
| `agent` | No | Agent selected before the prompt runs. |
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
| `subtask` | No | Accepted as a boolean, but currently has no execution effect in V2. |
The four optional fields can be used in JSON or YAML frontmatter. Do not put
`template` in frontmatter because the Markdown body always supplies it.
## Arguments
Use `$ARGUMENTS` for the complete argument string:
```md title=".opencode/commands/component.md"
---
description: Create a component
---
Create a typed React component named $ARGUMENTS.
```
```text
/component Button
```
Use `$1`, `$2`, and higher numbers for parsed positional arguments. Single and
double quotes group text containing spaces and are removed during parsing.
```md title=".opencode/commands/check.md"
---
description: Check one area with a specific focus
---
Check $1. Focus on $2.
```
```text
/check src/auth "error handling and missing tests"
```
The highest-numbered positional placeholder present in the template consumes
that argument and all remaining arguments. For example, if a template contains
only `$1`, then `$1` receives the full parsed argument list. Missing positions
become empty strings.
If a template contains neither positional placeholders nor `$ARGUMENTS`,
OpenCode appends non-empty arguments to the template after a blank line.
## Shell interpolation
Wrap a shell command in `!` followed by backticks to insert its output before
the prompt is submitted:
```md title=".opencode/commands/review-diff.md"
---
description: Review the current diff
---
Review this diff:
!`git diff --stat && git diff`
```
OpenCode runs each interpolation with the configured shell in the active
project location and inserts its combined output into the template. Argument
interpolation happens first, so avoid placing untrusted arguments inside shell
interpolations.
<Warning>
Shell interpolations run when the command is evaluated, outside the agent's
tool permission flow. Only use commands from sources you trust.
</Warning>
No other template interpolation is performed. In particular, an `@path`
written into a stored template remains ordinary prompt text; V2 does not
automatically attach that file.
## Agent, model, and execution
Running a command evaluates its arguments and shell blocks, submits the result
as a durable user prompt in the current session, and schedules normal model
execution.
If `agent` is set, it overrides the agent selected when the command was
invoked and becomes the session's active agent. If `model` is set, it overrides
the model. Otherwise, a model configured on the command's agent takes
precedence over the model selected at invocation.
Although `subtask` is accepted in JSON and frontmatter, V2 currently ignores
it: commands run in the current session and do not create a child session.
Selecting an agent whose mode is `subagent` also does not turn the command into
a subtask.
@@ -0,0 +1,153 @@
---
title: "Compaction"
description: ""
---
Compaction replaces the active model context from an older part of a session
with a generated checkpoint. The checkpoint contains a structured summary and
a serialized tail of recent context, so the agent can continue with more room
in the model's context window.
Compaction is lossy, but it does not delete the earlier durable session
messages. After a successful compaction, V2 builds model requests from the
latest completed checkpoint and the messages that follow it.
## Automatic compaction
Automatic compaction is enabled by default. Before a model call, V2 estimates
the size of the final system prompt, messages, and advertised tools. It starts
compaction when:
```text
estimated tokens > context limit - max(requested output tokens, buffer)
```
The estimate is approximate: V2 JSON-serializes the request and assumes four
characters per token. When compaction succeeds, V2 rebuilds the request from
the new checkpoint and retries the step without promoting the input again.
V2 also recognizes provider errors classified as context overflow. If an
overflow occurs before the provider produces assistant output or other retry
evidence, V2 can compact and retry that step once. This recovery is attempted
even when `auto` is `false`; `auto` controls only the preflight size check. A
second overflow after recovery is returned as an error.
## Manual compaction
In the TUI, run:
```text
/compact
```
`/summarize` is an alias. The default keybind is `<leader>c`, configured as
`session_compact`.
A manual request is durably admitted and wakes the session runner. It can
compact short histories that would not trigger automatic compaction. If the
session is busy, compaction runs at the next safe drain boundary before later
steered or queued prompts are promoted. Repeated requests while one is pending
coalesce into that pending request. Whether compaction completes or fails, the
barrier is then settled so later prompts can proceed.
The CLI has no separate `compact` subcommand. Use the TUI command or the server
API. For example:
```bash
opencode2 api v2.session.compact \
--param sessionID=ses_example \
--data '{}'
```
The equivalent raw request is:
```bash
opencode2 api post /api/session/ses_example/compact --data '{}'
```
`POST /api/session/:sessionID/compact` returns the admitted compaction input;
it does not wait for summary generation. Clients can call
`client.session.compact({ sessionID })` and then wait for the session or follow
the `session.compaction.*` events. Supplying an optional message `id` makes an
exact retry idempotent, but reusing an ID owned by another record returns a
conflict.
## Configuration
Add `compaction` to any [OpenCode configuration file](/docs/config):
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"prune": false,
"keep": {
"tokens": 8000
},
"buffer": 20000
}
}
```
| Field | Default | V2 behavior |
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Token reserve used by the automatic threshold. The requested model output allowance wins when it is larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
preserves more recent detail but leaves less room for future work. Larger
`buffer` triggers preflight compaction earlier.
## Checkpoint contents
V2 uses the session's selected or default model to generate the summary, with
tools disabled and at most 4096 output tokens. The summary records the
objective, important details, completed and active work, blockers, next moves,
and relevant files.
The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters,
and file or media attachments become textual descriptors rather than embedded
data. On later compactions, V2 updates the previous summary and carries forward
its retained recent context before selecting a new tail.
The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are
not included in model context.
## Compaction advances the instruction epoch
Conversation compaction and instruction synchronization are separate. Before
promoting pending input, V2 compares live instruction sources with the latest
admitted values. Ordinary changes become durable value deltas; their
model-facing System messages are derived during request assembly rather than
persisted.
Completed compaction advances the instruction epoch at the exact ended-event
sequence and makes the currently admitted values initial. It does not reread
sources or publish an instruction event. Session movement and committed revert
clear the instruction fold so the next safe boundary requires one complete
source read. See [Instructions](/docs/instructions) for source ordering and update
behavior.
## Current limitations
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
not implemented in V2.
- Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
its output allowance, the model returns no summary, or the provider fails.
- Automatic and overflow compaction need older conversation context that can be
replaced. A provider overflow can still surface when there is no compressible
head or fixed instructions and tool schemas dominate the request.
- Overflow recovery retries only once per step. Token estimation is heuristic,
so it cannot prevent every provider-specific overflow.
- Earlier durable messages remain stored even though they are no longer in the
active model context.
V1 used additional tail-turn and pruning behavior. Those V1 details are only
migration context; the settings and behavior on this page describe V2.
+450
View File
@@ -0,0 +1,450 @@
---
title: "Config"
description: ""
---
<Tip>
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
</Tip>
## Format
OpenCode supports both **JSON** and **JSONC** (JSON with Comments) configuration files.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/gpt-5.2-custom",
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom"
}
}
}
}
}
```
## Locations
OpenCode loads global configuration from:
```text
~/.config/opencode/opencode.json(c)
```
Project-specific configuration can use either form:
```text
/home/user/projects/my-app/opencode.json(c)
/home/user/projects/my-app/.opencode/opencode.json(c)
```
When OpenCode starts, it searches for configuration files from the current
directory upward to the project root. It merges direct `opencode.json(c)` files
from the project root toward the current directory, then does the same for
files inside `.opencode` directories. A `.opencode` config therefore overrides
every direct config, even when the direct config is closer to the current
directory. Avoid mixing the two forms across one project hierarchy unless this
precedence is intentional.
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
```text
~/.config/opencode/opencode.json
/home/user/projects/acme/
├── opencode.json
└── packages/
└── web/
├── opencode.json
└── src/
```
OpenCode applies these files from lowest to highest precedence:
1. `~/.config/opencode/opencode.json`
2. `/home/user/projects/acme/opencode.json`
3. `/home/user/projects/acme/packages/web/opencode.json`
In this direct-config example, the package config overrides matching settings
from the repository config, which overrides matching settings from the global
config. Settings that do not conflict are preserved from every file.
## Schema
The complete OpenCode configuration schema is available at
[opencode.ai/config.json](https://opencode.ai/config.json).
Add the `$schema` field to your configuration file to enable validation and
autocomplete in editors that support JSON Schema:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json"
}
```
Use the schema as the source of truth for available fields, accepted values,
and nested configuration shapes.
### Shell
Set the shell used by the terminal and shell tools.
```jsonc
{
"shell": "/bin/zsh"
}
```
### Model
Set the default model in `provider/model` format. The root default currently
does not retain a `#variant`; select variants in the TUI or on an agent or command.
```jsonc
{
"model": "anthropic/claude-sonnet-4-5"
}
```
See the [models guide](/docs/models) for model selection
and local models.
### Default agent
Choose the primary agent used when a session does not select one explicitly.
```jsonc
{
"default_agent": "build"
}
```
See the [agents guide](/docs/agents) for built-in and custom
agents.
### Autoupdate
Control automatic updates from the global config. Set this to `false` to
disable updates. The current beta treats `true` and `"notify"` identically and
automatically installs compatible non-major updates; project-level values are
ignored.
```jsonc
{
"autoupdate": false
}
```
### Sharing
Set the intended session sharing policy. V2 accepts this field, but session
sharing is not implemented yet.
```jsonc
{
"share": "manual"
}
```
See the [sharing guide](/docs/sharing) for more details.
### Username
Set a username for future display behavior. V2 accepts this field but does not
currently display it in conversations.
```jsonc
{
"username": "alice"
}
```
### Permissions
Define ordered rules that allow, deny, or ask before an agent uses a tool on a
matching resource.
```jsonc
{
"permissions": [
{
"action": "shell",
"resource": "git push *",
"effect": "ask"
}
]
}
```
See the [permissions guide](/docs/permissions) for rule matching and available actions.
### Agents
Override built-in agents or define specialized agents with their own model,
instructions, mode, and permissions.
```jsonc
{
"agents": {
"reviewer": {
"description": "Review changes without editing files",
"mode": "subagent",
"system": "Focus on correctness, security, and missing tests.",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" }
]
}
}
}
```
See the [agents guide](/docs/agents) for all agent options and file-based agents.
### Snapshots
Enable or disable filesystem snapshots used by undo and revert behavior.
```jsonc
{
"snapshots": false
}
```
See the [snapshots guide](/docs/snapshots) for undo and redo behavior.
### Watcher
Ignore files and directories that should not trigger filesystem updates.
```jsonc
{
"watcher": {
"ignore": ["dist/**", "coverage/**"]
}
}
```
### Formatter
Define formatter settings for compatibility and future use. V2 accepts this
field, but it does not run formatters yet.
```jsonc
{
"formatter": {
"prettier": {
"command": ["bunx", "prettier", "--write", "$FILE"],
"extensions": [".js", ".ts", ".tsx"]
}
}
}
```
See the [formatters guide](/docs/formatters) for accepted fields and current limitations.
### LSP
Define language server settings for compatibility and future use. V2 accepts
this field, but it does not start language servers yet.
```jsonc
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"]
}
}
}
```
See the [LSP guide](/docs/lsp) for accepted fields and current limitations.
### Attachments
Control how oversized images loaded by the `read` tool are resized or rejected
before they are sent to a model.
```jsonc
{
"attachments": {
"image": {
"auto_resize": true,
"max_width": 2000,
"max_height": 2000,
"max_base64_bytes": 5242880
}
}
}
```
See the [attachments guide](/docs/attachments) for image processing and limits.
### Tool output
Set the maximum number of lines and bytes retained from a tool result.
```jsonc
{
"tool_output": {
"max_lines": 2000,
"max_bytes": 51200
}
}
```
### MCP
Configure local and remote Model Context Protocol servers. Global timeouts can
be overridden by an individual server.
```jsonc
{
"mcp": {
"servers": {
"playwright": {
"type": "local",
"command": ["bunx", "@playwright/mcp"]
}
}
}
}
```
See the [MCP guide](/docs/mcp-servers) for remote servers, OAuth, environment variables, and timeouts.
### Compaction
Control automatic context compaction and how much recent context it preserves.
```jsonc
{
"compaction": {
"auto": true,
"keep": {
"tokens": 8000
},
"buffer": 20000
}
}
```
See the [compaction guide](/docs/compaction) for automatic context management.
### Skills
Add directories or URLs that OpenCode should search for agent skills.
```jsonc
{
"skills": ["./team-skills", "https://example.com/.well-known/skills/"]
}
```
See the [skills guide](/docs/skills) for skill structure and automatic discovery under `.opencode/skills/`.
### Commands
Define reusable slash commands as named prompt templates.
```jsonc
{
"commands": {
"review": {
"description": "Review the current changes",
"template": "Review the current diff for correctness and missing tests."
}
}
}
```
See the [commands guide](/docs/commands) for arguments, models, agents, and file-based commands.
### Instructions
Declare additional instruction files, globs, or URLs. V2 accepts this field,
but does not load these entries yet; use `AGENTS.md` for active instructions.
```jsonc
{
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md"]
}
```
See the [instructions guide](/docs/instructions) for project instructions and `AGENTS.md`.
### References
Make local directories or Git repositories available as named supporting
context.
```jsonc
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main"
}
}
}
```
See the [references guide](/docs/references) for shorthand, visibility, and path resolution.
### Plugins
Load plugins from packages or local files. Use the object form when a plugin
accepts options.
```jsonc
{
"plugins": [
"opencode-example-plugin",
{
"package": "./plugins/local.ts",
"options": {
"enabled": true
}
}
]
}
```
See the [plugins guide](/docs/build/plugins) for plugin development and configuration.
### Providers
Configure providers and add or override their models, request settings,
headers, and model variants.
```jsonc
{
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom",
"limit": {
"context": 200000,
"output": 32000
}
}
}
}
}
}
```
See the [providers guide](/docs/providers) for credentials, custom endpoints, provider packages, and model configuration.
@@ -0,0 +1,93 @@
---
title: "Formatters"
description: ""
---
OpenCode V2 accepts formatter configuration, but it does not yet include a
formatter runtime. File writes and edits are not automatically formatted.
<Warning>
V2 currently has no built-in formatters. The built-in formatter list and
automatic post-edit formatting documented for V1 do not apply to V2.
</Warning>
## Configuration
The `formatter` field accepts a boolean or an object keyed by formatter name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": {
"prettier": {
"disabled": false,
"command": ["prettier", "--write", "$FILE"],
"environment": {
"NODE_ENV": "development"
},
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
}
```
This example is valid V2 configuration, but V2 does not currently execute the
command.
Each named formatter entry supports these optional fields:
| Field | Type | Current V2 behavior |
| --- | --- | --- |
| `disabled` | `boolean` | Accepted, but there is no runtime formatter to enable or disable. |
| `command` | `string[]` | Accepted as an argument array, but not executed. |
| `environment` | `Record<string, string>` | Accepts string environment variable names and values, but they are not applied. |
| `extensions` | `string[]` | Accepted without extension-specific validation, but files are not matched against it. |
All entry fields are optional. The schema therefore also accepts an empty entry
such as `"prettier": {}`.
## Enable and disable
The schema accepts all of the following forms:
```jsonc
// Omit `formatter`, or use false, when formatting is not requested.
{
"formatter": false
}
```
```jsonc
// Reserved for enabling all built-ins once a V2 runtime provides them.
{
"formatter": true
}
```
```jsonc
// Configure named entries or mark one as disabled.
{
"formatter": {
"prettier": { "disabled": true },
"custom": {
"command": ["custom-fmt", "$FILE"],
"extensions": [".foo"]
}
}
}
```
At present, omitted, `false`, `true`, and object forms have the same runtime
result: V2 runs no formatter. `disabled` is retained as configuration data but
does not control an executable formatter.
## Commands and placeholders
`command` is an array of strings, not a shell command string. `$FILE` is the V1
file-path placeholder and is often retained in migrated configuration. V2 does
not currently substitute `$FILE` or define another formatter placeholder.
Likewise, V2 does not currently use `extensions` to select commands, merge
`environment` into a child process, discover formatter executables or project
configuration, or run multiple matching formatters. These behaviors will only
be available after a V2 formatter runtime is implemented.
+160
View File
@@ -0,0 +1,160 @@
---
title: "Intro"
description: "Get started with OpenCode."
---
<Warning>
These docs are for the beta version of OpenCode, which will become OpenCode 2.0. The beta is still changing: we may
wipe your data, things may break, and APIs, configuration, and plugin APIs may change.
</Warning>
## Install
<Note>The curl install script is not available in beta.</Note>
You can also install it with the following package managers.
<Tabs>
<Tab title="npm">
```bash
npm install -g @opencode-ai/cli@next
```
</Tab>
<Tab title="bun">
```bash
bun install -g @opencode-ai/cli@next
```
</Tab>
<Tab title="pnpm">
```bash
pnpm install -g @opencode-ai/cli@next
```
</Tab>
<Tab title="Yarn">
```bash
yarn global add @opencode-ai/cli@next
```
</Tab>
</Tabs>
<Note>During beta, the binary is called `opencode2`.</Note>
### Homebrew
Homebrew installation is not available in beta.
### Arch Linux
Arch Linux installation is not available in beta.
### Windows
<Tip>
For the best experience on Windows, install [Windows Subsystem for Linux
(WSL)](https://learn.microsoft.com/windows/wsl/install), open your Linux distribution, and use one of the beta package
manager commands above.
</Tip>
<Tabs>
<Tab title="chocolatey">
Chocolatey installation is not available in beta.
</Tab>
<Tab title="scoop">
Scoop installation is not available in beta.
</Tab>
<Tab title="mise">
Mise installation is not available in beta.
</Tab>
<Tab title="docker">
Docker installation is not available in beta.
</Tab>
</Tabs>
Standalone binaries are not available in beta.
---
#### Prerequisites
To use OpenCode in your terminal, you'll need:
1. A modern terminal emulator like:
- [Ghostty](https://ghostty.org), Linux and macOS
- [WezTerm](https://wezterm.org), cross-platform
- [Alacritty](https://alacritty.org), cross-platform
- [Kitty](https://sw.kovidgoyal.net/kitty/), Linux and macOS
2. API keys for the LLM providers you want to use.
---
## Connect
With OpenCode you can use any LLM provider by configuring its API key.
Run `/connect` in the TUI and select your provider.
```text
/connect
```
If you'd like easy access to all the best coding models you can try out
[OpenCode Console](https://console.opencode.ai).
You can also try [OpenCode Go](https://opencode.ai/go) a $10/month subscription
plan that grants you access to the best open source models.
Use `/models` to browse the providers and models available to your project. See [Providers](/docs/providers) for connection and
configuration details.
---
## Usage
You are now ready to use OpenCode in your project. Here are a few common workflows.
### Ask questions
Ask OpenCode to explain your codebase.
<Tip>Use `@` to fuzzy search for files in the project.</Tip>
```text
How is authentication handled in @packages/functions/src/api/index.ts
```
### Add features
Ask OpenCode to add a feature by describing the desired behavior and providing relevant context.
```text
When a user deletes a note, flag it as deleted in the database.
Create a screen that shows recently deleted notes.
From this screen, the user can restore a note or permanently delete it.
```
<Tip>Give OpenCode plenty of context and examples.</Tip>
### Undo changes
Use `/undo` when a change isn't what you wanted.
```text
/undo
```
OpenCode stages a conversation revert and restores your original message so you can revise it. In a Git repository, it
also restores file changes when snapshots were captured successfully. Run `/undo` multiple times to move the conversation
boundary back, or use `/redo` to restore the staged conversation and files. See [Undo](/docs/snapshots) for
limitations and safety details.
```text
/redo
```
---
## Customize
Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing
keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](/docs/formatters), [creating commands](/docs/commands), or
editing the [OpenCode config](/docs/config).
@@ -0,0 +1,128 @@
---
title: "Instructions"
description: ""
---
Instructions are privileged context that guide an agent throughout a session.
V2 combines built-in context, discovered `AGENTS.md` files, and dynamic sources
such as skill, reference, MCP, and session context. It stores source values as
durable deltas, then renders initial instructions and chronological updates when
assembling each model request.
## AGENTS.md
Use `AGENTS.md` for persistent guidance such as build commands, architecture,
code conventions, and verification requirements. Commit project files so the
whole team receives the same instructions.
V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the project
root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
```text
my-project/
├── AGENTS.md
└── packages/
├── AGENTS.md
└── web/
└── AGENTS.md
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then project files from the Location toward the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant project directory.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
discovery but does not disable the global file.
<Note>
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback
and related precedence described by older OpenCode documentation do not apply.
</Note>
### Nested instructions
An `AGENTS.md` below the Location is not part of the initial upward scan. When
the read tool successfully reads a file or lists a directory, OpenCode discovers
`AGENTS.md` files from that target upward to, but not including, the Location.
It adds newly discovered files to the session in nearest-first order.
Each nested file is injected once per session and recorded in durable session
history. Reading the same area again does not inject it again. Consequently,
editing an already injected nested `AGENTS.md` does not replace its earlier
session entry automatically; start a new session if the updated text must apply
immediately.
## Config entries
The V2 config schema accepts an `instructions` array of strings:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"instructions": [
"CONTRIBUTING.md",
"docs/guidelines/*.md",
"https://example.com/shared-instructions.md"
]
}
```
Configuration is loaded from global through project-local files. If more than
one config defines `instructions`, the highest-precedence, closest config's
entire array is selected; arrays are not merged.
<Warning>
V2 currently parses and retains this field but does not resolve its entries
into instruction sources. Local files, glob patterns, and HTTP or HTTPS URLs
in `instructions` therefore do not reach the model yet. Use `AGENTS.md` for
active V2 instructions. URL fetching and timeout behavior documented for V1
are not supported by the current V2 implementation.
</Warning>
See [Config](/docs/config) for config locations and general precedence.
## Ordering
The selected agent or provider system prompt is sent first. OpenCode then sends
the session's initial instructions, composed in this order:
1. Built-in environment and date context.
2. Ambient `AGENTS.md` discovery.
3. Available skill, reference, and MCP guidance.
4. Session-specific instruction entries supplied through the API.
These sources are combined; ordering is not an override mechanism. Nested
`AGENTS.md` files discovered by reads are chronological session entries rather
than part of the initial instructions.
## Changes
Before promoting pending input, V2 compares live instruction sources with the
latest admitted source values:
- A new or changed ambient `AGENTS.md` aggregate is announced as a system update
that replaces the previous ambient aggregate.
- Removing all ambient files announces that the previous ambient instructions
no longer apply.
- A temporary read or discovery failure preserves the session's last known
instructions instead of treating them as deleted. If no instruction epoch
exists yet, pending input waits until every source is available.
- Completed conversation compaction advances the instruction epoch, making the
currently admitted values initial without rereading sources or authoring an
instruction event.
- Moving a session or committing a revert clears the instruction fold. The next
safe boundary requires one complete source read before promoting input.
The durable event stores changed source keys and value hashes, not rendered
prose. During request assembly, OpenCode renders the epoch's initial values and
interleaves later changes as chronological System messages. Clients see changed
keys but never the privileged value bodies.
+105
View File
@@ -0,0 +1,105 @@
---
title: "LSP"
description: ""
---
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Warning>
OpenCode V2 does not yet have an LSP runtime or built-in language servers.
The `lsp` configuration is accepted and preserved, but it does not currently
start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Warning>
## Built-in servers
There are no built-in LSP servers in the current V2 implementation. Setting
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
effect until V2 provides a server registry and LSP runtime.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": true
}
```
## Configuration
The `lsp` field accepts a boolean or an object keyed by server name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"env": {
"TSS_LOG": "-level verbose"
},
"initialization": {
"preferences": {
"importModuleSpecifierPreference": "relative"
}
}
}
}
}
```
Each enabled server entry has this shape:
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `command` | `string[]` | Yes | Executable followed by any arguments. |
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
| `disabled` | `boolean` | No | Disables the entry when `true`. |
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
The only entry that may omit `command` is the disable-only form:
```jsonc
{
"lsp": {
"typescript": {
"disabled": true
}
}
}
```
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
including for a custom server, although a future runtime will need a way to
associate that server with files.
## Disable LSP
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
disable the whole integration, including when a lower-priority configuration
set it to `true` or supplied an object:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": false
}
```
Use `{ "disabled": true }` under a server name to disable one server while
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
V2 currently performs no automatic LSP downloads.
## Current usage
V2 loads and validates the configuration shape for compatibility and future
integration. It does not currently use LSP when reading, writing, editing, or
patching files, and those tools do not notify a language server or return LSP
diagnostics.
For reliable feedback today, have the agent run the project's lint, typecheck,
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
skill so the agent knows when and where to run them.
@@ -0,0 +1,262 @@
---
title: "MCP servers"
description: ""
---
OpenCode can connect to [Model Context Protocol](https://modelcontextprotocol.io/) servers and make their tools, prompts, and instructions available to agents. MCP tools consume model context, so enable only the servers you need.
## Configure servers
Define each server by a unique name under `mcp.servers` in your [OpenCode configuration](/docs/config). V2 does not place server names directly under `mcp`.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"my-server": {
"type": "local",
"command": ["npx", "-y", "example-mcp-server"]
}
}
}
}
```
Servers connect automatically unless `disabled` is `true`. There is no V2 `enabled` field.
```jsonc
{
"mcp": {
"servers": {
"my-server": {
"type": "local",
"command": ["npx", "-y", "example-mcp-server"],
"disabled": true
}
}
}
}
```
As with other configuration, a server in a higher-precedence project config replaces a server with the same name from a lower-precedence config. Use different names when you need separate connections or accounts.
## Local servers
A local server is a command that OpenCode starts using the MCP stdio transport.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"everything": {
"type": "local",
"command": [
"npx",
"-y",
"@modelcontextprotocol/server-everything"
],
"cwd": ".",
"environment": {
"LOG_LEVEL": "info",
"MCP_API_KEY": "{env:MCP_API_KEY}"
}
}
}
}
}
```
| Field | Required | Description |
| --- | --- | --- |
| `type` | Yes | Must be `"local"`. |
| `command` | Yes | Executable followed by its arguments. |
| `cwd` | No | Process working directory. Relative paths resolve from the workspace directory; the workspace is the default. |
| `environment` | No | String environment variables added to the inherited OpenCode process environment. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `timeout` | No | Per-server timeout overrides. |
Use `{env:NAME}` to substitute an environment variable while loading config. Shell expressions such as `$NAME` are not expanded in JSON strings.
## Remote servers
A remote server uses the MCP Streamable HTTP transport. Its `url` must be a valid absolute URL.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"oauth": false,
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
}
}
}
}
}
```
| Field | Required | Description |
| --- | --- | --- |
| `type` | Yes | Must be `"remote"`. |
| `url` | Yes | Streamable HTTP endpoint. |
| `headers` | No | String HTTP headers sent to the MCP endpoint. |
| `oauth` | No | OAuth client settings, or `false` to disable OAuth support. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `timeout` | No | Per-server timeout overrides. |
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential.
## OAuth
OAuth support is enabled for remote servers unless `oauth` is `false`. OpenCode discovers the authorization server, uses PKCE, refreshes tokens, and attempts dynamic client registration when the server supports it. OAuth credentials are stored outside project configuration.
For a server that supports dynamic client registration, only the remote server is required:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/mcp"
}
}
}
}
```
When the server reports that it needs authentication, run `/connect` in the TUI:
```text
/connect
```
Select the MCP server under **Services**, then complete the browser authorization flow.
You can also authenticate from the command line:
```bash
opencode2 mcp auth sentry
```
The CLI command prints the authorization URL and waits for the redirect to OpenCode's loopback callback server.
If the provider issued client credentials, configure them using V2's snake_case field names:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"company-tools": {
"type": "remote",
"url": "https://mcp.example.com/mcp",
"oauth": {
"client_id": "{env:MCP_CLIENT_ID}",
"client_secret": "{env:MCP_CLIENT_SECRET}",
"scope": "tools:read tools:execute",
"callback_port": 19876,
"redirect_uri": "http://127.0.0.1:19876/callback"
}
}
}
}
}
```
| OAuth field | Description |
| --- | --- |
| `client_id` | Pre-registered OAuth client ID. If omitted, OpenCode attempts dynamic client registration. |
| `client_secret` | Client secret for a pre-registered client. |
| `scope` | Space-delimited scopes to request. |
| `callback_port` | Local callback port, from `1` through `65535`. An available ephemeral port is used by default. |
| `redirect_uri` | Pre-registered loopback redirect URI. Its path and port must reach the local callback listener. |
Remove stored credentials with:
```bash
opencode2 mcp logout sentry
```
## Timeouts
Timeouts are positive integer milliseconds. Configure defaults under `mcp.timeout`; a server's `timeout` fields override matching defaults.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"timeout": {
"startup": 45000,
"catalog": 30000,
"execution": 600000
},
"servers": {
"slow-tools": {
"type": "remote",
"url": "https://mcp.example.com/mcp",
"timeout": {
"catalog": 60000
}
}
}
}
}
```
| Timeout | Default | Applies to |
| --- | --- | --- |
| `startup` | 30 seconds | Establishing the transport and initializing the server. |
| `catalog` | 30 seconds | Listing tools, prompts, resources, and resource templates. |
| `execution` | 12 hours | Calling tools, getting prompts, and reading resources. |
## Names and permissions
OpenCode combines the server name and MCP tool name as `<server>_<tool>`. Characters other than letters, numbers, `_`, and `-` are replaced with `_`; for example, server `context 7` and tool `resolve.library/id` become `context_7_resolve_library_id`. MCP prompts appear as slash commands named `<server>:<prompt>` using the same normalization.
Choose short server names that remain unique after normalization. Under the default Code Mode, MCP tools are grouped by the normalized server name.
Use permission actions to hide or deny a server's tools without stopping its connection:
```jsonc
{
"permissions": [
{
"action": "context7_*",
"resource": "*",
"effect": "deny"
}
]
}
```
## CLI commands
V2 provides these MCP management commands:
```bash
# Add a local server to the project config
opencode2 mcp add everything --env LOG_LEVEL=info -- npx -y @modelcontextprotocol/server-everything
# Add a remote server to the project config
opencode2 mcp add context7 --url https://mcp.context7.com/mcp --header 'CONTEXT7_API_KEY={env:CONTEXT7_API_KEY}'
# Add to the global config instead
opencode2 mcp add context7 --global --url https://mcp.context7.com/mcp
# List configured servers and connection status
opencode2 mcp list
# Authenticate or remove OAuth credentials
opencode2 mcp auth context7
opencode2 mcp logout context7
```
`mcp add` accepts either `--url` for a remote server or a command after `--` for a local server, not both. Use `--header NAME=VALUE` only with remote servers and `--env NAME=VALUE` only with local servers. Edit the config directly for OAuth, timeout, working-directory, or enablement settings.
@@ -0,0 +1,28 @@
{
"title": "Docs",
"description": "Use and configure OpenCode.",
"root": true,
"pages": [
"---Get started---",
"index",
"migrate-v1",
"config",
"troubleshooting",
"---Configure---",
"providers",
"models",
"agents",
"permissions",
"sharing",
"snapshots",
"commands",
"skills",
"instructions",
"mcp-servers",
"attachments",
"compaction",
"formatters",
"lsp",
"references"
]
}
@@ -0,0 +1,576 @@
---
title: "Migrate from V1"
description: "Move from OpenCode V1 to the OpenCode 2.0 beta."
---
## Breaking changes
V2 has three intentional breaking changes:
- [Plugins](#plugins) use a new plugin API.
- The [server API and clients](#server-api-and-clients) have new contracts.
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
All other functionality is intended to remain compatible with V1.
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
an expected migration requirement.
<Tip>
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
a compatibility issue.
</Tip>
<Warning>
OpenCode 2.0 is in beta. Beta data may be wiped, features may break unintentionally, and the server and plugin APIs may
continue to change.
</Warning>
During the beta, OpenCode V1 and V2 use different executable names. You can keep using `opencode` for V1 while trying V2
with `opencode2`.
## Install the beta
Install the beta from the `next` distribution tag:
```bash
npm install -g @opencode-ai/cli@next
```
Start it in your project with:
```bash
opencode2
```
## Configuration
This section covers both JSON/JSONC configuration and file-based definitions under `.opencode/`.
### Use your existing configuration
V2 reads existing global and project configuration from the same locations as V1:
```text
~/.config/opencode/opencode.json(c)
<project>/opencode.json(c)
<project>/.opencode/opencode.json(c)
```
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
### Ask OpenCode to migrate
The V1 config format remains supported. The native V2 format is optional and makes several settings more explicit and
ergonomic.
The recommended migration path is to ask OpenCode to update the configuration for you:
```text
Migrate my OpenCode configuration, including file-based definitions, from the V1 format to the native V2 format.
Preserve its behavior and all unrelated settings.
```
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
change. Do not mix V1 and V2 field names manually in one file.
### Sharing
The deprecated V1 `autoshare` boolean becomes the explicit `share` policy:
```jsonc
// V1
{ "autoshare": true }
// V2
{ "share": "auto" }
```
Use `"manual"`, `"auto"`, or `"disabled"`. If the V1 file already uses `share`, no change is needed.
### Permissions and tools
V1 groups permission effects by tool. V2 uses one ordered `permissions` array, making precedence and exceptions explicit:
```jsonc
// V1
{
"permission": {
"bash": {
"git push *": "ask"
},
"edit": "allow"
},
"tools": {
"websearch": false
}
}
// V2
{
"permissions": [
{ "action": "shell", "resource": "git push *", "effect": "ask" },
{ "action": "edit", "resource": "*", "effect": "allow" },
{ "action": "websearch", "resource": "*", "effect": "deny" }
]
}
```
Permission actions also changed: `bash` is now `shell`, `task` is now `subagent`, and `write` and `patch` are now `edit`.
See [Permissions](/docs/permissions) for the ordered V2 rule format.
### Agents and modes
The singular `agent` and deprecated `mode` maps become `agents`. Agent fields become more consistent with the rest of the
V2 config:
```jsonc
// V1
{
"agent": {
"reviewer": {
"prompt": "Review for correctness and missing tests.",
"model": "anthropic/claude-sonnet-4-5",
"variant": "high",
"disable": false,
"permission": {
"edit": "deny"
}
}
}
}
// V2
{
"agents": {
"reviewer": {
"system": "Review for correctness and missing tests.",
"model": "anthropic/claude-sonnet-4-5#high",
"disabled": false,
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" }
]
}
}
}
```
`prompt` becomes `system`, `disable` becomes `disabled`, and a separate `variant` joins the model reference after `#`.
`temperature`, `top_p`, and provider-specific `options` move under `request.body`. `maxSteps` becomes `steps`. Entries from
the old `mode` map become primary agents.
### Snapshots
Rename the singular `snapshot` field to `snapshots`. Its boolean value does not change:
```jsonc
// V1
{ "snapshot": false }
// V2
{ "snapshots": false }
```
### Attachments
Rename the singular `attachment` object to `attachments`. Nested image settings keep the same names:
```jsonc
// V1
{ "attachment": { "image": { "auto_resize": true } } }
// V2
{ "attachments": { "image": { "auto_resize": true } } }
```
### MCP servers
V2 groups servers under `mcp.servers`, replaces `enabled` with the inverse `disabled`, and separates timeout purposes:
```jsonc
// V1
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "@playwright/mcp"],
"enabled": true,
"timeout": 30000
}
}
}
// V2
{
"mcp": {
"servers": {
"playwright": {
"type": "local",
"command": ["npx", "@playwright/mcp"],
"disabled": false,
"timeout": {
"catalog": 30000,
"execution": 30000
}
}
}
}
}
```
Remote OAuth fields use snake case: `clientId` becomes `client_id`, `clientSecret` becomes `client_secret`,
`callbackPort` becomes `callback_port`, and `redirectUri` becomes `redirect_uri`. The V1 `experimental.mcp_timeout` value
also becomes the default `mcp.timeout.catalog` and `mcp.timeout.execution` values. See [MCP servers](/docs/mcp-servers).
### Compaction
V2 groups the retained-context token budget under `keep` and gives the reserve a clearer name:
```jsonc
// V1
{
"compaction": {
"preserve_recent_tokens": 8000,
"reserved": 20000
}
}
// V2
{
"compaction": {
"keep": {
"tokens": 8000
},
"buffer": 20000
}
}
```
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
See [Compaction](/docs/compaction).
### Skills
V1 separates extra skill paths and URLs. V2 combines both into one ordered array:
```jsonc
// V1
{
"skills": {
"paths": ["./team-skills"],
"urls": ["https://example.com/skills/"]
}
}
// V2
{
"skills": ["./team-skills", "https://example.com/skills/"]
}
```
Existing skill files and automatic `.opencode/skills/` discovery do not change. See [Skills](/docs/skills).
### Commands
Rename the singular `command` map to `commands`. Join a separate model `variant` to the model reference:
```jsonc
// V1
{
"command": {
"review": {
"template": "Review the current changes.",
"model": "anthropic/claude-sonnet-4-5",
"variant": "high"
}
}
}
// V2
{
"commands": {
"review": {
"template": "Review the current changes.",
"model": "anthropic/claude-sonnet-4-5#high"
}
}
}
```
`template`, `description`, `agent`, and `subtask` keep their names. Existing Markdown command definitions remain supported.
See [Commands](/docs/commands).
### References
Rename the deprecated singular `reference` map to `references`:
```jsonc
// V1
{ "reference": { "docs": "../docs" } }
// V2
{ "references": { "docs": "../docs" } }
```
V1 already accepts `references`, so no change is needed when the file uses it. Reference entries keep the
same shapes. See [References](/docs/references).
### Providers
Rename the singular `provider` map to `providers`. V2 separates the runtime package, endpoint, and request settings:
```jsonc
// V1
{
"provider": {
"acme": {
"npm": "@ai-sdk/openai-compatible",
"api": "https://llm.example.com/v1",
"options": {
"apiKey": "{env:ACME_API_KEY}"
}
}
}
}
// V2
{
"providers": {
"acme": {
"package": "aisdk:@ai-sdk/openai-compatible",
"settings": {
"baseURL": "https://llm.example.com/v1",
"apiKey": "{env:ACME_API_KEY}"
}
}
}
}
```
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/docs/providers).
### Models and variants
Models remain nested under their provider, but several model fields become more explicit:
- `id` becomes `modelID`.
- `tool_call` and `modalities` become `capabilities.tools`, `capabilities.input`, and `capabilities.output`.
- A `status` of `"deprecated"` becomes `disabled: true`.
- Cache costs move from `cache_read` and `cache_write` to `cache.read` and `cache.write`.
- Provider-specific `options` become `settings`.
- A V1 variants object becomes a V2 array with an `id` on each entry.
```jsonc
// V1
{
"variants": {
"high": {
"reasoningEffort": "high"
}
}
}
// V2
{
"variants": [
{
"id": "high",
"settings": {
"reasoningEffort": "high"
}
}
]
}
```
See [Models](/docs/models) for the complete native model shape.
### Fields without native equivalents
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
These V1 fields do not have one-to-one native V2 config fields:
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
### Agent files
V1 agent files may use `agent/`, `agents/`, `mode/`, or `modes/`. V2 still discovers all four directories. The preferred
V2 location is:
```text
.opencode/agents/<name>.md
```
Files under a V1 `mode/` or `modes/` directory represent primary agents. When moving one into `agents/`, add
`mode: primary` to its frontmatter. Files under `agent/` can move to `agents/` without changing their path-derived ID.
When converting the frontmatter to native V2 fields:
- Keep the Markdown body as the agent's system instructions.
- Rename `prompt` to `system` when it appears in JSON configuration; file bodies do not need a `system` field.
- Rename `disable` to `disabled` and `permission` to `permissions`.
- Join `model` and `variant` as `provider/model#variant`.
- Move `temperature`, `top_p`, and provider-specific options under `request.body`.
V2 translates legacy agent frontmatter automatically, so these edits are optional. See [Agents](/docs/agents).
### Command files
V1 command files may use `command/` or `commands/`. V2 discovers both. The preferred location is:
```text
.opencode/commands/<name>.md
```
Move files from `command/` to the same relative path under `commands/` to preserve command names. The Markdown body remains
the command template, and `description`, `agent`, and `subtask` frontmatter keep the same names. If frontmatter has separate
`model` and `variant` fields, append the variant to the model and remove `variant`:
```yaml
# V1
model: anthropic/claude-sonnet-4-5
variant: high
# V2
model: anthropic/claude-sonnet-4-5#high
```
See [Commands](/docs/commands).
### Skill files
V2 discovers skills from both `.opencode/skill/` and `.opencode/skills/`. The preferred layout is:
```text
.opencode/skills/<skill-id>/SKILL.md
```
Move the complete skill directory, not only `SKILL.md`, so relative scripts, references, and other supporting files remain
available. Keep the directory name stable to preserve the skill ID. Existing skill frontmatter and Markdown bodies do not
require a V2 rewrite. See [Skills](/docs/skills).
### Instruction files
Existing `AGENTS.md` files stay in place. V2 discovers the global `~/.config/opencode/AGENTS.md` and project `AGENTS.md`
files from the current directory up to the project root.
If a V1 setup relied on a `CLAUDE.md` fallback, move that guidance into the applicable `AGENTS.md`. V2 currently only
discovers `AGENTS.md`; because non-API V1 behavior is intended to remain compatible, also run `/report` with the affected
project details. See [Instructions](/docs/instructions).
## TUI configuration
V1 loaded `tui.json(c)` from the global config directory and from project directories discovered while walking up from
the current directory. V2 instead stores CLI and TUI settings in one global file:
```text
~/.config/opencode/cli.json
```
The CLI owns this file. The background service does not load it, and V2 does not discover or merge project-local
`tui.json(c)` or `cli.json` files.
The native V2 format groups related settings. For example:
```jsonc
// V1: ~/.config/opencode/tui.json
{
"theme": "tokyonight",
"scroll_speed": 2,
"scroll_acceleration": {
"enabled": true
}
}
// V2: ~/.config/opencode/cli.json
{
"theme": {
"name": "tokyonight"
},
"scroll": {
"speed": 2,
"acceleration": true
}
}
```
V2 migrates the global TUI configuration automatically. On the first CLI or TUI startup, when `cli.json` does not already
exist, it:
- Reads `~/.config/opencode/tui.json`.
- Reads persisted TUI preferences from the legacy `kv.json` state file.
- Converts supported settings to the native grouped format and writes `~/.config/opencode/cli.json`.
- Leaves the V1 files unchanged so V1 can continue using them.
Migration runs only while `cli.json` is absent. Once that file exists, V2 treats it as the source of truth and does not
continually synchronize later changes from `tui.json` or `kv.json`. If you created `cli.json` before starting V2, merge any
V1 settings you still need into it manually.
Project-local V1 TUI configuration is not migrated because V2 has no project-local CLI configuration. Move settings you
still want into the global `cli.json`; when multiple projects used different values for the same setting, choose the
global behavior you want V2 to use.
## Plugins
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
```jsonc
// V1
{
"plugin": [
"opencode-example-plugin",
["./plugin/local.ts", { "enabled": true }]
]
}
// V2
{
"plugins": [
"opencode-example-plugin",
{
"package": "./plugin/local.ts",
"options": { "enabled": true }
}
]
}
```
V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files.
Moving a file between these directories does not migrate its implementation.
<Warning>V1 plugins will not work in V2.</Warning>
The config entry can be translated automatically, but plugin implementation code must be ported to the new API. The V2
plugin API is still being finalized during beta, and detailed plugin migration guidance will be published when it is
ready.
Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related
local modules and dependencies together. See the current beta [Plugins guide](/docs/build/plugins).
## Server API and clients
OpenCode 2 has a revised, more ergonomic server API and a new set of clients. Integrations that call the V1 server API
must migrate to the V2 API.
Use the `@opencode-ai/client` package to access the new clients. The server API and clients are still being finalized
during beta, so their contracts may continue to change. See the generated [API reference](/docs/api) for the current endpoints,
request types, and responses.
## Verify your setup
Start `opencode2` in a project and verify your model, provider credentials, agents, permissions, MCP servers, and plugins
before relying on the beta for regular work. Keep your V1 setup until you have confirmed the V2 behavior you need, and do
not point V1 at configuration that you have converted to the native V2 shape.
+213
View File
@@ -0,0 +1,213 @@
---
title: "Models"
description: ""
---
OpenCode builds its model catalog from [Models.dev](https://models.dev), provider integrations, and your configuration.
Only enabled models whose provider is available for the current project appear in the model picker.
Connect a provider with `/connect` in the TUI, or configure it in [Providers](/docs/providers).
## Choose a model
Open the model picker with `/models` or the default `<leader>m` keybind. The picker shows the models available from
providers connected to the current project.
Select a model to use it in the current session. Switching models updates that session without changing your config. Use
the catalog entries shown in the picker rather than guessing a provider or model name.
## Per-run model
Select a model for one non-interactive run with `--model` or `-m`:
```bash
opencode2 run --model openai/gpt-5.2 "Explain this repository"
opencode2 run -m openai/gpt-5.2#high "Review the current changes"
```
Agents and commands can also select their own model. See [Agents](/docs/agents) and [Commands](/docs/commands).
## Variants
Variants are named request overlays for one model, commonly used for reasoning effort or token budgets. Available names
are model-specific and are derived from current catalog metadata. Do not assume that names such as `low`, `high`, or
`max` exist for every model; `/variants` shows the valid choices.
Use `/variants` to choose one for the current model, or press `ctrl+t` to cycle through available variants.
## Configure
### Default model
Set `model` in `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet-4-5"
}
```
The configured model becomes the catalog default when its provider is available and the model is enabled. Otherwise,
session execution falls back to the newest available supported model. An explicit model already selected on a session
takes precedence over the default; switching models changes that session and does not rewrite your config.
See [Config](/docs/config) for configuration locations and precedence.
### Model settings
Provider and model entries can supply three kinds of request configuration:
- `settings` contains provider-package options such as `baseURL`, `reasoningEffort`, or `thinkingConfig`.
- `headers` adds HTTP request headers.
- `body` adds provider-specific fields to the request body.
These values are provider-specific JSON. OpenCode applies provider values first, then model values, then the selected
variant. Nested `settings` and `body` objects are merged; later array and scalar values replace earlier values. Header
names are matched case-insensitively.
You can also map a friendly catalog ID to a different API model ID with `modelID`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/coding-default",
"providers": {
"openai": {
"models": {
"coding-default": {
"modelID": "gpt-5.2",
"name": "Coding default",
"capabilities": {
"tools": true,
"input": ["text", "image"],
"output": ["text"]
},
"limit": {
"context": 200000,
"output": 32000
}
}
}
}
}
}
```
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. When adding a
model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and
enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog.
### Custom variants
Add a variant, or override a catalog variant with the same ID, under the model's `variants` array:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"models": {
"gpt-5.2": {
"settings": {
"reasoningEffort": "medium"
},
"variants": [
{
"id": "fast",
"settings": {
"reasoningEffort": "low"
}
},
{
"id": "deep",
"settings": {
"reasoningEffort": "high",
"reasoningSummary": "auto"
}
}
]
}
}
}
}
}
```
Variant entries support `settings`, `headers`, and `body`. Selecting one deeply overlays its values on the effective
provider and model configuration. An unknown variant fails model resolution instead of silently using the base model.
### Local models
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "local/coder",
"providers": {
"local": {
"name": "Local server",
"package": "aisdk:@ai-sdk/openai-compatible",
"settings": {
"baseURL": "http://127.0.0.1:1234/v1"
},
"models": {
"coder": {
"modelID": "model-name-on-server",
"capabilities": {
"tools": true,
"input": ["text"],
"output": ["text"]
},
"limit": {
"context": 32768,
"output": 8192
}
}
}
}
}
}
```
Use the server's real model name, limits, modalities, and tool support. OpenCode cannot infer these for a model you add
manually. If the endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
`"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets.
### Model references
Configuration and CLI options identify a model as `provider/model`, with an optional `#variant`:
```text
openai/gpt-5.2
openai/gpt-5.2#high
openrouter/anthropic/claude-sonnet-4.5#high
```
OpenCode splits the reference at the first `/`, so model IDs may contain additional slashes. Provider and model IDs are
case-sensitive. Provider IDs cannot contain `/` or `#`, and model IDs cannot contain `#`.
The expanded config form is equivalent when generated or programmatic configuration is more convenient:
```jsonc
{
"model": {
"providerID": "openrouter",
"model": "anthropic/claude-sonnet-4.5"
}
}
```
Root, agent, and command `model` fields accept both forms. Use the IDs shown by `/models`, not provider display names.
### Caveats
- The selector object uses `model`, while a provider catalog entry uses `modelID` for the upstream API identifier.
- The root `model` currently sets the default provider and model only. Although its selection shape accepts a variant,
the V2 catalog default does not retain it; select a variant in the TUI, with `opencode2 run`, or on an agent or command.
- Model options are provider-specific. A setting accepted by one provider package may be ignored or rejected by another.
- Catalog data, credentials, and config are location-scoped. A model available in one project may be unavailable in
another.
- Configuration files are watched and normally reload automatically, but an in-flight model request keeps the settings
with which it started.
@@ -0,0 +1,209 @@
---
title: "Permissions"
description: ""
---
Permissions control whether an agent may perform an action on a resource. V2
configuration uses the `permissions` field and an ordered array of rules.
<Warning>
The V1 object syntax uses different field and action names. Do not use
`permission`, `bash`, or `task` in V2 configuration; use `permissions`,
`shell`, and `subagent`.
</Warning>
## Rule schema
Each rule has three required string fields:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "*", "resource": "*", "effect": "ask" },
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "deny" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" },
{ "action": "edit", "resource": "packages/docs/*.mdx", "effect": "allow" }
]
}
```
- `action` matches a tool permission action.
- `resource` matches the value the tool is trying to use, such as a path,
command, URL, query, or agent ID.
- `effect` is `"allow"`, `"deny"`, or `"ask"`.
`allow` proceeds without prompting, `deny` blocks the operation, and `ask`
waits for a user decision. If no rule matches, the result is `ask`.
## Matching and order
Both `action` and `resource` support simple wildcards:
- `*` matches zero or more characters, including `/`.
- `?` matches exactly one character.
- All other characters are literal.
Matches cover the entire value. Slashes are normalized, and matching is
case-insensitive on Windows. For shell convenience, a pattern ending in
`" *"` also matches the command without arguments: `"git status *"` matches
both `git status` and `git status --short`.
The **last matching rule wins**. Put broad rules first and exceptions later.
Rules from lower-priority configuration files are loaded first. OpenCode then
appends all global rules before agent-specific rules, so a matching agent rule
overrides a global rule.
Some operations check several resources at once, such as a patch touching
multiple files. OpenCode denies the operation if any resource resolves to
`deny`; otherwise it asks if any resolves to `ask`; otherwise it allows it.
## Actions and resources
V2 action names are strings, so plugins may introduce additional actions. The
current built-in actions use these resources:
| Action | Resource matched |
| --- | --- |
| `read` | Location-relative path for an internal file or directory; canonical absolute path for an external target |
| `edit` | Target path for `edit`, `write`, and `patch`; all three tools share this action |
| `glob` | The requested glob pattern |
| `grep` | The requested regular expression, not the search path |
| `shell` | The complete raw shell command string |
| `subagent` | The target agent ID |
| `skill` | The skill ID |
| `question` | `*` |
| `webfetch` | The requested URL |
| `websearch` | The search query |
| `external_directory` | A canonical external directory boundary, normally ending in `/*` |
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
Built-in agent policy also reserves `plan_enter` and `plan_exit` for plan-mode
transitions. `doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories
An external path requires a separate `external_directory` decision before the
tool's own `read` or `edit` decision. This applies to external paths used by
`read`, `edit`, `write`, and `patch`, and to an external `shell` working
directory.
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{
"action": "external_directory",
"resource": "~/projects/reference/*",
"effect": "allow"
},
{
"action": "read",
"resource": "~/projects/reference/*",
"effect": "allow"
},
{
"action": "edit",
"resource": "~/projects/reference/*",
"effect": "deny"
}
]
}
```
For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
raw command text and are **not** home-expanded.
<Warning>
`shell` runs with the host user's filesystem, process, and network authority.
Its resource is raw text, not a parsed command. External command arguments
produce only best-effort warnings; `external_directory` is enforced for the
working directory, not every path embedded in a command. Prefer a narrow
shell allowlist over patterns intended to identify every dangerous command.
</Warning>
Relative mutation paths cannot escape the active Location, and symlink escapes
from inside it are rejected. Explicit external paths are canonicalized before
matching, so authorize only trusted directory boundaries.
## Defaults
The evaluator's fallback is `ask`, but shipped agents include ordered defaults:
| Agent | Effective default policy |
| --- | --- |
| `build` | Allows most actions; asks for external directories and `.env` reads; allows questions and entering plan mode; denies exiting plan mode |
| `plan` | Uses the same base, allows questions and exiting plan mode, and denies edits except OpenCode plan files |
| `general` | Uses the base policy but cannot launch another subagent; questions and plan transitions remain denied |
| `explore` | Denies everything except `read`, `glob`, `grep`, `webfetch`, and `websearch`; cannot launch subagents and asks for external directories |
| Hidden maintenance agents | Deny all actions |
The base read rules are ordered as follows:
```jsonc
[
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "ask" },
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
{ "action": "read", "resource": "*.env.example", "effect": "allow" }
]
```
OpenCode also permits its managed tool-output and temporary directories where
needed. These exceptions do not grant general external-directory access.
## Agent overrides
Configure shared policy at the top level and append narrower rules to a named
agent under `agents.<id>.permissions`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" }
],
"agents": {
"reviewer": {
"description": "Review code without changing it",
"mode": "subagent",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" }
]
}
}
}
```
Agent rules do not replace the global array; they are appended after it. A
custom subagent executes with its own permissions, not a permission subset
derived from the parent agent.
## Approval choices
When an `ask` rule matches, clients can reply with:
- **Allow once** (`once`): approve only the pending request.
- **Allow always** (`always`): approve this request and save the patterns
proposed by the tool for the current project.
- **Reject** (`reject`): reject the request. Rejecting also rejects other
pending permission requests in the same session; clients may attach feedback.
Saved approvals are durable and project-scoped. They are additional `allow`
rules, but they can never override a configured `deny`. The proposed saved
pattern may be broader than the displayed resource: several tools propose `*`,
shell proposes the exact command text, and skills and subagents propose their
IDs. Review the confirmation carefully and remove saved approvals that are no
longer needed.
For non-interactive runs, `opencode2 run --auto` replies `once` to permission
requests. It does not save approvals, and explicit `deny` rules remain enforced.
Without `--auto`, a non-interactive run rejects permission requests.
@@ -0,0 +1,204 @@
---
title: "Providers"
description: ""
---
OpenCode builds its provider and model catalog from [Models.dev](https://models.dev), then applies the `providers`
overlays from your [configuration](/docs/config). A provider needs both a usable runtime package and, when required, an
active connection.
## Connect a provider
Run `/connect` in the TUI, choose an integration, and complete one of the methods it offers:
```text
/connect
```
An integration may support an API key, OAuth, environment variables, or a combination of them. API keys and OAuth
tokens entered through `/connect` are stored by the OpenCode service in its database. Run `/connect` again to replace
or remove a stored credential.
Providers from Models.dev also declare their standard environment variables. A non-empty declared variable is exposed
as an environment connection automatically, so common providers usually need no config:
```bash
export ANTHROPIC_API_KEY="your-key"
```
For a custom provider, `env` declares the variables that can supply its key:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"acme": {
"env": ["ACME_API_KEY"]
}
}
}
```
When several credential sources exist, OpenCode uses the stored credential first, then the first non-empty variable in
`env`, then `settings.apiKey`. Use config substitution instead of committing a literal key:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"acme": {
"settings": {
"apiKey": "{env:ACME_API_KEY}"
}
}
}
}
```
<Warning>Do not commit API keys or authorization headers to your repository.</Warning>
## Configure
The `providers` object is keyed by provider ID. Each provider accepts these fields:
| Field | Purpose |
| --- | --- |
| `name` | Display name. |
| `env` | Ordered environment variable names that provide a connection. |
| `package` | Runtime provider package. |
| `settings` | JSON settings passed to the runtime package, such as `baseURL`. |
| `headers` | String-valued HTTP headers added to requests. |
| `body` | JSON fields merged into request bodies. |
| `models` | Models to add or override, keyed by catalog model ID. |
Configuration files are applied from lowest to highest precedence. `settings` and `body` are deep-merged. Headers are
merged case-insensitively. At request time, provider values are inherited by the model, model values override them, and
the selected variant is applied last.
### Endpoint
Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,
models, and connection continue to apply:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"anthropic": {
"settings": {
"baseURL": "https://llm-proxy.example.com/anthropic"
}
}
}
}
```
`settings` is package-specific. A field only has an effect when the selected package supports it.
### Headers and body
Headers and body fields can be set at provider, model, or variant scope:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"headers": {
"X-Gateway-Tenant": "engineering"
},
"body": {
"metadata": {
"application": "opencode"
}
},
"models": {
"gpt-5.2": {
"headers": {
"X-Model-Policy": "coding"
}
}
}
}
}
}
```
These are request overlays, not a generic authentication scheme. Prefer `/connect`, `env`, or `settings.apiKey` for
provider credentials unless the endpoint explicitly requires a custom header.
### Provider packages
For an OpenAI-compatible service, use the V2 native compatible package. The model map is explicit because a custom
provider has no Models.dev catalog entries:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "acme/qwen3-coder",
"providers": {
"acme": {
"name": "Acme Gateway",
"env": ["ACME_API_KEY"],
"package": "@opencode-ai/llm/providers/openai-compatible",
"settings": {
"baseURL": "https://llm.acme.example/v1"
},
"models": {
"qwen3-coder": {
"name": "Qwen 3 Coder",
"capabilities": {
"tools": true,
"input": ["text"],
"output": ["text"]
},
"limit": {
"context": 131072,
"output": 32768
}
}
}
}
}
}
```
Omit `env` for an endpoint that does not require authentication. The native compatible package requires
`settings.baseURL` and uses bearer authentication when a key is available.
The `package` field supports two runtime contracts:
| Form | Contract |
| --- | --- |
| `"@opencode-ai/llm/providers/openai-compatible"` | A V2 native package exporting `model(modelID, settings)`. An npm specifier or absolute `file://` URL may use the same contract. |
| `"aisdk:@ai-sdk/openai-compatible"` | An AI SDK provider package. The `aisdk:` prefix is required. |
Native packages receive the merged `settings` plus the resolved `apiKey`, `headers`, `body`, and `limits`. AI SDK
packages receive their merged provider options. Use a package's own documentation for accepted settings; OpenCode does
not validate package-specific keys.
`package` may also be set on one model to override the provider package for that model.
### Models
Add a model under a provider's `models` map. The object key is the model ID used in OpenCode; `modelID` is the ID sent to
the provider:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/coding",
"providers": {
"openai": {
"models": {
"coding": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Coding"
}
}
}
}
}
```
See [Models](/docs/models) for model selection, defaults, capabilities, limits, costs, and variants.

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