mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5ac6ba4f3 |
+245
@@ -0,0 +1,245 @@
|
||||
# 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.
|
||||
@@ -12,7 +12,6 @@
|
||||
"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",
|
||||
@@ -103,8 +102,6 @@
|
||||
"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:",
|
||||
|
||||
@@ -627,6 +627,7 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
this.themes.splice(index, 1)
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -1022,6 +1023,7 @@ export class RunFooter implements FooterApi {
|
||||
void resolveRunTheme(this.renderer).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(theme: RunTheme): SyntaxStyle {
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
active.renderable.fg = entryColor(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(active.commit, 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(this.theme),
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
|
||||
@@ -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(theme()))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
||||
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
||||
|
||||
@@ -47,6 +47,7 @@ export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
@@ -171,10 +172,42 @@ 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]
|
||||
@@ -469,7 +502,10 @@ 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)
|
||||
@@ -530,6 +566,7 @@ function map(
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: scrollbackTheme.diffAdded,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
@@ -640,7 +677,13 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed),
|
||||
syntax,
|
||||
shared.generateSubtleSyntax(syntaxTheme),
|
||||
)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
@@ -35,23 +35,9 @@ 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",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -70,17 +56,12 @@ 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 },
|
||||
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,
|
||||
diffs: { view: "unified" },
|
||||
prompt: { paste: "full" },
|
||||
session: { grouping: "none" },
|
||||
hints: { tips: 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)
|
||||
|
||||
@@ -171,6 +171,7 @@ 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
|
||||
|
||||
|
||||
@@ -727,16 +727,16 @@ const invokeArrayMethod = <R>(
|
||||
}
|
||||
return undefined
|
||||
case "reduce": {
|
||||
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
|
||||
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
|
||||
}
|
||||
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 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
|
||||
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
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
if (!(index in target)) continue
|
||||
@@ -764,8 +764,7 @@ const invokeArrayMethod = <R>(
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) return item
|
||||
if (yield* apply([target[index], index, target])) return target[index]
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
|
||||
@@ -26,11 +26,9 @@
|
||||
* - 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
|
||||
@@ -212,11 +210,6 @@ 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`,
|
||||
@@ -232,11 +225,6 @@ 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`,
|
||||
@@ -335,46 +323,3 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -543,8 +543,7 @@ const layer = Layer.effect(
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
|
||||
@@ -1614,14 +1614,14 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
|
||||
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.tool("call-active", "echo", { text: "active" }),
|
||||
reply.text("Active complete", "text-active"),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
reply.text("Steer complete", "text-steer"),
|
||||
reply.text("Queue complete", "text-queue"),
|
||||
|
||||
Vendored
+5
-14
@@ -1,21 +1,12 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed OpenCode directly in your application."
|
||||
description: "Embed an OpenCode host in an Effect application."
|
||||
---
|
||||
|
||||
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.
|
||||
`@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.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
|
||||
@@ -159,6 +159,7 @@ 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/"
|
||||
@@ -184,12 +185,13 @@ for (const item of targets) {
|
||||
windows: {},
|
||||
},
|
||||
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
||||
entrypoints: ["./src/index.ts", parserWorker, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(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") } : {}),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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)))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
@@ -18,7 +18,9 @@ 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"
|
||||
@@ -80,6 +82,8 @@ const cli = yargs(args)
|
||||
.command(AcpCommand)
|
||||
.command(McpCommand)
|
||||
.command(V2ServeCommand)
|
||||
.command(TuiThreadCommand)
|
||||
.command(AttachCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
|
||||
@@ -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 } from "@opencode-ai/tui/plugin/runtime"
|
||||
import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
|
||||
|
||||
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
||||
|
||||
@@ -1121,4 +1121,11 @@ async function load(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function createLegacyTuiPluginHost(): TuiPluginHost {
|
||||
return {
|
||||
start: init,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
export * as TuiPluginRuntime from "./runtime"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -28,4 +29,5 @@ 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,
|
||||
syntax: previousSyntax,
|
||||
subtleSyntax: previousSyntax,
|
||||
},
|
||||
}
|
||||
const next = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
syntax: nextSyntax,
|
||||
subtleSyntax: nextSyntax,
|
||||
},
|
||||
}
|
||||
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
|
||||
|
||||
@@ -67,7 +67,9 @@ 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)
|
||||
@@ -80,6 +82,7 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,6 +103,7 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
|
||||
expectIndexed(theme.block.warning)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -115,7 +119,9 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -132,7 +138,9 @@ 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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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")')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -358,7 +358,6 @@ 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
|
||||
@@ -435,7 +434,6 @@ type TuiConfigView = {
|
||||
sidebar?: "auto" | "hide"
|
||||
scrollbar?: boolean
|
||||
thinking?: "show" | "hide"
|
||||
markdown?: "source" | "rendered"
|
||||
grouping?: "auto" | "none"
|
||||
}
|
||||
hints?: { tips?: boolean; onboarding?: boolean }
|
||||
@@ -630,7 +628,6 @@ 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
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
"./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",
|
||||
|
||||
+176
-105
@@ -44,6 +44,7 @@ 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"
|
||||
@@ -70,11 +71,13 @@ 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"
|
||||
@@ -136,6 +139,7 @@ const appBindingCommands = [
|
||||
"diff.open",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
"terminal.suspend",
|
||||
"terminal.title.toggle",
|
||||
"app.toggle.animations",
|
||||
@@ -151,7 +155,8 @@ export type TuiInput = {
|
||||
reload?: () => Promise<void>
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
config: Config.Interface | TuiConfigV1.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
pluginHost: TuiPluginHost
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
@@ -179,12 +184,62 @@ 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 config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
|
||||
terminalSuspend: process.platform !== "win32",
|
||||
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(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(
|
||||
@@ -321,66 +376,72 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
service={loaded.service}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
@@ -412,21 +473,25 @@ 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 configState = useConfig()
|
||||
const config = configState.data
|
||||
const configContext = useConfig()
|
||||
const config = configContext.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
|
||||
@@ -435,7 +500,7 @@ function App(props: {
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config, update: configState.update })
|
||||
const attention = createTuiAttention({ renderer, config, kv })
|
||||
const clipboard = useClipboard()
|
||||
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
@@ -472,11 +537,12 @@ function App(props: {
|
||||
tuiConfig: config,
|
||||
dialog,
|
||||
keymap,
|
||||
kv,
|
||||
route,
|
||||
routes: pluginRuntime.routes,
|
||||
event,
|
||||
sdk,
|
||||
project,
|
||||
sync,
|
||||
data,
|
||||
theme: themeState,
|
||||
toast,
|
||||
@@ -489,6 +555,7 @@ function App(props: {
|
||||
props.pluginHost
|
||||
.start({
|
||||
api,
|
||||
config: props.pluginConfig,
|
||||
runtime: pluginRuntime,
|
||||
dispose: () => attention.dispose(),
|
||||
})
|
||||
@@ -524,8 +591,8 @@ function App(props: {
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const terminalTitleEnabled = () => config.terminal?.title ?? true
|
||||
const pasteSummaryEnabled = () => config.prompt?.paste !== "full"
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
|
||||
@@ -792,6 +859,7 @@ function App(props: {
|
||||
name: "opencode.settings",
|
||||
title: "Open settings",
|
||||
slashName: "settings",
|
||||
enabled: configContext.writable,
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogConfig />)
|
||||
},
|
||||
@@ -855,7 +923,6 @@ 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()
|
||||
@@ -865,7 +932,6 @@ function App(props: {
|
||||
{
|
||||
name: "theme.mode.lock",
|
||||
title: locked() ? "Unlock theme mode" : "Lock theme mode",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
if (locked()) unlock()
|
||||
else lock()
|
||||
@@ -917,6 +983,20 @@ 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",
|
||||
@@ -933,57 +1013,41 @@ function App(props: {
|
||||
name: "terminal.title.toggle",
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
const next = !terminalTitleEnabled()
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.terminal = { ...draft.terminal, title: next }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setTerminalTitleEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("terminal_title_enabled", next)
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
return next
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.animations",
|
||||
title: (config.animations ?? true) ? "Disable animations" : "Enable animations",
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.animations = !(config.animations ?? true)
|
||||
})
|
||||
.catch(toast.error)
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.file_context",
|
||||
title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
|
||||
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) }
|
||||
})
|
||||
.catch(toast.error)
|
||||
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.diffwrap",
|
||||
title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -991,13 +1055,12 @@ function App(props: {
|
||||
name: "app.toggle.paste_summary",
|
||||
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setPasteSummaryEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("paste_summary_enabled", next)
|
||||
return next
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1095,13 +1158,21 @@ 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?`,
|
||||
"later",
|
||||
"skip",
|
||||
)
|
||||
|
||||
if (choice === false) {
|
||||
kv.set("skipped_version", version)
|
||||
return
|
||||
}
|
||||
|
||||
if (choice !== true) return
|
||||
|
||||
toast.show({
|
||||
|
||||
@@ -38,8 +38,9 @@ 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 = {
|
||||
@@ -113,8 +114,6 @@ 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 {
|
||||
@@ -135,7 +134,8 @@ export function createTuiAttention(input: {
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
return activePackID ?? input.config.attention.sound_pack
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
@@ -234,12 +234,7 @@ export function createTuiAttention(input: {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist)
|
||||
void input
|
||||
.update?.((draft) => {
|
||||
draft.attention = { ...draft.attention, sound_pack: pack.id }
|
||||
})
|
||||
.catch(() => {})
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Setting = {
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
detail?: string
|
||||
path: string[]
|
||||
default: unknown
|
||||
values?: readonly unknown[]
|
||||
@@ -21,12 +26,18 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Theme",
|
||||
category: "Appearance",
|
||||
description: "Interface color theme",
|
||||
detail:
|
||||
"Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
|
||||
path: ["theme", "name"],
|
||||
default: "opencode",
|
||||
},
|
||||
{
|
||||
title: "Color mode",
|
||||
category: "Appearance",
|
||||
description: "Terminal color preference",
|
||||
detail:
|
||||
"Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
|
||||
path: ["theme", "mode"],
|
||||
default: "system",
|
||||
values: ["system", "dark", "light"],
|
||||
@@ -34,6 +45,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Animations",
|
||||
category: "Appearance",
|
||||
description: "Interface motion",
|
||||
path: ["animations"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -42,6 +54,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Tips",
|
||||
category: "Appearance",
|
||||
description: "Home screen hints",
|
||||
path: ["hints", "tips"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -50,6 +63,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
description: "Getting-started guidance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -58,6 +72,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
description: "Session sidebar visibility",
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
@@ -65,6 +80,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
description: "Transcript scrollbar",
|
||||
path: ["session", "scrollbar"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -73,20 +89,15 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
description: "Model reasoning by default",
|
||||
path: ["session", "thinking"],
|
||||
default: "hide",
|
||||
values: ["hide", "show"],
|
||||
},
|
||||
{
|
||||
title: "Markdown",
|
||||
category: "Session",
|
||||
path: ["session", "markdown"],
|
||||
default: "rendered",
|
||||
values: ["source", "rendered"],
|
||||
},
|
||||
{
|
||||
title: "Grouping",
|
||||
category: "Session",
|
||||
description: "Related transcript items",
|
||||
path: ["session", "grouping"],
|
||||
default: "auto",
|
||||
values: ["none", "auto"],
|
||||
@@ -94,6 +105,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
description: "Diff presentation",
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
@@ -101,6 +113,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Wrapping",
|
||||
category: "Diffs",
|
||||
description: "Long diff lines",
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
@@ -108,6 +121,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "File tree",
|
||||
category: "Diffs",
|
||||
description: "Diff file navigation",
|
||||
path: ["diffs", "tree"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -116,6 +130,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Single patch",
|
||||
category: "Diffs",
|
||||
description: "Only the selected patch",
|
||||
path: ["diffs", "single"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -124,6 +139,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scroll speed",
|
||||
category: "Input",
|
||||
description: "Distance per input tick",
|
||||
path: ["scroll", "speed"],
|
||||
default: 3,
|
||||
step: 0.25,
|
||||
@@ -134,6 +150,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Acceleration",
|
||||
category: "Input",
|
||||
description: "Repeated scrolling",
|
||||
path: ["scroll", "acceleration"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -142,6 +159,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Mouse",
|
||||
category: "Input",
|
||||
description: "Terminal mouse capture",
|
||||
path: ["mouse"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -150,6 +168,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Editor context",
|
||||
category: "Input",
|
||||
description: "Active selection in prompts",
|
||||
path: ["prompt", "editor"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -158,6 +177,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Large pastes",
|
||||
category: "Input",
|
||||
description: "Paste display style",
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
@@ -165,6 +185,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
description: "Wait after leader key",
|
||||
path: ["leader", "timeout"],
|
||||
default: 2000,
|
||||
step: 250,
|
||||
@@ -175,6 +196,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
description: "Alerts when input is needed",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -183,6 +205,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
description: "System notifications",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -191,6 +214,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
description: "Attention sounds",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -199,6 +223,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Volume",
|
||||
category: "Alerts",
|
||||
description: "Attention sound level",
|
||||
path: ["attention", "volume"],
|
||||
default: 0.4,
|
||||
step: 0.1,
|
||||
@@ -209,6 +234,7 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Window title",
|
||||
category: "Terminal",
|
||||
description: "Update terminal title",
|
||||
path: ["terminal", "title"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -218,10 +244,17 @@ const settings: Setting[] = [
|
||||
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const { theme } = themeState
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [selected, setSelected] = createSignal(settings[0])
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
onMount(() => {
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
const current = setting.path.reduce<unknown>((result, key) => {
|
||||
@@ -242,17 +275,18 @@ export function DialogConfig() {
|
||||
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
|
||||
}
|
||||
const options = createMemo(() =>
|
||||
settings.map((setting, index) => ({
|
||||
settings.map((setting) => ({
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
footer: display(setting),
|
||||
value: index,
|
||||
value: setting,
|
||||
footer: selected() === setting ? `‹ ${display(setting)} ›` : ` ${display(setting)} `,
|
||||
})),
|
||||
)
|
||||
const split = createMemo(() => dimensions().width >= 110)
|
||||
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
|
||||
|
||||
async function change(direction: number, index = selected()) {
|
||||
async function change(setting: Setting, direction: number) {
|
||||
if (saving()) return
|
||||
const setting = settings[index]
|
||||
const current = value(setting)
|
||||
const choices = values(setting)
|
||||
const next = choices
|
||||
@@ -273,26 +307,48 @@ export function DialogConfig() {
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<box flexDirection="row" height={height() + 1}>
|
||||
<box width={split() ? "54%" : "100%"}>
|
||||
<DialogSelect
|
||||
title="Settings"
|
||||
options={options()}
|
||||
renderFilter={false}
|
||||
hideClose={split()}
|
||||
maxHeight={height() - 2}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value, 1)}
|
||||
bindings={[
|
||||
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(selected(), -1) },
|
||||
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(selected(), 1) },
|
||||
]}
|
||||
/>
|
||||
</box>
|
||||
<Show when={split()}>
|
||||
<box
|
||||
position="relative"
|
||||
top={-1}
|
||||
width="46%"
|
||||
height={height() + 2}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{selected().title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{selected().detail ?? selected().description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
{
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect integration" : "View all integrations",
|
||||
selection: "none",
|
||||
onTrigger() {
|
||||
dialog.replace(() => (
|
||||
<DialogIntegration
|
||||
@@ -155,7 +154,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
focusCurrent={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -345,7 +345,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
selection: "none",
|
||||
onTrigger: () => void create(),
|
||||
},
|
||||
{
|
||||
@@ -361,7 +360,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.refresh",
|
||||
title: "refresh",
|
||||
selection: "none",
|
||||
onTrigger: () => void refetch(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,6 +43,7 @@ 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"
|
||||
@@ -53,7 +54,6 @@ 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,10 +177,11 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const kv = useKV()
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true)
|
||||
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
|
||||
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
|
||||
const editorContext = createMemo(() => {
|
||||
const selection = fileContextEnabled() ? editor.selection() : undefined
|
||||
@@ -1004,10 +1005,8 @@ 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()
|
||||
@@ -1100,53 +1099,31 @@ 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: submittedPrompt.files,
|
||||
agents: submittedPrompt.agents,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.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()
|
||||
}
|
||||
if (!cleared) {
|
||||
history.append({
|
||||
...submittedPrompt,
|
||||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
props.onSubmit?.()
|
||||
}
|
||||
history.append({
|
||||
...store.prompt,
|
||||
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) {
|
||||
@@ -1158,7 +1135,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
if (!cleared) input.clear()
|
||||
input.clear()
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
return true
|
||||
}
|
||||
@@ -1214,7 +1191,7 @@ export function Prompt(props: PromptProps) {
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
config.prompt?.paste !== "full"
|
||||
kv.get("paste_summary_enabled", true)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
@@ -1518,7 +1495,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={config.animations ?? true} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import { useKV } from "../context/kv"
|
||||
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 config = useConfig().data
|
||||
const kv = useKV()
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={config.animations ?? true} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<Show when={kv.get("animations_enabled", 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}>
|
||||
|
||||
@@ -118,9 +118,6 @@ 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(
|
||||
@@ -182,6 +179,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
const ConfigContext = createContext<{
|
||||
data: Resolved
|
||||
update: Interface["update"]
|
||||
writable: boolean
|
||||
}>()
|
||||
|
||||
export function ConfigProvider(props: {
|
||||
@@ -199,7 +197,7 @@ export function ConfigProvider(props: {
|
||||
return info
|
||||
}
|
||||
return (
|
||||
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
|
||||
<ConfigContext.Provider value={{ data: config, update, writable: !!host }}>{props.children}</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -384,7 +384,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
event.data.inputID,
|
||||
])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const item: SessionMessageInfo =
|
||||
message.append(
|
||||
draft,
|
||||
index,
|
||||
event.data.input.type === "user"
|
||||
? {
|
||||
id: event.data.inputID,
|
||||
@@ -397,10 +399,8 @@ 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,28 +928,6 @@ 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])))
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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 } = {}) {},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSubtleSyntax,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
@@ -21,6 +22,7 @@ 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"
|
||||
@@ -62,6 +64,7 @@ export {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSubtleSyntax,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
@@ -73,6 +76,7 @@ export {
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
type SyntaxStyleOverrides,
|
||||
} from "../theme"
|
||||
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
@@ -99,8 +103,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
|
||||
const renderer = useRenderer()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
@@ -109,11 +113,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(config.theme?.mode)
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
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 ?? "opencode"
|
||||
const active = config.theme?.name ?? kv.get("theme", "opencode")
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
@@ -127,10 +132,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
const mode = config.theme?.mode
|
||||
if (mode === "dark" || mode === "light") {
|
||||
pin(mode, false)
|
||||
pin(mode)
|
||||
return
|
||||
}
|
||||
if (mode === "system" && store.lock !== undefined) free(false)
|
||||
if (mode === "system" && store.lock !== undefined) free()
|
||||
})
|
||||
|
||||
function syncCustomThemes() {
|
||||
@@ -204,31 +209,23 @@ 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, persist = true) {
|
||||
function pin(mode: "dark" | "light" = store.mode) {
|
||||
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(persist = true) {
|
||||
function free() {
|
||||
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") => {
|
||||
@@ -268,12 +265,20 @@ 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(), {
|
||||
@@ -288,6 +293,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
mode: () => store.mode,
|
||||
locked: () => store.lock !== undefined,
|
||||
lock: () => pin(store.mode),
|
||||
@@ -296,11 +302,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.theme = { ...draft.theme, name: theme }
|
||||
})
|
||||
.catch(() => {})
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
||||
@@ -13,8 +16,52 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ 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: [
|
||||
{
|
||||
@@ -18,13 +16,8 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
|
||||
title: props.hidden ? "Show tips" : "Hide tips",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
hidden: true,
|
||||
run() {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.hints = { ...draft.hints, tips: props.hidden }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
|
||||
props.api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -47,8 +40,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
slots: {
|
||||
home_bottom() {
|
||||
const data = useData()
|
||||
const config = useConfig().data
|
||||
const hidden = createMemo(() => !(config.hints?.tips ?? true))
|
||||
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
||||
const first = createMemo(() => api.state.session.count() === 0)
|
||||
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
|
||||
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
||||
|
||||
@@ -4,20 +4,18 @@ 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(() => !(config.data.hints?.onboarding ?? true))
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const location = createMemo(() => {
|
||||
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
@@ -46,16 +44,7 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
<text fg={theme().text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text
|
||||
fg={theme().textMuted}
|
||||
onMouseDown={() =>
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.hints = { ...draft.hints, onboarding: false }
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
>
|
||||
<text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
@@ -42,6 +41,9 @@ 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"
|
||||
@@ -90,7 +92,6 @@ 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 = () =>
|
||||
@@ -134,9 +135,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
})
|
||||
const files = createMemo(() => diff() ?? [])
|
||||
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
|
||||
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
|
||||
)
|
||||
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
|
||||
const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false)
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
|
||||
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
@@ -145,7 +148,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(config.data.diffs?.view))
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
||||
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
|
||||
const fileTree = createMemo(() => buildFileTree(files()))
|
||||
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
|
||||
@@ -620,33 +623,23 @@ 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)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, tree: next }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.kv.set(KV_SHOW_FILE_TREE, next)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.single_patch",
|
||||
title: "Toggle single patch view",
|
||||
category: "VCS",
|
||||
hidden: true,
|
||||
run() {
|
||||
setSelectedHunk(undefined)
|
||||
if (!singlePatch()) {
|
||||
ensureHighlightedPatchFile()
|
||||
setSinglePatch(true)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, single: true }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.kv.set(KV_SINGLE_PATCH, true)
|
||||
scrollSinglePatchToTop()
|
||||
return
|
||||
}
|
||||
@@ -660,11 +653,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
)
|
||||
if (fileIndex !== undefined) selectPatchFile(fileIndex)
|
||||
setSinglePatch(false)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, single: false }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.kv.set(KV_SINGLE_PATCH, false)
|
||||
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
|
||||
},
|
||||
},
|
||||
@@ -680,17 +669,12 @@ 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)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, view: next }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.kv.set(KV_VIEW, next)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -217,7 +217,6 @@ function View(props: { api: TuiPluginApi }) {
|
||||
{
|
||||
title: "install",
|
||||
command: "dialog.plugins.install",
|
||||
selection: "none",
|
||||
hidden: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
|
||||
@@ -22,6 +22,8 @@ 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,
|
||||
@@ -529,8 +531,8 @@ function WhichKeyPanel(props: {
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const [pinned, setPinned] = createSignal(false)
|
||||
const [mode, setMode] = createSignal(layout("dock"))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(false)
|
||||
const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
|
||||
|
||||
api.keymap.registerLayer({
|
||||
priority: LAYER_PRIORITY,
|
||||
@@ -552,6 +554,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
run() {
|
||||
setMode((value) => {
|
||||
const next = value === "dock" ? "overlay" : "dock"
|
||||
api.kv.set(KV_LAYOUT, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
@@ -563,6 +566,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
category: "System",
|
||||
run() {
|
||||
setPendingPreview((value) => {
|
||||
api.kv.set(KV_PENDING_PREVIEW, !value)
|
||||
return !value
|
||||
})
|
||||
},
|
||||
|
||||
@@ -3,11 +3,12 @@ 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"
|
||||
@@ -25,11 +26,12 @@ 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>
|
||||
project: ReturnType<typeof useProject>
|
||||
sync: ReturnType<typeof useSync>
|
||||
data: ReturnType<typeof useData>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
@@ -95,51 +97,57 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
get config() {
|
||||
return {}
|
||||
return sync.data.config
|
||||
},
|
||||
get provider() {
|
||||
return []
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
return sync.path
|
||||
},
|
||||
get vcs() {
|
||||
return undefined
|
||||
if (!sync.data.vcs) return
|
||||
return {
|
||||
branch: sync.data.vcs.branch,
|
||||
default_branch: sync.data.vcs.default_branch,
|
||||
}
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return data.session.list().length
|
||||
},
|
||||
get(_sessionID) {
|
||||
return undefined
|
||||
get(sessionID) {
|
||||
return sync.session.get(sessionID)
|
||||
},
|
||||
diff(_sessionID) {
|
||||
return []
|
||||
diff(sessionID) {
|
||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||
)
|
||||
},
|
||||
messages(_sessionID) {
|
||||
return []
|
||||
messages(sessionID) {
|
||||
return sync.data.message[sessionID] ?? []
|
||||
},
|
||||
status(sessionID) {
|
||||
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
||||
},
|
||||
permission(_sessionID) {
|
||||
return []
|
||||
permission(sessionID) {
|
||||
return sync.data.permission[sessionID] ?? []
|
||||
},
|
||||
question(_sessionID) {
|
||||
return []
|
||||
question(sessionID) {
|
||||
return sync.data.question[sessionID] ?? []
|
||||
},
|
||||
},
|
||||
part(_messageID) {
|
||||
return []
|
||||
part(messageID) {
|
||||
return sync.data.part[messageID] ?? []
|
||||
},
|
||||
lsp() {
|
||||
return []
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
},
|
||||
mcp() {
|
||||
return (data.location.mcp.server.list() ?? [])
|
||||
@@ -284,14 +292,17 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(_key, fallback) {
|
||||
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
|
||||
return fallback
|
||||
get(key, fallback) {
|
||||
return input.kv.get(key, fallback)
|
||||
},
|
||||
set(key, value) {
|
||||
input.kv.set(key, value)
|
||||
},
|
||||
get ready() {
|
||||
return input.kv.ready
|
||||
},
|
||||
set() {},
|
||||
ready: true,
|
||||
},
|
||||
state: stateApi(input.project, input.data),
|
||||
state: stateApi(input.sync, input.data),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
|
||||
@@ -60,6 +60,7 @@ export type PluginRuntime = ReturnType<typeof createPluginRuntime>
|
||||
export type TuiPluginHost = {
|
||||
start(input: {
|
||||
api: TuiPluginApi
|
||||
config: any
|
||||
runtime: PluginRuntime
|
||||
dispose?: () => void
|
||||
}): Promise<void>
|
||||
|
||||
@@ -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 { useTheme } from "../../context/theme"
|
||||
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
@@ -54,6 +54,7 @@ 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"
|
||||
@@ -65,7 +66,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, type ThinkingMode } from "../../context/thinking"
|
||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
@@ -121,7 +122,6 @@ 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 configState = useConfig()
|
||||
const config = configState.data
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
@@ -194,14 +194,15 @@ export function Session() {
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const showThinking = createMemo(() => 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 [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 wide = createMemo(() => dimensions().width > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -461,11 +462,7 @@ export function Session() {
|
||||
run: () => {
|
||||
batch(() => {
|
||||
const isVisible = sidebarVisible()
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebar(() => (isVisible ? "hide" : "auto"))
|
||||
setSidebarOpen(!isVisible)
|
||||
})
|
||||
dialog.clear()
|
||||
@@ -479,17 +476,12 @@ export function Session() {
|
||||
})(),
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
slash: {
|
||||
name: "thinking",
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, thinking: nextThinkingMode(thinkingMode()) }
|
||||
})
|
||||
.catch(toast.error)
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -497,13 +489,8 @@ export function Session() {
|
||||
title: "Toggle session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, scrollbar: !showScrollbar() }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setShowScrollbar((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -511,13 +498,8 @@ export function Session() {
|
||||
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
||||
value: "session.toggle.exploration_grouping",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, grouping: groupExploration() ? "none" : "auto" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setGroupExploration((prev) => !prev)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -873,7 +855,6 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
markdownMode,
|
||||
groupExploration,
|
||||
diffWrapMode,
|
||||
models,
|
||||
@@ -1306,6 +1287,7 @@ 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)
|
||||
@@ -1318,7 +1300,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<Show when={ctx.config.animations ?? true} fallback={<text fg={color()}>⋯</text>}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯</text>}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1338,7 +1320,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
internalBlockMode="top-level"
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -1716,7 +1698,7 @@ function ReasoningPart(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
message: SessionMessageAssistant
|
||||
}) {
|
||||
const { theme, syntax } = useTheme()
|
||||
const { theme } = 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.
|
||||
@@ -1736,6 +1718,8 @@ 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)
|
||||
@@ -1761,7 +1745,7 @@ function ReasoningPart(props: {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().body}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
conceal={false}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
@@ -1827,7 +1811,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
conceal={false}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -2396,6 +2380,10 @@ 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]" : ""}`
|
||||
}
|
||||
@@ -2404,6 +2392,11 @@ 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[] {
|
||||
|
||||
@@ -177,9 +177,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ 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
|
||||
@@ -556,6 +557,32 @@ 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 [
|
||||
{
|
||||
|
||||
@@ -36,7 +36,14 @@ export interface DialogSelectProps<T> {
|
||||
renderFilter?: boolean
|
||||
locked?: boolean
|
||||
preserveSelection?: boolean
|
||||
actions?: DialogSelectAction<T>[]
|
||||
actions?: {
|
||||
command: string
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
hidden?: boolean
|
||||
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
footerHints?: {
|
||||
title: string
|
||||
label: string
|
||||
@@ -44,27 +51,10 @@ export interface DialogSelectProps<T> {
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
focusCurrent?: boolean
|
||||
hideClose?: boolean
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
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
|
||||
@@ -116,7 +106,6 @@ 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) {
|
||||
@@ -225,7 +214,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
|
||||
const height = createMemo(() => Math.min(rows(), props.maxHeight ?? Math.floor(dimensions().height / 2) - 6))
|
||||
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
@@ -233,11 +222,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection) {
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next >= 0 && next !== store.selected) setStore("selected", next)
|
||||
return
|
||||
}
|
||||
if (!props.preserveSelection) return
|
||||
if (resetSelection && store.filter.length > 0) {
|
||||
const option = flat()[0]
|
||||
if (!option) return
|
||||
@@ -246,7 +231,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return
|
||||
}
|
||||
if (!selection) {
|
||||
if (props.focusCurrent !== false && props.current !== undefined) {
|
||||
if (props.current !== undefined) {
|
||||
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
|
||||
if (index >= 0) {
|
||||
setStore("selected", index)
|
||||
@@ -296,7 +281,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setTimeout(() => {
|
||||
if (filter.length > 0) {
|
||||
moveTo(0, true, false)
|
||||
} else if (current && props.focusCurrent !== false) {
|
||||
} else if (current) {
|
||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||
if (currentIndex >= 0) {
|
||||
moveTo(currentIndex, true)
|
||||
@@ -365,7 +350,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("input", "keyboard")
|
||||
const index = focusedAction()
|
||||
if (index !== undefined) {
|
||||
trigger(actionItems()[index])
|
||||
triggerAction(actionItems()[index])
|
||||
return
|
||||
}
|
||||
const option = selected()
|
||||
@@ -456,7 +441,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
name: item.command,
|
||||
title: item.title,
|
||||
category: "Dialog",
|
||||
run: () => trigger(item),
|
||||
run() {
|
||||
if (props.locked) return
|
||||
if (isActionDisabled(item)) return
|
||||
setStore("input", "keyboard")
|
||||
const option = selected()
|
||||
if (!option) return
|
||||
item.onTrigger(option)
|
||||
},
|
||||
})),
|
||||
],
|
||||
bindings: [
|
||||
@@ -512,13 +504,10 @@ 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 trigger(item: Action | undefined) {
|
||||
if (props.locked || !item || isActionDisabled(item)) return
|
||||
function triggerAction(item: VisibleAction | undefined) {
|
||||
if (props.locked) return
|
||||
if (!item || !isActionItem(item) || isActionDisabled(item)) return
|
||||
setStore("input", "keyboard")
|
||||
if (item.selection === "none") {
|
||||
item.onTrigger()
|
||||
return
|
||||
}
|
||||
const option = selected()
|
||||
if (!option) return
|
||||
item.onTrigger(option)
|
||||
@@ -529,9 +518,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
|
||||
function isActionDisabled(item: Action) {
|
||||
const option = selected()
|
||||
if (item.selection !== "none" && !option) return true
|
||||
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
|
||||
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
|
||||
}
|
||||
|
||||
function isActionFocused(item: VisibleAction) {
|
||||
@@ -558,7 +545,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseUp={() => trigger(item)}
|
||||
onMouseUp={() => triggerAction(item)}
|
||||
>
|
||||
<text
|
||||
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
||||
@@ -580,9 +567,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
{props.title}
|
||||
</text>
|
||||
)}
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
<Show when={!props.hideClose}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={props.renderFilter !== false}>
|
||||
<box paddingTop={1}>
|
||||
@@ -804,7 +793,11 @@ function Option(props: {
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
||||
{typeof props.footer === "string" ? (
|
||||
<text fg={props.active && !props.muted ? fg : theme.textMuted}>{props.footer}</text>
|
||||
) : (
|
||||
props.footer
|
||||
)}
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -90,7 +90,6 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** @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 }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
@@ -653,75 +653,6 @@ 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"
|
||||
@@ -1271,21 +1202,6 @@ 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,10 +26,12 @@ 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 },
|
||||
@@ -37,6 +39,7 @@ 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"),
|
||||
@@ -64,13 +67,15 @@ async function mountPrompt(input: {
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={resolvedConfig}>
|
||||
<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>
|
||||
</KVProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/** @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,6 +4,7 @@ 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"
|
||||
@@ -181,7 +182,9 @@ function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</KVProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -173,9 +174,11 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<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>
|
||||
</KVProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</SDKProvider>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -20,6 +21,7 @@ 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[] = []
|
||||
@@ -76,11 +78,13 @@ async function mountForm(root: string, width = 80) {
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||
<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>
|
||||
</KVProvider>
|
||||
</SDKProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
|
||||
@@ -2,8 +2,10 @@ 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,
|
||||
@@ -180,6 +182,13 @@ 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(
|
||||
@@ -198,4 +207,5 @@ 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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 {
|
||||
@@ -18,6 +19,15 @@ 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(),
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
.source
|
||||
.tanstack
|
||||
.wrangler
|
||||
dist
|
||||
node_modules
|
||||
worker-configuration.d.ts
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,284 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,153 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,450 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,93 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,160 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,262 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,213 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,204 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
title: "References"
|
||||
description: ""
|
||||
---
|
||||
|
||||
References give OpenCode named access to directories outside the current
|
||||
project. Use them for documentation, shared libraries, examples, or source from
|
||||
another repository.
|
||||
|
||||
Configure references by alias in `opencode.json` or `opencode.jsonc`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"references": {
|
||||
"docs": {
|
||||
"path": "../product-docs",
|
||||
"description": "Use for product behavior and terminology"
|
||||
},
|
||||
"effect": {
|
||||
"repository": "Effect-TS/effect",
|
||||
"branch": "main",
|
||||
"description": "Use for Effect implementation details"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local directories
|
||||
|
||||
Use `path` for a local directory:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"design-system": {
|
||||
"path": "../design-system",
|
||||
"description": "Use when working with components or design tokens"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths resolve from the directory containing the config file that
|
||||
defines them. Absolute paths and home-relative paths such as `~/docs` are also
|
||||
supported.
|
||||
|
||||
The string shorthand is useful when no other fields are needed:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"docs": "../docs",
|
||||
"shared": "~/work/shared"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
A shorthand string is treated as a local path only when it starts with `.`,
|
||||
`/`, or `~`. Use `./docs`, not `docs`; a bare `docs` value is interpreted as
|
||||
a Git repository.
|
||||
</Note>
|
||||
|
||||
## Git repositories
|
||||
|
||||
Use `repository` for a remote Git repository. GitHub `owner/repo` shorthand,
|
||||
Git URLs, host/path forms, and SCP-style remotes are supported.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"effect": {
|
||||
"repository": "Effect-TS/effect",
|
||||
"branch": "main"
|
||||
},
|
||||
"internal-sdk": {
|
||||
"repository": "git@gitlab.example.com:platform/sdk.git",
|
||||
"branch": "release/v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `branch`, OpenCode checks out and refreshes the remote's default
|
||||
branch. Branch names may contain letters, numbers, `/`, `_`, `.`, and `-`, but
|
||||
cannot start with `-` or contain `..`. Local `file:` repositories are not
|
||||
supported.
|
||||
|
||||
Git references also support shorthand:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"effect": "Effect-TS/effect",
|
||||
"sdk": "gitlab.com/platform/sdk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cloning and storage
|
||||
|
||||
OpenCode normalizes a remote and stores one checkout under its global data
|
||||
directory at `opencode/repos/<host>/<repository-path>`. On a typical Linux
|
||||
installation, for example, `Effect-TS/effect` is stored at:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/repos/github.com/Effect-TS/effect
|
||||
```
|
||||
|
||||
Missing repositories are cloned. Existing checkouts are fetched and reset to
|
||||
the requested branch, or to the remote default branch when `branch` is omitted.
|
||||
Materialization runs asynchronously when references load or reload, so a new
|
||||
reference can appear before its checkout is ready. Clone and refresh failures
|
||||
are logged and do not stop other references from loading.
|
||||
|
||||
<Warning>
|
||||
The cache has one checkout per normalized remote, not one per branch. Do not
|
||||
configure the same repository at multiple branches; only one branch can be
|
||||
exposed. Avoid editing cached checkouts because a refresh resets them.
|
||||
</Warning>
|
||||
|
||||
## Description and visibility
|
||||
|
||||
`description` tells agents when a reference is relevant. References with a
|
||||
description are included in agent instructions with their alias and resolved
|
||||
path. References without one remain available in `@` autocomplete but are not
|
||||
advertised automatically.
|
||||
|
||||
Set `hidden` to `true` to remove a reference from TUI `@` autocomplete:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"references": {
|
||||
"internal": {
|
||||
"path": "../internal",
|
||||
"description": "Use for internal service behavior",
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`hidden` controls only autocomplete visibility. It does not remove the
|
||||
reference from the reference API or agent instructions when a description is
|
||||
present.
|
||||
|
||||
## Use references
|
||||
|
||||
Type `@` in the TUI and select a reference alias to attach its root directory:
|
||||
|
||||
```text
|
||||
Compare the current implementation with @effect
|
||||
```
|
||||
|
||||
The attachment provides a non-recursive listing of the root's immediate files
|
||||
and directories. V2 currently attaches references by root alias;
|
||||
`@alias/path` is not a reference-specific file browser. Ask the agent to
|
||||
inspect a particular path when more detail is needed.
|
||||
|
||||
References do not grant extra tool permissions. Access outside the active
|
||||
Location remains subject to the agent's normal tool rules and the
|
||||
`external_directory` permission. Editing a reference additionally requires the
|
||||
applicable edit permission.
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Local | Git | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `path` | Required | No | Local directory path |
|
||||
| `repository` | No | Required | Remote Git repository |
|
||||
| `branch` | No | Optional | Branch to fetch and check out |
|
||||
| `description` | Optional | Optional | Guidance describing when agents should use it |
|
||||
| `hidden` | Optional | Optional | Hide it from TUI `@` autocomplete |
|
||||
|
||||
An alias cannot be empty or contain `/`, `\`, whitespace, a backtick, or a
|
||||
comma.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
title: "Session sharing"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Session sharing is not yet available in OpenCode V2. V2 does not currently
|
||||
publish sessions, upload conversation history to a sharing service, or create
|
||||
public links.
|
||||
|
||||
<Warning>
|
||||
The V2 TUI registers `/share`, but it currently only reports that sharing is unavailable. There is no functional
|
||||
share/unshare command or server API endpoint.
|
||||
</Warning>
|
||||
|
||||
## Configuration
|
||||
|
||||
The V2 configuration schema accepts a `share` field with three values:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"share": "manual"
|
||||
}
|
||||
```
|
||||
|
||||
- `"manual"` represents sharing only when explicitly requested.
|
||||
- `"auto"` represents automatically sharing new sessions.
|
||||
- `"disabled"` represents preventing session sharing.
|
||||
|
||||
These values are parsed but are not acted on by the current V2 runtime. In
|
||||
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
|
||||
V2 leaves the sharing policy unspecified.
|
||||
|
||||
## Beta limitations
|
||||
|
||||
V2 currently provides no public session viewer, share URL, history sync,
|
||||
retention controls, or unshare/delete operation. Until those surfaces are
|
||||
implemented in the V2 server and protocol, keep using sessions locally and do
|
||||
not treat the `share` configuration field as a privacy or publishing control.
|
||||
@@ -1,227 +0,0 @@
|
||||
---
|
||||
title: "Skills"
|
||||
description: ""
|
||||
---
|
||||
|
||||
Skills are Markdown instructions that OpenCode can advertise to an agent and
|
||||
load when they are relevant. A skill can include supporting scripts,
|
||||
references, and other files in the same directory.
|
||||
|
||||
## Create a skill
|
||||
|
||||
Create one directory per skill with a `SKILL.md` file:
|
||||
|
||||
```text
|
||||
.opencode/skills/
|
||||
└── git-release/
|
||||
├── SKILL.md
|
||||
├── scripts/
|
||||
│ └── changelog.ts
|
||||
└── references/
|
||||
└── release-policy.md
|
||||
```
|
||||
|
||||
```markdown title=".opencode/skills/git-release/SKILL.md"
|
||||
---
|
||||
name: Git Release
|
||||
description: Prepare release notes, version bumps, and GitHub releases
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `references/release-policy.md`.
|
||||
2. Summarize merged changes since the previous tag.
|
||||
3. Propose the version bump before changing files.
|
||||
4. Run `scripts/changelog.ts` only after the user approves the version.
|
||||
```
|
||||
|
||||
Paths in a skill are relative to the directory containing `SKILL.md`.
|
||||
|
||||
## Discovery
|
||||
|
||||
OpenCode automatically adds the following source directories:
|
||||
|
||||
| Scope | Sources |
|
||||
| --- | --- |
|
||||
| Global | `~/.config/opencode/skills` |
|
||||
| Global compatibility | `~/.claude/skills`, `~/.agents/skills` |
|
||||
| Project | `.opencode/skills` |
|
||||
| Project compatibility | `.claude/skills`, `.agents/skills` |
|
||||
|
||||
For project sources, OpenCode searches from the current directory upward to
|
||||
the project root and includes matching directories at every level.
|
||||
|
||||
Within each source directory, OpenCode discovers:
|
||||
|
||||
- Markdown files at the source root, such as `skills/git-release.md`
|
||||
- `SKILL.md` files at any depth, such as `skills/git-release/SKILL.md`
|
||||
|
||||
The directory form is recommended because it gives the skill a private base
|
||||
directory for supporting files.
|
||||
|
||||
## Configure sources
|
||||
|
||||
Use the `skills` array in any `opencode.json` or `opencode.jsonc` to add local
|
||||
directories or HTTP catalogs:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"skills": [
|
||||
"./team-skills",
|
||||
"~/shared/opencode-skills",
|
||||
"/opt/company-skills",
|
||||
"https://example.com/opencode/skills/"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Relative paths are resolved from the active OpenCode working directory, not
|
||||
from the directory containing the config file. Paths beginning with `~/` use
|
||||
the current user's home directory. Only `http://` and `https://` values are
|
||||
treated as URL sources.
|
||||
|
||||
Every discovered config document contributes its `skills` entries; the arrays
|
||||
are additive rather than replacing one another.
|
||||
|
||||
### HTTP catalogs
|
||||
|
||||
An HTTP source is a base URL containing an `index.json`:
|
||||
|
||||
```json title="index.json"
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "git-release",
|
||||
"version": "3",
|
||||
"files": [
|
||||
"git-release.md",
|
||||
"references/release-policy.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode downloads those files from
|
||||
`<base-url>/git-release/<file>`. File paths must be safe, relative,
|
||||
same-origin paths. Each entry must include either `SKILL.md` or a Markdown file
|
||||
named after the index entry, such as `git-release.md`.
|
||||
|
||||
Use the named Markdown form for HTTP catalogs. Each downloaded skill directory
|
||||
is itself a source root, so `git-release.md` produces the ID `git-release`; a
|
||||
root-level `SKILL.md` produces the literal ID `SKILL` in the current V2
|
||||
implementation. Increment `version` when files change so OpenCode refreshes
|
||||
the cached copy.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
V2 reads these fields:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `name` | Display name; defaults to the path-derived ID |
|
||||
| `description` | Summary shown to the model and command catalog |
|
||||
| `slash` | Set to `false` to hide the skill from the V2 slash-command catalog |
|
||||
| `metadata.opencode/slash` | Boolean or `"true"`/`"false"`; overrides `slash` |
|
||||
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from model-facing discovery |
|
||||
|
||||
Frontmatter, `name`, and `description` are optional at runtime. However, a
|
||||
clear `description` is strongly recommended: skills without one are not
|
||||
advertised to the model. `license`, `compatibility`, and other metadata may be
|
||||
included for portability, but V2 does not interpret them.
|
||||
|
||||
`opencode/autoinvoke: false` only removes the skill from the model's available
|
||||
skills list. The skill remains registered and can still be activated explicitly
|
||||
by its ID.
|
||||
|
||||
## IDs and validation
|
||||
|
||||
The skill ID comes from its path, not its frontmatter:
|
||||
|
||||
| File | ID |
|
||||
| --- | --- |
|
||||
| `<source>/git-release.md` | `git-release` |
|
||||
| `<source>/git-release/SKILL.md` | `git-release` |
|
||||
| `<source>/teams/release/SKILL.md` | `release` |
|
||||
|
||||
IDs are exact and case-sensitive. V2 currently does not enforce the Agent
|
||||
Skills name regex, length limits, a match between `name` and the directory, or
|
||||
a maximum description length. The frontmatter `name` is only a display label.
|
||||
|
||||
For portable, predictable skills, use a unique lowercase kebab-case ID of 1-64
|
||||
characters and keep it aligned with the directory name:
|
||||
|
||||
```text
|
||||
^[a-z0-9]+(-[a-z0-9]+)*$
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
Skills are keyed by ID. If several sources define the same ID, the later source
|
||||
wins. Sources are registered in this order, from lower to higher precedence:
|
||||
|
||||
1. Built-in skills
|
||||
2. `.claude/skills` sources, global first and then from the current directory upward
|
||||
3. `.agents/skills` sources, global first and then from the current directory upward
|
||||
4. `~/.config/opencode/skills`
|
||||
5. Project `.opencode/skills`, from the project root toward the current directory
|
||||
6. Explicit `skills` config entries, in config priority and array order
|
||||
|
||||
Avoid duplicate IDs unless an override is intentional.
|
||||
|
||||
## Runtime loading
|
||||
|
||||
At each model step, OpenCode advertises permitted skills that have a
|
||||
description and do not set `opencode/autoinvoke` to `false`. The advertisement
|
||||
contains only each skill's ID, name, and description; it does not add every
|
||||
skill body to the prompt.
|
||||
|
||||
When the model calls the `skill` tool with an exact ID, OpenCode:
|
||||
|
||||
1. Resolves the current winning definition for that ID
|
||||
2. Checks the `skill` permission for the selected agent
|
||||
3. Adds the Markdown body, without frontmatter, to the conversation
|
||||
4. Provides the skill's base directory and a sample of up to ten supporting file paths
|
||||
|
||||
Supporting file contents are not loaded automatically. The agent can read a
|
||||
referenced file when the skill instructs it to do so. The supporting-file
|
||||
sample is available for directory-based `SKILL.md` skills; flat Markdown skills
|
||||
receive no neighboring file list.
|
||||
|
||||
In the V2 CLI, skills appear as `/id` commands unless `slash` resolves to
|
||||
`false`. Selecting one appends the skill body as a skill message and resumes
|
||||
the session.
|
||||
|
||||
## Permissions
|
||||
|
||||
Permission rules use the `skill` action and the skill ID as the resource. Rules
|
||||
are evaluated in order, with the last matching rule winning:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"permissions": [
|
||||
{ "action": "skill", "resource": "*", "effect": "allow" },
|
||||
{ "action": "skill", "resource": "internal-*", "effect": "deny" },
|
||||
{ "action": "skill", "resource": "experimental-*", "effect": "ask" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`deny` removes matching skills from model-facing discovery and rejects skill
|
||||
tool loading. `ask` advertises the skill but requests approval when the model
|
||||
loads it. The same rules can be placed under an individual
|
||||
`agents.<id>.permissions` array.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If a skill is missing or loads the wrong content:
|
||||
|
||||
1. Confirm the file is either a root-level `*.md` or a nested file named exactly `SKILL.md`.
|
||||
2. Check the path-derived ID rather than the frontmatter `name`.
|
||||
3. Add a `description` if the skill should be advertised to the model.
|
||||
4. Check `opencode/autoinvoke` and the selected agent's `skill` permissions.
|
||||
5. Look for a later source defining the same ID.
|
||||
6. For HTTP catalogs, verify `index.json`, same-origin file paths, and a changed `version`.
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
title: "Undo"
|
||||
description: ""
|
||||
---
|
||||
|
||||
OpenCode snapshots let the default interactive TUI roll back conversation history and related file changes. They are a
|
||||
convenience for revising recent work, not a replacement for Git commits or backups.
|
||||
|
||||
## Configuration
|
||||
|
||||
Snapshots are enabled by default. Set `snapshots` to `false` in your [configuration](/docs/config#snapshots) to stop capturing
|
||||
filesystem state:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"snapshots": false
|
||||
}
|
||||
```
|
||||
|
||||
Filesystem snapshots require a Git repository. With snapshots disabled, unavailable, or missing, undo can still stage a
|
||||
conversation rollback, but it has no captured file state to restore. Disabling snapshots does not delete snapshots that
|
||||
were already stored.
|
||||
|
||||
## What is captured
|
||||
|
||||
For each model step, OpenCode attempts to capture the worktree immediately before the model call and after a cleanly
|
||||
completed step. It records the paths changed between those two points on the assistant message.
|
||||
|
||||
Snapshots use a separate internal Git object database in the OpenCode data directory. They do not create commits, move
|
||||
branches, or intentionally modify your repository's Git index. Capture is limited to the session's active directory, which
|
||||
may be a subdirectory of the repository.
|
||||
|
||||
Within that directory, snapshots include tracked files and untracked files that are not ignored by Git. An individual
|
||||
untracked file larger than 2 MiB is excluded. Ignored files, files outside the active directory, and changes to Git
|
||||
metadata are not captured.
|
||||
|
||||
During undo, OpenCode does not check out an entire tree. It restores only paths attributed to cleanly completed assistant
|
||||
steps after the selected conversation boundary. Each path is restored to its state before the first affected step.
|
||||
|
||||
## Undo
|
||||
|
||||
Wait for the session to become idle, then run:
|
||||
|
||||
```text
|
||||
/undo
|
||||
```
|
||||
|
||||
The TUI finds the latest non-empty user message and stages a revert at that message:
|
||||
|
||||
- The selected user message and every later message are hidden, but not deleted yet.
|
||||
- The selected message's text, attachments, and agent mentions are placed in the composer for revision.
|
||||
- Captured files changed by the affected assistant steps are restored to their earlier contents. Files created by those
|
||||
steps are removed when they did not exist in the earlier snapshot.
|
||||
- A summary shows the staged message count and restored paths.
|
||||
|
||||
Running `/undo` again moves the staged boundary to an earlier user message. OpenCode keeps the filesystem state from
|
||||
immediately before the first undo as the redo baseline, so repeated undos form one wider staged revert rather than a redo
|
||||
stack.
|
||||
|
||||
<Warning>
|
||||
Sending a new prompt while an undo is staged commits the revert. The hidden message range is removed from the active
|
||||
session history, the currently reverted files are kept, and redo is no longer available.
|
||||
</Warning>
|
||||
|
||||
## Redo
|
||||
|
||||
While a revert is staged, run:
|
||||
|
||||
```text
|
||||
/redo
|
||||
```
|
||||
|
||||
Redo clears the staged boundary, makes the hidden messages visible again, and restores affected files to their exact state
|
||||
immediately before the first undo. It does not rerun the model. After multiple undos, one redo restores the whole staged
|
||||
range; there is no step-by-step redo stack.
|
||||
|
||||
## Revert a message
|
||||
|
||||
The TUI's **Message Actions** menu also provides **Revert**. It stages the selected message as the conversation boundary
|
||||
and uses the same file restoration and redo behavior, but it does not copy that message into the composer.
|
||||
|
||||
For a conversation-and-files rollback, select a user message. If an assistant message is selected, that message is hidden,
|
||||
but only file changes attributed to later assistant steps are restored; the selected assistant message's own file changes
|
||||
are not included.
|
||||
|
||||
## Limitations and safety
|
||||
|
||||
- Capture is best effort. A failed capture is logged and the model step continues, so conversation rollback may have no
|
||||
matching file rollback.
|
||||
- Interrupted or failed steps do not receive a completed end snapshot. File changes made before the failure may remain.
|
||||
- Shell commands can change databases, services, processes, network resources, Git state, ignored build output, or files
|
||||
outside the active directory. Undo and redo do not reverse those side effects.
|
||||
- Undo overwrites the current contents of affected paths with older contents. Redo likewise overwrites those paths with
|
||||
the pre-undo state, including edits made after running undo.
|
||||
- Other processes can edit the worktree between capture and restore. The server rejects revert operations while the
|
||||
session is actively running, but it cannot protect against external editors or commands.
|
||||
- Snapshot objects can contain complete contents of tracked and non-ignored untracked files. They are stored locally in
|
||||
the OpenCode data directory; do not treat snapshots as secret-free metadata.
|
||||
- Undo is not secure erasure. Committing a revert removes messages from the active projection, not from durable session
|
||||
history or existing snapshot storage.
|
||||
|
||||
Review the staged file summary and your Git diff before continuing. Commit or back up important work independently before
|
||||
using undo on a dirty worktree.
|
||||
|
||||
<Note>
|
||||
`/undo` and `/redo` are interactive TUI commands. The non-interactive `run` command does not provide them.
|
||||
</Note>
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
title: "Troubleshooting"
|
||||
description: "Diagnose OpenCode startup, server, and session issues."
|
||||
---
|
||||
|
||||
<Tip>
|
||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
|
||||
steps below, inspect its service and logs, and help identify the issue.
|
||||
</Tip>
|
||||
|
||||
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
|
||||
other application state. Start by determining whether an issue is in the client, the shared server, or a specific project.
|
||||
|
||||
## Check the background service
|
||||
|
||||
Show the current server status:
|
||||
|
||||
```bash
|
||||
opencode2 service status
|
||||
```
|
||||
|
||||
Verify that its API is healthy:
|
||||
|
||||
```bash
|
||||
opencode2 api get /api/health
|
||||
```
|
||||
|
||||
If the service is stuck or unhealthy, restart it:
|
||||
|
||||
```bash
|
||||
opencode2 service restart
|
||||
```
|
||||
|
||||
From inside the TUI, run `/reload` to restart the managed service and reconnect:
|
||||
|
||||
```text
|
||||
/reload
|
||||
```
|
||||
|
||||
You can also stop and start it explicitly:
|
||||
|
||||
```bash
|
||||
opencode2 service stop
|
||||
opencode2 service start
|
||||
```
|
||||
|
||||
<Note>
|
||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
|
||||
when diagnosing its lifecycle.
|
||||
</Note>
|
||||
|
||||
## Run an isolated session
|
||||
|
||||
Use standalone mode to run the TUI with a private server that exits with it:
|
||||
|
||||
```bash
|
||||
opencode2 --standalone
|
||||
```
|
||||
|
||||
If an issue disappears in standalone mode, it is likely related to the shared background service rather than the TUI or
|
||||
project itself.
|
||||
|
||||
## Inspect the API
|
||||
|
||||
The `api` command uses the same discovery and authentication flow as the TUI. It accepts either an HTTP method and path or
|
||||
an OpenAPI operation ID.
|
||||
|
||||
See the [API reference](/docs/api) for all endpoints and operation IDs.
|
||||
|
||||
Pass a JSON request body with `--data` or `-d`, and add headers with `--header` or `-H`.
|
||||
|
||||
<Warning>
|
||||
Running `opencode2 api` may start the background service when no compatible healthy service is available.
|
||||
</Warning>
|
||||
|
||||
## Read logs
|
||||
|
||||
Installed builds write logs to:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/log/opencode.log
|
||||
```
|
||||
|
||||
Follow the log while reproducing the problem:
|
||||
|
||||
```bash
|
||||
tail -f ~/.local/share/opencode/log/opencode.log
|
||||
```
|
||||
|
||||
Each line includes a process `run` ID and a `role` field. Use `role=cli` for TUI and command startup, and `role=server` for
|
||||
session, provider, plugin, permission, and tool activity.
|
||||
|
||||
```bash
|
||||
grep 'role=cli' ~/.local/share/opencode/log/opencode.log
|
||||
grep 'role=server' ~/.local/share/opencode/log/opencode.log
|
||||
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode.log
|
||||
```
|
||||
|
||||
Increase verbosity for one reproduction:
|
||||
|
||||
```bash
|
||||
OPENCODE_LOG_LEVEL=DEBUG opencode2
|
||||
```
|
||||
|
||||
## Service files
|
||||
|
||||
The shared server registers itself at:
|
||||
|
||||
```text
|
||||
~/.local/state/opencode/service.json
|
||||
```
|
||||
|
||||
Its private service configuration is stored separately at:
|
||||
|
||||
```text
|
||||
~/.config/opencode/service.json
|
||||
```
|
||||
|
||||
The database normally lives at:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/opencode-next.db
|
||||
```
|
||||
|
||||
`OPENCODE_DB` can override the database location.
|
||||
|
||||
<Warning>
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
|
||||
and make a backup before inspecting persistent data with external tools.
|
||||
</Warning>
|
||||
|
||||
## Explicit servers
|
||||
|
||||
When connecting with `--server`, set `OPENCODE_PASSWORD` if the server requires authentication:
|
||||
|
||||
```bash
|
||||
OPENCODE_PASSWORD=secret opencode2 --server http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
The CLI checks the server before opening the TUI and reports whether it is unreachable, requires a password, or rejected
|
||||
the supplied password.
|
||||
|
||||
## Report an issue
|
||||
|
||||
Include the following when reporting a reproducible problem:
|
||||
|
||||
- Output from `opencode2 --version`
|
||||
- Output from `opencode2 service status`
|
||||
- The smallest sequence of steps that reproduces the issue
|
||||
- Whether the issue also occurs with `opencode2 --standalone`
|
||||
- Relevant log lines, including their `run` and `role` fields
|
||||
|
||||
Remove API keys, authorization headers, prompts, file contents, and other sensitive data before sharing logs.
|
||||
|
||||
## Local development
|
||||
|
||||
When working from the OpenCode repository, run V2 commands from the repository root:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
The local development channel keeps its logs, SQLite database, and service registration separate from installed builds:
|
||||
|
||||
```text
|
||||
~/.local/share/opencode/log/opencode-local.log
|
||||
~/.local/share/opencode/opencode-local.db
|
||||
~/.local/state/opencode/service-local.json
|
||||
```
|
||||
|
||||
Use the same diagnostics through the package development command:
|
||||
|
||||
```bash
|
||||
bun dev service status
|
||||
bun dev service restart
|
||||
bun dev api get /api/health
|
||||
```
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: "API Reference"
|
||||
description: "OpenCode HTTP API."
|
||||
---
|
||||
|
||||
The endpoint reference is generated from the current OpenCode V2 [OpenAPI specification](/openapi.json).
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"title": "API",
|
||||
"description": "Use the OpenCode HTTP API.",
|
||||
"root": true,
|
||||
"pages": ["index"]
|
||||
}
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
---
|
||||
title: "Client"
|
||||
description: "Connect an application to the OpenCode HTTP API."
|
||||
---
|
||||
|
||||
`@opencode-ai/client` is the generated TypeScript client for the OpenCode HTTP
|
||||
API. Use it when your application connects to an OpenCode server over the
|
||||
network. Its types and methods are generated from the same contract as the
|
||||
[API reference](/docs/api).
|
||||
|
||||
<Warning>
|
||||
The V2 API and client are beta. Method names, inputs, and outputs may change
|
||||
before the stable release.
|
||||
</Warning>
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next
|
||||
```
|
||||
|
||||
## Create a client
|
||||
|
||||
Create a client with the server URL, then call methods grouped by API resource:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:4096",
|
||||
})
|
||||
|
||||
const session = await client.session.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Review the current changes",
|
||||
})
|
||||
```
|
||||
|
||||
## Headers and requests
|
||||
|
||||
Pass default authentication or application headers to `OpenCode.make` with
|
||||
`headers`. You can also supply a custom `fetch` implementation. Each operation
|
||||
accepts request options as its final argument for an `AbortSignal` or
|
||||
per-request headers.
|
||||
|
||||
```ts
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "https://opencode.example.com",
|
||||
headers: {
|
||||
authorization: `Bearer ${process.env.OPENCODE_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
await client.session.list(undefined, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
```
|
||||
|
||||
## Stream events
|
||||
|
||||
Streaming endpoints return async iterables:
|
||||
|
||||
```ts
|
||||
for await (const event of client.event.subscribe()) {
|
||||
console.log(event.type)
|
||||
}
|
||||
```
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect client through the
|
||||
`@opencode-ai/client/effect` entrypoint. It returns typed Effects and Streams
|
||||
and decodes responses into OpenCode schema values.
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/client@next effect
|
||||
```
|
||||
|
||||
### Create a client
|
||||
|
||||
```ts
|
||||
import { AbsolutePath, Location, OpenCode } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
|
||||
const session = yield* client.session.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* client.session.get({ sessionID: session.id })
|
||||
})
|
||||
|
||||
const session = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(FetchHttpClient.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
Streaming operations, including `client.event.subscribe()` and
|
||||
`client.session.log(...)`, return Effect `Stream` values.
|
||||
|
||||
### Service
|
||||
|
||||
`Service` discovers and manages the local OpenCode background service from a
|
||||
Node application:
|
||||
|
||||
- `Service.discover()` returns a healthy registered endpoint without starting
|
||||
a process.
|
||||
- `Service.start()` reuses a compatible service or starts one when needed.
|
||||
- `Service.stop()` stops the registered service.
|
||||
- `Service.headers(endpoint)` creates the authentication headers for a client.
|
||||
|
||||
```sh
|
||||
bun add @effect/platform-node
|
||||
```
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { OpenCode, Service } from "@opencode-ai/client/effect"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const endpoint = yield* Service.start()
|
||||
const client = yield* OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return yield* client.health.get()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
```
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
---
|
||||
title: "Build"
|
||||
description: "Build on the engine used by millions daily."
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="Extend OpenCode" href="/docs/build/plugins">
|
||||
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode
|
||||
intact.
|
||||
</Card>
|
||||
<Card title="Run it as a server" href="/docs/build/client">
|
||||
Connect to OpenCode with the same client used by the TUI and desktop app, then build any interface, workflow, or agent
|
||||
experience around it.
|
||||
</Card>
|
||||
<Card title="Embed it" href="/docs/build/sdk">
|
||||
Embed OpenCode directly into your application and build a completely custom agent, interface, or developer product
|
||||
around it.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Warning>
|
||||
The plugin API, client, and SDK are still being finalized during beta and may change before OpenCode 2.0 is stable.
|
||||
</Warning>
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"title": "Build",
|
||||
"description": "Extend and embed OpenCode.",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": ["index", "plugins", "client", "sdk"]
|
||||
}
|
||||
-430
@@ -1,430 +0,0 @@
|
||||
---
|
||||
title: "Plugins"
|
||||
description: "Extend OpenCode with plugins."
|
||||
---
|
||||
|
||||
Plugins extend OpenCode in-process. They can transform agents, models, commands,
|
||||
integrations, references, skills, and tools; intercept model requests and tool
|
||||
execution; and call a subset of the V2 client.
|
||||
|
||||
<Warning>
|
||||
The V2 plugin API is beta. Entrypoints, hooks, draft shapes, and configuration may change before the stable release.
|
||||
Use the `/v2` exports described on this page.
|
||||
</Warning>
|
||||
|
||||
## Load plugins
|
||||
|
||||
Plugins can be loaded from npm packages, explicit local paths, or config
|
||||
directories. Each module must have one default export containing a unique
|
||||
plugin `id` and a `setup` function.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add ordered entries to the `plugins` field in `opencode.json(c)`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugins": [
|
||||
"opencode-acme-plugin@1.2.0",
|
||||
"@acme/opencode-plugin",
|
||||
"./plugins/local.ts",
|
||||
{
|
||||
"package": "./plugins/reviewer.ts",
|
||||
"options": {
|
||||
"agent": "reviewer",
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
A string is either a package specifier or a local path. Local paths must start
|
||||
with `./` or `../` and resolve relative to the configuration file containing
|
||||
the entry. Absolute paths and `file://` URLs are also supported. Both scoped
|
||||
packages and versioned package specifiers are supported.
|
||||
|
||||
Use the object form to pass JSON configuration to the plugin. OpenCode passes
|
||||
`options` unchanged as `ctx.options`; omitted options become an empty object.
|
||||
The plugin owns validation and defaults for its options.
|
||||
|
||||
See [Config](/docs/config#locations) for configuration locations and precedence.
|
||||
Entries from all applicable files are processed from lowest to highest
|
||||
precedence rather than replacing the entire array.
|
||||
|
||||
### Local discovery
|
||||
|
||||
OpenCode automatically scans this directory in every discovered OpenCode config
|
||||
directory:
|
||||
|
||||
```text
|
||||
.opencode/plugins/
|
||||
```
|
||||
|
||||
The equivalent global directory is `~/.config/opencode/plugins/`. Direct `.ts`
|
||||
and `.js` children are loaded. An immediate child directory is also loaded as a
|
||||
package when OpenCode can resolve a string `exports`, `module`, or `main`
|
||||
entrypoint, or an `index.ts` or `index.js` file.
|
||||
|
||||
A `plugins/` directory beside a project-root `opencode.json` is not discovered
|
||||
automatically. Put it under `.opencode/`, or add its file explicitly with a
|
||||
relative config entry.
|
||||
|
||||
### Enable and disable
|
||||
|
||||
A string beginning with `-` disables plugins by their exported `id`. `*`
|
||||
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
|
||||
applied in order:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"plugins": ["./plugins/reviewer.ts", "-acme.reviewer", "-opencode.provider.*", "opencode.provider.openai"],
|
||||
}
|
||||
```
|
||||
|
||||
Package specifiers and local paths locate plugin modules; they are not disable
|
||||
selectors. Use the `id` from the plugin's default export to disable it. A later
|
||||
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
|
||||
after local auto-discovery, so they can disable discovered plugins by ID.
|
||||
|
||||
User plugins are activated in configured order between OpenCode's internal
|
||||
plugin phases. Hooks run sequentially in registration order, and later hooks
|
||||
observe earlier mutations. Do not depend on the internal phase ordering while
|
||||
the API is beta.
|
||||
|
||||
### Installation and dependencies
|
||||
|
||||
OpenCode installs bare package entries and their production dependencies into
|
||||
an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
|
||||
```sh
|
||||
cd .opencode
|
||||
bun add @opencode-ai/plugin@next
|
||||
```
|
||||
|
||||
Match the plugin package version to the OpenCode release you target.
|
||||
|
||||
Configuration and discovered plugin files under watched config directories are
|
||||
reloaded when they change. Reloading replaces the active plugin generation and
|
||||
releases its scoped registrations. Restart OpenCode after changing an npm
|
||||
package version or a local dependency when no watched file changed.
|
||||
|
||||
## Create a plugin
|
||||
|
||||
Export the result of `Plugin.define` as the module default:
|
||||
|
||||
```ts title=".opencode/plugins/reviewer.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer",
|
||||
setup: async (ctx) => {
|
||||
const description =
|
||||
typeof ctx.options.description === "string" ? ctx.options.description : "Reviews code for regressions"
|
||||
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = description
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`setup` runs each time the plugin is activated. Register long-lived behavior
|
||||
during setup; do not wait there on an infinite event stream. It may return a
|
||||
synchronous or asynchronous cleanup function. OpenCode awaits that cleanup
|
||||
when the plugin is disabled, reloaded, or shut down:
|
||||
|
||||
```ts
|
||||
setup: async (ctx) => {
|
||||
const controller = new AbortController()
|
||||
const task = synchronize(ctx, controller.signal)
|
||||
|
||||
return async () => {
|
||||
controller.abort()
|
||||
await task
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hook registrations are released automatically with the same plugin scope. Use
|
||||
the returned cleanup for resources the plugin owns, such as timers, watchers,
|
||||
connections, and background tasks.
|
||||
|
||||
### Context
|
||||
|
||||
The plugin context is essentially an [OpenCode server client](/docs/build/client).
|
||||
Its read and action methods use the same inputs and responses as the client. It
|
||||
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
|
||||
and plugin options.
|
||||
|
||||
| Capability | Available operations |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `ctx.agent` | `list`, `transform`, `reload` |
|
||||
| `ctx.catalog.provider` | `list`, `get` |
|
||||
| `ctx.catalog.model` | `list`, `default` |
|
||||
| `ctx.catalog` | `transform`, `reload` |
|
||||
| `ctx.command` | `list`, `transform`, `reload` |
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `interrupt`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
| `ctx.event` | `subscribe` to the current public server event stream |
|
||||
| `ctx.options` | Readonly options from the matching config object |
|
||||
|
||||
### Transform hooks
|
||||
|
||||
Transform hooks let a plugin modify how OpenCode is configured. Use them to add
|
||||
or remove definitions, override settings, choose defaults, and provide tools or
|
||||
other sources.
|
||||
|
||||
| Transform | Draft operations |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `agent.transform` | `list`, `get`, `default`, `update`, `remove` |
|
||||
| `catalog.transform` | Provider `list`, `get`, `update`, `remove`; model `get`, `update`, `remove`; default model `get`, `set` |
|
||||
| `command.transform` | `list`, `get`, `update`, `remove` |
|
||||
| `integration.transform` | Integration `list`, `get`, `update`, `remove`; method `list`, `update`, `remove` |
|
||||
| `reference.transform` | `add`, `remove`, `list` |
|
||||
| `skill.transform` | `source`, `list` |
|
||||
| `tool.transform` | `add` |
|
||||
|
||||
Here's an example that keeps models synced from a remote source:
|
||||
|
||||
```js title=".opencode/plugins/remote-models.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.remote-models",
|
||||
setup: async (ctx) => {
|
||||
let models = []
|
||||
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
for (const model of models) {
|
||||
catalog.model.update(model.providerID, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
const response = await fetch("https://example.com/opencode/models.json", {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!response.ok) return
|
||||
models = await response.json()
|
||||
await ctx.catalog.reload()
|
||||
}
|
||||
|
||||
await refresh()
|
||||
const timer = setInterval(() => void refresh().catch(console.error), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`ctx.catalog.reload()` replays every catalog transform to derive the new
|
||||
catalog. Each plugin's logic remains composed with the others, so a later
|
||||
plugin can still modify models added by an earlier one. The catalog updates
|
||||
without restarting OpenCode.
|
||||
|
||||
### Runtime hooks
|
||||
|
||||
Runtime hooks intercept live operations. Their event objects expose specific
|
||||
mutable fields:
|
||||
|
||||
| Hook | Mutable fields |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles |
|
||||
|
||||
For example, remove a tool from selected model requests and normalize another
|
||||
tool's input:
|
||||
|
||||
```ts title=".opencode/plugins/guards.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
await ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.tool !== "lookup" || typeof event.input !== "object" || event.input === null) return
|
||||
event.input = { ...event.input, source: "plugin" }
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
A hook failure fails the operation it intercepts. Keep runtime hooks fast and
|
||||
handle expected errors inside the callback.
|
||||
|
||||
## Examples
|
||||
|
||||
### Add a tool
|
||||
|
||||
Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
|
||||
use an async executor:
|
||||
|
||||
```js title=".opencode/plugins/greeting.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.greeting",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "greeting",
|
||||
description: "Create a greeting",
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
const text = `Hello, ${name}!`
|
||||
return {
|
||||
structured: { greeting: text },
|
||||
content: [{ type: "text", text }],
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
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 `options` on the declaration to
|
||||
configure registration with `{ group, deferred }`:
|
||||
|
||||
- `group` prefixes and groups the exposed tool name.
|
||||
- `deferred: true` makes the tool available through the deferred `execute`
|
||||
tool instead of exposing it directly.
|
||||
|
||||
The executor receives a second context argument containing `sessionID`,
|
||||
`agent`, `assistantMessageID`, and `toolCallID`.
|
||||
|
||||
### Add a command
|
||||
|
||||
```js title=".opencode/plugins/review-command.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.review-command",
|
||||
setup: async (ctx) => {
|
||||
await ctx.command.transform((commands) => {
|
||||
commands.update("review", (command) => {
|
||||
command.description = "Review the current changes"
|
||||
command.template = "Review the current changes for correctness and missing tests."
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Set the default model
|
||||
|
||||
```js title=".opencode/plugins/default-model.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.default-model",
|
||||
setup: async (ctx) => {
|
||||
await ctx.catalog.transform((catalog) => {
|
||||
catalog.model.default.set("anthropic", "claude-sonnet-4-5")
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Publish a package
|
||||
|
||||
A package plugin uses the same default export as a local plugin. A minimal
|
||||
manifest is:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "next"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Verify loading
|
||||
|
||||
List active plugin IDs through the V2 API:
|
||||
|
||||
```sh
|
||||
opencode2 api get /api/plugin
|
||||
```
|
||||
|
||||
If a plugin is absent, check the server log described in
|
||||
[Troubleshooting](/docs/troubleshooting#read-logs). Invalid modules and setup failures are
|
||||
logged; one failing package does not prevent unrelated valid packages from
|
||||
being resolved.
|
||||
|
||||
## Effect
|
||||
|
||||
OpenCode provides a first-class Effect API for plugins through the
|
||||
`@opencode-ai/plugin/v2/effect` entrypoint. Install `effect` alongside the
|
||||
plugin package and export an `effect` function instead of `setup`:
|
||||
|
||||
```sh
|
||||
bun add @opencode-ai/plugin@next effect
|
||||
```
|
||||
|
||||
```ts title=".opencode/plugins/reviewer-effect.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "acme.reviewer-effect",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.agent.transform((agents) => {
|
||||
agents.update("reviewer", (agent) => {
|
||||
agent.description = "Reviews code for regressions"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
Context operations return Effects. The plugin effect is scoped, so finalizers,
|
||||
fibers, and registrations are released when the plugin reloads or unloads.
|
||||
OpenCode does not expose its private Core services to the plugin; use the
|
||||
capabilities on `ctx`.
|
||||
|
||||
Typed tools can use `Schema` from `effect` and the contracts exported from
|
||||
`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may
|
||||
fail with the typed tool failure channel.
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed an OpenCode host in an Effect application."
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
|
||||
host OpenCode in-process. Unlike the [network client](/docs/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
|
||||
published for external installation yet, and its package name and API may
|
||||
change before release.
|
||||
</Warning>
|
||||
|
||||
## Create a host
|
||||
|
||||
`OpenCode.create()` creates a scoped host. Closing its Effect Scope releases
|
||||
the router, location services, fibers, and scoped plugin registrations.
|
||||
|
||||
```ts
|
||||
import {
|
||||
AbsolutePath,
|
||||
Location,
|
||||
OpenCode,
|
||||
} from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const session = yield* opencode.sessions.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* opencode.sessions.get({ sessionID: session.id })
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Effect.runPromise(program)
|
||||
```
|
||||
|
||||
The embedded host uses the same routes, middleware, codecs, errors, and schema
|
||||
values as `@opencode-ai/client/effect`. It exposes the full generated client and
|
||||
adds the convenience aliases `sessions` and `events` for the session and event
|
||||
groups.
|
||||
|
||||
## Use as a service
|
||||
|
||||
Use `OpenCode.layer` when the host should be provided through Effect dependency
|
||||
injection:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk-next"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.sessions.active()
|
||||
})
|
||||
|
||||
const active = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(OpenCode.layer)),
|
||||
)
|
||||
```
|
||||
|
||||
## Register plugins
|
||||
|
||||
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
|
||||
use the same discovery and location-scoped activation path as configured
|
||||
plugins. The SDK also exports `Tool` for plugin-defined tools. See the
|
||||
[Plugins guide](/docs/build/plugins) for the plugin shape and available hooks.
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"title": "Documentation",
|
||||
"pages": ["(docs)", "build", "api"]
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/www",
|
||||
"version": "1.17.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"pretypecheck": "fumadocs-mdx",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.44.0",
|
||||
"@tailwindcss/vite": "4.3.2",
|
||||
"@tanstack/react-router": "1.170.17",
|
||||
"@tanstack/react-start": "1.168.27",
|
||||
"@tanstack/router-plugin": "1.168.19",
|
||||
"fumadocs-core": "16.11.1",
|
||||
"fumadocs-mdx": "15.1.0",
|
||||
"fumadocs-ui": "16.11.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"tailwindcss": "4.3.2",
|
||||
"vite": "8.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mdx": "2.0.14",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"typescript": "catalog:",
|
||||
"wrangler": "4.110.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
|
||||
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/><path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user