mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0a4fecd5b | |||
| 9b8282ad3d | |||
| 2a08cd3b96 | |||
| 5cf24bf185 | |||
| c15e3487b2 | |||
| 6963f2f6da | |||
| 547e0148c7 | |||
| 358d4746a9 | |||
| ce2e301e24 | |||
| fbe7f26e71 | |||
| ecb5754f4c | |||
| 36f8cb7054 | |||
| 4c45a5cf23 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 9db5073eab | |||
| 775fce0177 | |||
| 4e74f77e44 | |||
| b980f74ed9 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 |
-245
@@ -1,245 +0,0 @@
|
||||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**Model Context**:
|
||||
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||
_Avoid_: System Context
|
||||
|
||||
**Instructions**:
|
||||
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Instruction Source**:
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**InstructionEntry**:
|
||||
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Instruction Update**:
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||
@@ -108,7 +108,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1006,7 +1005,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/FreeModelBadge"
|
||||
const projectID = "proj_free_model_badge"
|
||||
const sessionID = "ses_free_model_badge"
|
||||
|
||||
test("shows the Free badge instead of repeating Free in an OpenCode model name", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "free-model-badge",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"glm-5-free": {
|
||||
id: "glm-5-free",
|
||||
name: "GLM 5 Free",
|
||||
cost: { input: 0 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "glm-5-free" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "free-model-badge",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Free model badge",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="session-composer"]')
|
||||
await expectAppVisible(composer)
|
||||
|
||||
const trigger = composer.locator('[data-action="prompt-model"]')
|
||||
await expect(trigger).toContainText("GLM 5")
|
||||
await expect(trigger).toContainText("Free")
|
||||
await expect(trigger).not.toContainText("GLM 5 Free")
|
||||
|
||||
await trigger.click()
|
||||
const option = page.getByRole("dialog").getByRole("button", { name: /GLM 5/ })
|
||||
await expect(option).toBeVisible()
|
||||
await expect(option).toContainText("GLM 5")
|
||||
await expect(option).toContainText("Free")
|
||||
await expect(option).not.toContainText("GLM 5 Free")
|
||||
|
||||
await page.screenshot({ path: "e2e/test-results/free-model-badge.png" })
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -27,6 +26,9 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
const c = model.current()
|
||||
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
@@ -81,15 +83,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{modelDisplayName(item)}</span>
|
||||
<Show when={isFreeModel(item)}>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -58,7 +57,13 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
@@ -72,7 +77,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2.5">
|
||||
<span>{modelDisplayName(i)}</span>
|
||||
<span>{i.name}</span>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
<Show when={i.latest}>
|
||||
<Tag>{language.t("model.tag.latest")}</Tag>
|
||||
|
||||
@@ -31,7 +31,9 @@ import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
@@ -92,7 +94,7 @@ const ModelList: Component<{
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
@@ -106,8 +108,8 @@ const ModelList: Component<{
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{modelDisplayName(i)}</span>
|
||||
<Show when={isFreeModel(i)}>
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={isFree(i.provider.id, i.cost)}>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
@@ -451,7 +453,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
@@ -465,8 +474,8 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{modelDisplayName(item)}</span>
|
||||
<Show when={isFreeModel(item)}>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
const model = (name: string, provider = "opencode", input = 0) => ({
|
||||
name,
|
||||
provider: { id: provider },
|
||||
cost: { input },
|
||||
})
|
||||
|
||||
describe("model display", () => {
|
||||
test("recognizes free models provided by OpenCode", () => {
|
||||
expect(isFreeModel(model("GLM 5 Free"))).toBe(true)
|
||||
expect(isFreeModel(model("GLM 5 Free", "openrouter"))).toBe(false)
|
||||
expect(isFreeModel(model("GLM 5 Free", "opencode", 1))).toBe(false)
|
||||
})
|
||||
|
||||
test("removes the standalone free label from free model names", () => {
|
||||
expect(modelDisplayName(model("GLM 5 Free"))).toBe("GLM 5")
|
||||
expect(modelDisplayName(model("GLM 5 (Free)"))).toBe("GLM 5")
|
||||
expect(modelDisplayName(model("Free GLM 5"))).toBe("GLM 5")
|
||||
})
|
||||
|
||||
test("preserves names that are not OpenCode free models", () => {
|
||||
expect(modelDisplayName(model("GLM 5 Free", "openrouter"))).toBe("GLM 5 Free")
|
||||
expect(modelDisplayName(model("FreeModel"))).toBe("FreeModel")
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
type ModelDisplay = {
|
||||
name: string
|
||||
provider: { id: string }
|
||||
cost?: { input: number }
|
||||
}
|
||||
|
||||
export const isFreeModel = (model: ModelDisplay) =>
|
||||
model.provider.id === "opencode" && (!model.cost || model.cost.input === 0)
|
||||
|
||||
export const modelDisplayName = (model: ModelDisplay) => {
|
||||
if (!isFreeModel(model)) return model.name
|
||||
return model.name
|
||||
.replace(/\(\s*free\s*\)/gi, " ")
|
||||
.replace(/\bfree\b/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { modelDisplayName } from "./model-display"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
type InputMap = Record<InputKey, boolean>
|
||||
@@ -9,12 +8,8 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
}
|
||||
capabilities?: {
|
||||
reasoning: boolean
|
||||
input: InputMap
|
||||
@@ -65,14 +60,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${modelDisplayName(props.model)}${suffix}`
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${modelDisplayName(props.model)}${suffix}`
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
const inputs = () => {
|
||||
if (props.model.capabilities) {
|
||||
|
||||
@@ -41,15 +41,12 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { isFreeModel, modelDisplayName } from "@/components/model-display"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -1544,30 +1541,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => {
|
||||
const current = props.controls.model.selection.current()
|
||||
return {
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: current?.provider?.id,
|
||||
modelName: current ? modelDisplayName(current) : language.t("dialog.model.select.title"),
|
||||
free: current ? isFreeModel(current) : false,
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}
|
||||
})
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const bindEditorRef = (el: HTMLDivElement) => {
|
||||
@@ -2185,7 +2178,6 @@ type ComposerModelControlState = {
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
free: boolean
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
@@ -2225,7 +2217,6 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
}
|
||||
|
||||
function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Show when={!props.state.loading}>
|
||||
<Show
|
||||
@@ -2264,9 +2255,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Show when={props.state.free}>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
@@ -2342,7 +2330,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
@@ -2355,11 +2342,6 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Show when={props.state.free}>
|
||||
<Show when={props.v2} fallback={<Tag>{language.t("model.tag.free")}</Tag>}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
</Show>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -12,11 +12,11 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
createOpencodeClient,
|
||||
OpenCode,
|
||||
type IntegrationAttemptStatus,
|
||||
type IntegrationOAuthMethod,
|
||||
type OpencodeClient,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
type OpenCodeClient,
|
||||
} from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -20,7 +20,7 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
@@ -32,10 +32,9 @@ export default Runtime.handler(
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data?.data
|
||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
||||
const attempt = started.data
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
@@ -52,13 +51,14 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpencodeClient,
|
||||
client: OpenCodeClient,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
||||
const status = response.data?.data
|
||||
if (!status || status.status === "pending") {
|
||||
const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
|
||||
Effect.map((result) => result.data),
|
||||
)
|
||||
if (status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, attemptID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode, type McpServer } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -12,9 +12,9 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
process.stdout.write("No MCP servers configured" + EOL)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
@@ -15,7 +15,7 @@ export default Runtime.handler(
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.start(options))
|
||||
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
@@ -31,7 +31,7 @@ export default Runtime.handler(
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
||||
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
|
||||
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
|
||||
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
|
||||
// or anonymous server), leaving that case for the caller to interpret.
|
||||
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
|
||||
export const resolveIntegration = (client: OpenCodeClient, name: string, location: { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
|
||||
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
|
||||
const servers = yield* Effect.promise(() => client.mcp.list({ location }))
|
||||
const server = servers.data.find((entry) => entry.name === name)
|
||||
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
||||
const integrationID = server.integrationID
|
||||
if (!integrationID) return undefined
|
||||
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
|
||||
return found.data?.data
|
||||
return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
|
||||
Effect.map((result) => result.data ?? undefined),
|
||||
)
|
||||
})
|
||||
|
||||
+121
-298
@@ -1,7 +1,7 @@
|
||||
// Demo mode for testing direct interactive mode without a real SDK.
|
||||
//
|
||||
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic
|
||||
// SDK events that feed through the real reducer and footer pipeline. This
|
||||
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
|
||||
// presentation commits and footer actions as the live transport. This
|
||||
// lets you test scrollback formatting, permission UI, question UI, and tool
|
||||
// snapshots without making actual model calls. Pass a demo slash command as
|
||||
// the initial interactive message to trigger a preview immediately.
|
||||
@@ -15,10 +15,18 @@
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
import path from "path"
|
||||
import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||
import { toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
MiniToolPart,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
const KINDS = [
|
||||
"markdown",
|
||||
@@ -124,7 +132,7 @@ type Permit = {
|
||||
ref: Ref
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
metadata?: PermissionV2Request["metadata"]
|
||||
always: string[]
|
||||
done: Perm["done"]
|
||||
}
|
||||
@@ -132,9 +140,7 @@ type Permit = {
|
||||
type State = {
|
||||
id: string
|
||||
thinking: boolean
|
||||
data: SessionData
|
||||
footer: FooterApi
|
||||
limits: () => Record<string, number>
|
||||
msg: number
|
||||
part: number
|
||||
call: number
|
||||
@@ -142,12 +148,12 @@ type State = {
|
||||
ask: number
|
||||
perms: Map<string, Perm>
|
||||
asks: Map<string, Ask>
|
||||
started: Set<string>
|
||||
}
|
||||
|
||||
type Input = {
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: () => Record<string, number>
|
||||
footer: FooterApi
|
||||
}
|
||||
|
||||
@@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
|
||||
return `demo_${prefix}_${state[key]}`
|
||||
}
|
||||
|
||||
function feed(state: State, event: Event): void {
|
||||
const out = reduceSessionData({
|
||||
data: state.data,
|
||||
event,
|
||||
sessionID: state.id,
|
||||
thinking: state.thinking,
|
||||
limits: state.limits(),
|
||||
})
|
||||
state.data = out.data
|
||||
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{
|
||||
footer: state.footer,
|
||||
commits,
|
||||
footer: view
|
||||
? {
|
||||
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
|
||||
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
out,
|
||||
)
|
||||
}
|
||||
|
||||
function clearBlocker(state: State): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
|
||||
)
|
||||
}
|
||||
|
||||
function open(state: State): string {
|
||||
const id = take(state, "msg", "msg")
|
||||
feed(state, {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
info: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
parentID: `user_${id}`,
|
||||
modelID: "demo",
|
||||
providerID: "demo",
|
||||
mode: "demo",
|
||||
agent: "demo",
|
||||
path: {
|
||||
cwd: process.cwd(),
|
||||
root: process.cwd(),
|
||||
},
|
||||
cost: 0.001,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 320,
|
||||
reasoning: 80,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
return id
|
||||
return take(state, "msg", "msg")
|
||||
}
|
||||
|
||||
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
|
||||
const msg = open(state)
|
||||
const part = take(state, "part", "part")
|
||||
const start = Date.now()
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "text",
|
||||
text: "",
|
||||
time: {
|
||||
start,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
|
||||
let next = ""
|
||||
for (const item of split(body)) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
next += item
|
||||
feed(state, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
field: "text",
|
||||
delta: item,
|
||||
},
|
||||
} as Event)
|
||||
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
|
||||
await wait(45, signal)
|
||||
}
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "text",
|
||||
text: next,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
|
||||
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
|
||||
const msg = open(state)
|
||||
const part = take(state, "part", "part")
|
||||
const start = Date.now()
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
time: {
|
||||
start,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
|
||||
let next = ""
|
||||
let first = true
|
||||
for (const item of split(body)) {
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
next += item
|
||||
feed(state, {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
field: "text",
|
||||
delta: item,
|
||||
},
|
||||
} as Event)
|
||||
if (state.thinking) {
|
||||
present(state, [
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
|
||||
phase: "progress",
|
||||
messageID: msg,
|
||||
partID: part,
|
||||
},
|
||||
])
|
||||
first = false
|
||||
}
|
||||
await wait(45, signal)
|
||||
}
|
||||
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: part,
|
||||
sessionID: state.id,
|
||||
messageID: msg,
|
||||
type: "reasoning",
|
||||
text: next,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
|
||||
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
||||
@@ -448,29 +338,23 @@ function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
||||
}
|
||||
|
||||
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: ref.input,
|
||||
metadata,
|
||||
time: {
|
||||
start: ref.start,
|
||||
},
|
||||
state.started.add(ref.part)
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
"start",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function askPermission(state: State, item: Permit): void {
|
||||
@@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
|
||||
done: item.done,
|
||||
})
|
||||
|
||||
feed(state, {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
always: item.always,
|
||||
tool: {
|
||||
messageID: item.ref.msg,
|
||||
callID: item.ref.call,
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
present(state, [], {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
save: item.always,
|
||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
function doneTool(
|
||||
@@ -508,77 +386,53 @@ function doneTool(
|
||||
metadata?: Record<string, unknown>
|
||||
},
|
||||
): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: ref.input,
|
||||
output: output.output,
|
||||
title: output.title,
|
||||
metadata: output.metadata ?? {},
|
||||
time: {
|
||||
start: ref.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
const part: MiniToolPart = {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: ref.input,
|
||||
output: output.output,
|
||||
title: output.title,
|
||||
metadata: output.metadata ?? {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
} as Event)
|
||||
}
|
||||
present(state, [toolCommit(part, output.output ? "progress" : "final")])
|
||||
}
|
||||
|
||||
function failTool(state: State, ref: Ref, error: string): void {
|
||||
feed(state, {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
type: "tool",
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error,
|
||||
metadata: {},
|
||||
time: {
|
||||
start: ref.start,
|
||||
end: Date.now(),
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error,
|
||||
metadata: {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
"final",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function emitError(state: State, text: string): void {
|
||||
const event = {
|
||||
id: `session.error:${state.id}:${Date.now()}`,
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: text,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
present(state, [{ kind: "error", source: "system", text, phase: "start" }])
|
||||
}
|
||||
|
||||
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
||||
@@ -685,7 +539,7 @@ function emitTask(state: State): void {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
} satisfies ToolPart
|
||||
} satisfies MiniToolPart
|
||||
showSubagent(state, {
|
||||
sessionID: "sub_demo_1",
|
||||
partID: ref.part,
|
||||
@@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
||||
const id = take(state, "ask", "ask")
|
||||
state.asks.set(id, { ref })
|
||||
|
||||
feed(state, {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
questions,
|
||||
tool: {
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
present(state, [], {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
questions,
|
||||
tool: { messageID: ref.msg, callID: ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||
@@ -1089,9 +937,7 @@ export function createRunDemo(input: Input) {
|
||||
const state: State = {
|
||||
id: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
data: createSessionData(),
|
||||
footer: input.footer,
|
||||
limits: input.limits,
|
||||
msg: 0,
|
||||
part: 0,
|
||||
call: 0,
|
||||
@@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
|
||||
ask: 0,
|
||||
perms: new Map(),
|
||||
asks: new Map(),
|
||||
started: new Set(),
|
||||
}
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
@@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.perms.delete(input.requestID)
|
||||
const event = {
|
||||
id: `permission.replied:${input.requestID}:${Date.now()}`,
|
||||
type: "permission.replied",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
reply: input.reply,
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
clearBlocker(state)
|
||||
|
||||
if (input.reply === "reject") {
|
||||
failTool(state, item.ref, input.message || "permission rejected")
|
||||
@@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
const event = {
|
||||
id: `question.replied:${input.requestID}:${Date.now()}`,
|
||||
type: "question.replied",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
answers: input.answers,
|
||||
},
|
||||
} satisfies Event
|
||||
feed(state, event)
|
||||
clearBlocker(state)
|
||||
doneTool(state, ask.ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
@@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
feed(state, {
|
||||
type: "question.rejected",
|
||||
properties: {
|
||||
sessionID: state.id,
|
||||
requestID: input.requestID,
|
||||
},
|
||||
} as Event)
|
||||
clearBlocker(state)
|
||||
failTool(state, ask.ref, "question rejected")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
@@ -130,7 +130,7 @@ export function RejectField(props: {
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: PermissionRequest
|
||||
request: PermissionV2Request
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
@@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
)
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
@@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionRequest
|
||||
request: QuestionV2Request
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
|
||||
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
this.themes.splice(index, 1)
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
|
||||
void resolveRunTheme(this.renderer).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
import { UI } from "./ui"
|
||||
import type { MiniToolPart } from "./types"
|
||||
|
||||
type Model = {
|
||||
providerID: string
|
||||
@@ -28,8 +28,8 @@ type Input = {
|
||||
auto: boolean
|
||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||
attached: boolean
|
||||
renderTool: (part: ToolPart) => Promise<void>
|
||||
renderToolError: (part: ToolPart) => Promise<void>
|
||||
renderTool: (part: MiniToolPart) => Promise<void>
|
||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
||||
}
|
||||
|
||||
type StartedPart = {
|
||||
@@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
return true
|
||||
}
|
||||
|
||||
const writeText = (part: TextPart, timestamp: number) => {
|
||||
const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
|
||||
if (emit("text", timestamp, { part })) return
|
||||
const text = part.text.trim()
|
||||
if (!text) return
|
||||
@@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!promoted) continue
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
const part: StepStartPart = {
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.text.ended") {
|
||||
const started = starts.get("text")
|
||||
starts.delete("text")
|
||||
const part: TextPart = {
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get("reasoning")
|
||||
starts.delete("reasoning")
|
||||
const part: ReasoningPart = {
|
||||
const part = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const part: ToolPart = {
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.tool.failed") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const part: ToolPart = {
|
||||
const part: MiniToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
@@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
|
||||
if (event.type === "session.step.ended") {
|
||||
const part: StepFinishPart = {
|
||||
const part = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
||||
// the request, delegating to tool.ts for tool-specific formatting.
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import type { PermissionReply } from "./types"
|
||||
import { toolPath, toolPermissionInfo } from "./tool"
|
||||
|
||||
@@ -55,7 +55,7 @@ function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function data(request: PermissionRequest): Dict {
|
||||
function data(request: PermissionV2Request): Dict {
|
||||
const meta = dict(request.metadata)
|
||||
return {
|
||||
...meta,
|
||||
@@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
|
||||
}
|
||||
}
|
||||
|
||||
function patterns(request: PermissionRequest): string[] {
|
||||
return request.patterns.filter((item): item is string => typeof item === "string")
|
||||
function patterns(request: PermissionV2Request): string[] {
|
||||
return request.resources.filter((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
||||
@@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
||||
const pats = patterns(request)
|
||||
const input = data(request)
|
||||
const info = toolPermissionInfo(request.permission, input, dict(request.metadata), pats)
|
||||
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
|
||||
if (info) {
|
||||
return info
|
||||
}
|
||||
|
||||
if (request.permission === "external_directory") {
|
||||
if (request.action === "external_directory") {
|
||||
const meta = dict(request.metadata)
|
||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
||||
@@ -108,7 +108,7 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
if (request.permission === "doom_loop") {
|
||||
if (request.action === "doom_loop") {
|
||||
return {
|
||||
icon: "⟳",
|
||||
title: "Continue after repeated failures",
|
||||
@@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
|
||||
return {
|
||||
icon: "⚙",
|
||||
title: `Call tool ${request.permission}`,
|
||||
lines: [`Tool: ${request.permission}`],
|
||||
title: `Call tool ${request.action}`,
|
||||
lines: [`Tool: ${request.action}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until OpenCode is restarted.`]
|
||||
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
|
||||
const save = request.save ?? []
|
||||
if (save.length === 1 && save[0] === "*") {
|
||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
...save.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//
|
||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
||||
import type { QuestionInfo, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export type QuestionBodyState = {
|
||||
@@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
|
||||
return createQuestionBodyState(requestID)
|
||||
}
|
||||
|
||||
export function questionSingle(request: QuestionRequest): boolean {
|
||||
export function questionSingle(request: QuestionV2Request): boolean {
|
||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
||||
}
|
||||
|
||||
export function questionTabs(request: QuestionRequest): number {
|
||||
export function questionTabs(request: QuestionV2Request): number {
|
||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
||||
}
|
||||
|
||||
export function questionConfirm(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
return !questionSingle(request) && state.tab === request.questions.length
|
||||
}
|
||||
|
||||
export function questionInfo(request: QuestionRequest, state: QuestionBodyState): QuestionInfo | undefined {
|
||||
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
|
||||
return request.questions[state.tab]
|
||||
}
|
||||
|
||||
export function questionCustom(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
return questionInfo(request, state)?.custom !== false
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
|
||||
return state.answers[state.tab]?.includes(value) ?? false
|
||||
}
|
||||
|
||||
export function questionOther(request: QuestionRequest, state: QuestionBodyState): boolean {
|
||||
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info || info.custom === false) {
|
||||
return false
|
||||
@@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
|
||||
return state.selected === info.options.length
|
||||
}
|
||||
|
||||
export function questionTotal(request: QuestionRequest, state: QuestionBodyState): number {
|
||||
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return 0
|
||||
@@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
|
||||
|
||||
function questionPick(
|
||||
state: QuestionBodyState,
|
||||
request: QuestionRequest,
|
||||
request: QuestionV2Request,
|
||||
answer: string,
|
||||
custom = false,
|
||||
): QuestionStep {
|
||||
@@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
|
||||
return storeAnswers(state, state.tab, list)
|
||||
}
|
||||
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionRequest, dir: -1 | 1): QuestionBodyState {
|
||||
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
|
||||
const total = questionTotal(request, state)
|
||||
if (total === 0) {
|
||||
return state
|
||||
@@ -216,7 +216,7 @@ export function questionMove(state: QuestionBodyState, request: QuestionRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
@@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
|
||||
return questionPick(state, request, option.label)
|
||||
}
|
||||
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionRequest): QuestionStep {
|
||||
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
||||
const info = questionInfo(request, state)
|
||||
if (!info) {
|
||||
return { state }
|
||||
@@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
|
||||
return questionPick(state, request, value, true)
|
||||
}
|
||||
|
||||
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply {
|
||||
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
|
||||
return {
|
||||
requestID: request.id,
|
||||
answers: questionAnswers(state, request.questions.length),
|
||||
}
|
||||
}
|
||||
|
||||
export function questionReject(request: QuestionRequest): QuestionReject {
|
||||
export function questionReject(request: QuestionV2Request): QuestionReject {
|
||||
return {
|
||||
requestID: request.id,
|
||||
}
|
||||
}
|
||||
|
||||
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string {
|
||||
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
|
||||
if (state.submitting) {
|
||||
return "Waiting for question event..."
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Server } from "../services/server"
|
||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
import type { MiniToolPart } from "./types"
|
||||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
@@ -224,7 +224,7 @@ function isBinaryContent(bytes: Uint8Array) {
|
||||
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||
}
|
||||
|
||||
async function renderTool(part: ToolPart) {
|
||||
async function renderTool(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
if (info.mode === "block") {
|
||||
UI.empty()
|
||||
@@ -240,7 +240,7 @@ async function renderTool(part: ToolPart) {
|
||||
)
|
||||
}
|
||||
|
||||
async function renderToolError(part: ToolPart) {
|
||||
async function renderToolError(part: MiniToolPart) {
|
||||
const info = toolInlineInfo(part)
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
footer,
|
||||
sessionID: state.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: () => state.limits,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
export function entrySyntax(theme: RunTheme): SyntaxStyle {
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
active.renderable.fg = entryColor(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(theme)
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
|
||||
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const syntax = createMemo(() => entrySyntax(theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
||||
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,10 @@
|
||||
// Session message extraction and prompt history.
|
||||
//
|
||||
// Fetches session messages from the SDK and extracts user turn text for
|
||||
// the prompt history ring. Also finds the most recently used variant for
|
||||
// the current model so the footer can pre-select it.
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
export type SessionMessages = Array<{ info: Message; parts: Part[] }>
|
||||
export type SessionMessages = SessionMessageInfo[]
|
||||
|
||||
type Turn = {
|
||||
prompt: RunPrompt
|
||||
@@ -25,133 +20,42 @@ export type RunSession = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
function fileName(url: string, filename?: string) {
|
||||
if (filename) {
|
||||
return filename
|
||||
}
|
||||
|
||||
try {
|
||||
const next = new URL(url)
|
||||
if (next.protocol !== "file:") {
|
||||
return url
|
||||
}
|
||||
|
||||
const name = next.pathname.split("/").at(-1)
|
||||
if (name) {
|
||||
return decodeURIComponent(name)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function fileSource(
|
||||
part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
|
||||
text: { start: number; end: number; value: string },
|
||||
) {
|
||||
if (part.source) {
|
||||
return {
|
||||
...structuredClone(part.source),
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
function messagePrompt(message: SessionMessageUser): RunPrompt {
|
||||
return {
|
||||
type: "file" as const,
|
||||
path: part.filename ?? part.url,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
||||
const parts: RunPrompt["parts"] = []
|
||||
let text = msg.parts
|
||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
||||
return part.type === "text" && !part.synthetic
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
let cursor = Bun.stringWidth(text)
|
||||
const used: Array<{ start: number; end: number }> = []
|
||||
|
||||
const take = (value: string): { start: number; end: number; value: string } | undefined => {
|
||||
let from = 0
|
||||
while (true) {
|
||||
const idx = text.indexOf(value, from)
|
||||
if (idx === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = Bun.stringWidth(text.slice(0, idx))
|
||||
const end = start + Bun.stringWidth(value)
|
||||
if (!used.some((item) => item.start < end && start < item.end)) {
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
from = idx + value.length
|
||||
}
|
||||
}
|
||||
|
||||
const add = (value: string) => {
|
||||
const gap = text ? " " : ""
|
||||
const start = cursor + Bun.stringWidth(gap)
|
||||
text += gap + value
|
||||
const end = start + Bun.stringWidth(value)
|
||||
cursor = end
|
||||
return { start, end, value }
|
||||
}
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "file") {
|
||||
const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename))
|
||||
const span = next ?? add("@" + fileName(part.url, part.filename))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: fileSource(part, span),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "agent") {
|
||||
continue
|
||||
}
|
||||
|
||||
const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name))
|
||||
used.push({ start: span.start, end: span.end })
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: span,
|
||||
})
|
||||
}
|
||||
|
||||
return { text, parts }
|
||||
}
|
||||
|
||||
function turn(msg: SessionMessages[number]): Turn | undefined {
|
||||
if (msg.info.role !== "user") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: messagePrompt(msg),
|
||||
provider: msg.info.model.providerID,
|
||||
model: msg.info.model.modelID,
|
||||
variant: msg.info.model.variant,
|
||||
text: message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file",
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function createSession(messages: SessionMessages): RunSession {
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((msg) => {
|
||||
const item = turn(msg)
|
||||
return item ? [item] : []
|
||||
}),
|
||||
turns: messages.flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
|
||||
: [],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,89 +68,34 @@ export async function resolveCurrentSession(
|
||||
sdk.message.list({ sessionID, limit, order: "desc" }),
|
||||
sdk.session.get({ sessionID }),
|
||||
])
|
||||
const messages = response.data.toReversed()
|
||||
const current = createSession(response.data.toReversed())
|
||||
return {
|
||||
first: messages.length === 0,
|
||||
turns: messages.flatMap((message) => {
|
||||
if (message.type !== "user") return []
|
||||
return [
|
||||
{
|
||||
prompt: {
|
||||
text: message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
source: file.mention
|
||||
? {
|
||||
type: "file" as const,
|
||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
...(message.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
},
|
||||
provider: session.model?.providerID,
|
||||
model: session.model?.id,
|
||||
variant: session.model?.variant,
|
||||
},
|
||||
]
|
||||
}),
|
||||
...current,
|
||||
turns: current.turns.map((turn) => ({
|
||||
...turn,
|
||||
provider: session.model?.providerID,
|
||||
model: session.model?.id,
|
||||
variant: session.model?.variant,
|
||||
})),
|
||||
...(session.model && {
|
||||
model: {
|
||||
providerID: session.model.providerID,
|
||||
modelID: session.model.id,
|
||||
},
|
||||
model: { providerID: session.model.providerID, modelID: session.model.id },
|
||||
variant: session.model.variant,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
|
||||
const out: RunPrompt[] = []
|
||||
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.prompt.text.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(promptCopy(turn.prompt))
|
||||
}
|
||||
|
||||
return out.slice(-limit)
|
||||
return session.turns
|
||||
.map((turn) => turn.prompt)
|
||||
.filter((prompt) => prompt.text.trim())
|
||||
.filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
|
||||
.map(promptCopy)
|
||||
.slice(-limit)
|
||||
}
|
||||
|
||||
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
if (!model) return
|
||||
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) return session.variant
|
||||
|
||||
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
|
||||
return session.variant
|
||||
}
|
||||
|
||||
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
|
||||
const turn = session.turns[idx]
|
||||
if (turn.provider !== model.providerID || turn.model !== model.modelID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return turn.variant
|
||||
}
|
||||
|
||||
return undefined
|
||||
return session.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
|
||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
|
||||
|
||||
const CHILD_MESSAGE_LIMIT = 80
|
||||
const CHILD_FRAME_LIMIT = 80
|
||||
@@ -32,11 +36,11 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
|
||||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
||||
}
|
||||
|
||||
export function legacyTool(input: {
|
||||
export function miniTool(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
tool: SessionMessageAssistantTool
|
||||
}): ToolPart {
|
||||
}): MiniToolPart {
|
||||
const tool = input.tool
|
||||
const providerCall =
|
||||
tool.executed === undefined && tool.providerState === undefined
|
||||
@@ -109,7 +113,7 @@ export function legacyTool(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
||||
const status = part.state.status
|
||||
const text =
|
||||
status === "running"
|
||||
@@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
}
|
||||
|
||||
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
|
||||
const part = legacyTool({
|
||||
const part = miniTool({
|
||||
sessionID: child.sessionID,
|
||||
messageID,
|
||||
tool: item,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionMessageInfo,
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
PermissionV2Request,
|
||||
QuestionV2Request,
|
||||
SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blockerStatus, pickBlockerView } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent"
|
||||
import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -87,8 +88,6 @@ type ShellWait = {
|
||||
}
|
||||
|
||||
type RunV2Event = EventSubscribeOutput
|
||||
type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
|
||||
type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
|
||||
type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
|
||||
|
||||
type ToolState = {
|
||||
@@ -101,8 +100,8 @@ type ToolState = {
|
||||
}
|
||||
|
||||
type State = {
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
permissions: PermissionV2Request[]
|
||||
questions: QuestionV2Request[]
|
||||
view: FooterView
|
||||
messageIDs: Set<string>
|
||||
text: Map<string, string>
|
||||
@@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function permission(request: PermissionV2Request): PermissionRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
permission: request.action,
|
||||
patterns: [...request.resources],
|
||||
metadata: request.metadata ?? {},
|
||||
always: [...(request.save ?? [])],
|
||||
tool: request.source?.type === "tool" ? request.source : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function question(request: QuestionV2Request): QuestionRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
|
||||
tool: request.tool,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionID(event: RunV2Event) {
|
||||
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
|
||||
}
|
||||
@@ -229,8 +207,7 @@ function streamPartKey(messageID: string, partID: string) {
|
||||
return `${messageID}\u0000${partID}`
|
||||
}
|
||||
|
||||
// Matches the commit shapes the legacy session-data reducer produced for direct
|
||||
// shell calls: one "start" commit rendering `$ command` and one "progress"
|
||||
// Direct shell calls use one "start" commit rendering `$ command` and one "progress"
|
||||
// commit rendering the merged output (see toolEntryBody in tool.ts).
|
||||
function shellCommit(
|
||||
callID: string,
|
||||
@@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
|
||||
const part = legacyTool({
|
||||
const part = miniTool({
|
||||
sessionID: input.sessionID,
|
||||
messageID,
|
||||
tool: item,
|
||||
@@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
])
|
||||
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
state.permissions = permissions.map(permission)
|
||||
state.questions = questions.map(question)
|
||||
state.permissions = permissions
|
||||
state.questions = questions
|
||||
syncBlockers()
|
||||
await subagents.hydrate({ messages: [...projected], active })
|
||||
const running = input.sessionID in active
|
||||
@@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "permission.v2.asked") {
|
||||
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data))
|
||||
if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(event.data)
|
||||
syncBlockers()
|
||||
return
|
||||
}
|
||||
@@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (event.type === "question.v2.asked") {
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
|
||||
syncBlockers()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Thin bridge between reducer output and the footer API.
|
||||
// Thin bridge between transport output and the footer API.
|
||||
//
|
||||
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// Transports produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
// phase -- a convenience so transport code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
@@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
|
||||
permissions: state.permissions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
tool: item.tool,
|
||||
action: item.action,
|
||||
resources: item.resources,
|
||||
source: item.source,
|
||||
metadata: item.metadata
|
||||
? {
|
||||
keys: Object.keys(item.metadata),
|
||||
@@ -137,7 +137,7 @@ export function traceFooterOutput(footer?: FooterOutput) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
// Forwards transport output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
|
||||
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
)
|
||||
}
|
||||
|
||||
function blend(color: RGBA, bg: RGBA): RGBA {
|
||||
if (color.a >= 1) {
|
||||
return color
|
||||
}
|
||||
|
||||
return RGBA.fromValues(
|
||||
bg.r + (color.r - bg.r) * color.a,
|
||||
bg.g + (color.g - bg.g) * color.a,
|
||||
bg.b + (color.b - bg.b) * color.a,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
|
||||
if (!style) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return SyntaxStyle.fromStyles(
|
||||
Object.fromEntries(
|
||||
[...style.getAllStyles()].map(([name, value]) => [
|
||||
name,
|
||||
{
|
||||
...value,
|
||||
fg: value.fg ? blend(value.fg, bg) : value.fg,
|
||||
bg: value.bg ? blend(value.bg, bg) : value.bg,
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
@@ -502,10 +469,7 @@ function map(
|
||||
scrollbackTheme: TuiThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
syntax?: SyntaxStyle,
|
||||
subtleSyntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
|
||||
subtleSyntax?.destroy()
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
@@ -566,7 +530,6 @@ function map(
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: scrollbackTheme.diffAdded,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed),
|
||||
syntax,
|
||||
shared.generateSubtleSyntax(syntaxTheme),
|
||||
)
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
import type { MiniToolPart, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type ToolView = {
|
||||
output: boolean
|
||||
@@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
|
||||
return TOOL_RULES[name]
|
||||
}
|
||||
|
||||
function frame(part: ToolPart): ToolFrame {
|
||||
function frame(part: MiniToolPart): ToolFrame {
|
||||
const state = dict(part.state)
|
||||
return {
|
||||
raw: "",
|
||||
@@ -1231,7 +1230,7 @@ export function toolStructuredFinal(commit: StreamCommit): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function toolInlineInfo(part: ToolPart): ToolInline {
|
||||
export function toolInlineInfo(part: MiniToolPart): ToolInline {
|
||||
const ctx = frame(part)
|
||||
const draw = rule(ctx.name)?.run
|
||||
try {
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
//
|
||||
// Data flow through the system:
|
||||
//
|
||||
// SDK events → session-data reducer → StreamCommit[] + FooterOutput
|
||||
// V2 events / demo actions → StreamCommit[] + FooterOutput
|
||||
// → stream.ts bridges to footer API
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
||||
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type {
|
||||
OpenCodeClient,
|
||||
PermissionV2Request,
|
||||
QuestionV2Request,
|
||||
ReferenceListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
|
||||
export type RunFilePart = {
|
||||
@@ -30,7 +34,11 @@ export type RunPromptPart =
|
||||
url: string
|
||||
filename?: string
|
||||
mime?: string
|
||||
source?: FilePart["source"]
|
||||
source?: {
|
||||
type: string
|
||||
text: { start: number; end: number; value: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
| { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
|
||||
|
||||
@@ -210,6 +218,41 @@ export type ToolQuestionSnapshot = {
|
||||
|
||||
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
|
||||
|
||||
export type MiniToolState =
|
||||
| { status: "pending"; input: Record<string, unknown>; raw?: string }
|
||||
| {
|
||||
status: "running"
|
||||
input: Record<string, unknown>
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number }
|
||||
}
|
||||
| {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
| {
|
||||
status: "error"
|
||||
input: Record<string, unknown>
|
||||
error: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type MiniToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type?: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: MiniToolState
|
||||
}
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
export type RunEntryBody =
|
||||
@@ -220,13 +263,13 @@ export type RunEntryBody =
|
||||
| { type: "structured"; snapshot: ToolSnapshot }
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// a time. The transport drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
// "prompt".
|
||||
export type FooterView =
|
||||
| { type: "prompt" }
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
| { type: "permission"; request: PermissionV2Request }
|
||||
| { type: "question"; request: QuestionV2Request }
|
||||
|
||||
export type FooterPromptRoute =
|
||||
| { type: "composer" }
|
||||
@@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
|
||||
export type FooterSubagentState = {
|
||||
tabs: FooterSubagentTab[]
|
||||
details: Record<string, FooterSubagentDetail>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
permissions: PermissionV2Request[]
|
||||
questions: QuestionV2Request[]
|
||||
}
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
// The transport emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
@@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The session-data reducer produces
|
||||
// these from SDK events, and RunFooter.append() queues them for the next
|
||||
// A single append-only commit to scrollback. The transport produces these from
|
||||
// V2 events, and RunFooter.append() queues them for the next
|
||||
// microtask flush. Once flushed, they become immutable terminal scrollback
|
||||
// rows -- they cannot be rewritten.
|
||||
export type StreamCommit = {
|
||||
@@ -370,7 +413,7 @@ export type StreamCommit = {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
tool?: string
|
||||
part?: ToolPart
|
||||
part?: MiniToolPart
|
||||
interrupted?: boolean
|
||||
toolState?: StreamToolState
|
||||
toolError?: string
|
||||
|
||||
@@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
path.join(directory, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
attention_sound_pack: "custom.pack",
|
||||
diff_wrap_mode: "none",
|
||||
diff_viewer_show_file_tree: false,
|
||||
diff_viewer_single_patch: true,
|
||||
diff_viewer_view: "split",
|
||||
terminal_title_enabled: false,
|
||||
file_context_enabled: false,
|
||||
paste_summary_enabled: false,
|
||||
sidebar: "hide",
|
||||
scrollbar_visible: true,
|
||||
thinking_mode: "show",
|
||||
exploration_grouping: false,
|
||||
tips_hidden: true,
|
||||
dismissed_getting_started: true,
|
||||
animations_enabled: false,
|
||||
skipped_version: "9.9.9",
|
||||
which_key_layout: "overlay",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,12 +70,17 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
diffs: { view: "unified" },
|
||||
prompt: { paste: "full" },
|
||||
session: { grouping: "none" },
|
||||
hints: { tips: false },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { tips: false, onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
## Future Design Notes
|
||||
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
|
||||
- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside CodeMode) instead.
|
||||
- Improve the failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure.
|
||||
- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default.
|
||||
- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization.
|
||||
- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability.
|
||||
|
||||
@@ -20,7 +20,7 @@ ultimate source of truth.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -94,7 +94,7 @@ ultimate source of truth.
|
||||
- [x] Optional property access and optional calls.
|
||||
- [x] Function/tool calls and spread arguments.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
continuation one reaction turn.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
@@ -109,7 +109,7 @@ ultimate source of truth.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
- [x] Tool calls start eagerly and return supervised, run-once sandbox promises.
|
||||
- [x] Tool calls start eagerly and return supervised, run-once CodeMode promises.
|
||||
- [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
|
||||
- [x] `Promise.resolve` and `Promise.reject`.
|
||||
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing
|
||||
@@ -148,7 +148,7 @@ ultimate source of truth.
|
||||
- [x] Computed property names and object spread.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
|
||||
- [x] `Object.keys` over arrays and tool references.
|
||||
- [x] Object identity is preserved by in-sandbox Object helpers.
|
||||
- [x] Object identity is preserved by in-CodeMode Object helpers.
|
||||
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
|
||||
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
|
||||
- [ ] `Object.groupBy`.
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
import { rejectCircularInsertion } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
@@ -33,7 +33,7 @@ import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../
|
||||
|
||||
export type CallbackRunner<R> = {
|
||||
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
readonly settlePromise: (promise: SandboxPromise) => Effect.Effect<unknown, unknown, never>
|
||||
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
|
||||
}
|
||||
|
||||
export const invokeIntrinsic = <R>(
|
||||
@@ -57,22 +57,22 @@ export const invokeIntrinsic = <R>(
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
if (ref.receiver instanceof CodeModeDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
if (ref.receiver instanceof CodeModeRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
if (ref.receiver instanceof CodeModeMap) {
|
||||
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
if (ref.receiver instanceof CodeModeSet) {
|
||||
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURL) {
|
||||
if (ref.receiver instanceof CodeModeURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
||||
if (ref.receiver instanceof CodeModeURLSearchParams) {
|
||||
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
@@ -153,7 +153,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
result = value.split(args[0].regex, optNum(1))
|
||||
break
|
||||
}
|
||||
@@ -181,7 +181,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
@@ -278,13 +278,13 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
if (args[0] instanceof CodeModeMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof CodeModeSet) return Array.from(args[0].set.values())
|
||||
if (args[0] instanceof CodeModeURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
const source = args[0]
|
||||
if (source instanceof SandboxPromise) {
|
||||
if (source instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from received an un-awaited Promise; await it before creating the array.",
|
||||
node,
|
||||
@@ -341,7 +341,7 @@ const invokeStringReplacer = <R>(
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof SandboxRegExp) {
|
||||
if (pattern instanceof CodeModeRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
@@ -364,7 +364,7 @@ const invokeStringReplacer = <R>(
|
||||
for (const match of matches) {
|
||||
const replacement = yield* apply(match.args)
|
||||
const resolved =
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof SandboxPromise
|
||||
args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise
|
||||
? yield* runner.settlePromise(replacement)
|
||||
: replacement
|
||||
output.push(
|
||||
@@ -404,7 +404,7 @@ export const applyCollectionCallback = <R>(
|
||||
|
||||
const invokeMapMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxMap,
|
||||
target: CodeModeMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -446,7 +446,7 @@ const invokeMapMethod = <R>(
|
||||
|
||||
const invokeSetMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxSet,
|
||||
target: CodeModeSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -485,7 +485,7 @@ const invokeSetMethod = <R>(
|
||||
|
||||
const invokeURLSearchParamsMethod = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
target: SandboxURLSearchParams,
|
||||
target: CodeModeURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SandboxPromise, SandboxURL } from "../values.js"
|
||||
import type { CodeModePromise, CodeModeURL } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
@@ -35,7 +35,7 @@ export type StatementResult =
|
||||
| { kind: "continue" }
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | SandboxURL
|
||||
target: SafeObject | Array<unknown> | CodeModeURL
|
||||
key: string | number
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
|
||||
|
||||
export class PromiseInstanceMethodReference {
|
||||
constructor(
|
||||
readonly promise: SandboxPromise,
|
||||
readonly promise: CodeModePromise,
|
||||
readonly name: PromiseInstanceMethodName,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,24 @@ import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { spreadItems } from "../stdlib/collections.js"
|
||||
import { createAggregateErrorValue } from "../stdlib/value.js"
|
||||
import { SandboxPromise } from "../values.js"
|
||||
import { CodeModePromise } from "../values.js"
|
||||
|
||||
// Observation only controls rejection reporting; program completion interrupts all promise work.
|
||||
export class PromiseRuntime<R> {
|
||||
private readonly active = new Set<SandboxPromise>()
|
||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||
private readonly observed = new WeakSet<SandboxPromise>()
|
||||
private readonly active = new Set<CodeModePromise>()
|
||||
private readonly ids = new WeakMap<CodeModePromise, number>()
|
||||
private readonly observed = new WeakSet<CodeModePromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return Effect.suspend(() => {
|
||||
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
const promise = new CodeModePromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
@@ -55,14 +55,14 @@ export class PromiseRuntime<R> {
|
||||
}
|
||||
|
||||
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
|
||||
markObserved(promise: SandboxPromise): void {
|
||||
markObserved(promise: CodeModePromise): void {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const invokePromiseMethod = <R>(
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
if (ref.name === "resolve") {
|
||||
const value = args[0]
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
return value instanceof CodeModePromise ? Effect.succeed(value) : promises.create(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return promises.create(Effect.fail(new ProgramThrow(args[0])))
|
||||
@@ -114,19 +114,19 @@ export const invokePromiseMethod = <R>(
|
||||
const items = Array.from(spread)
|
||||
|
||||
for (const item of items) {
|
||||
if (item instanceof SandboxPromise) promises.markObserved(item)
|
||||
if (item instanceof CodeModePromise) promises.markObserved(item)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
item instanceof CodeModePromise ? Effect.flatten(promises.await(item)) : Effect.succeed(item),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(
|
||||
settleAfterTurn(
|
||||
@@ -168,13 +168,13 @@ export const invokePromiseMethod = <R>(
|
||||
)
|
||||
}
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
item instanceof CodeModePromise ? promises.await(item) : Effect.succeed(Exit.succeed(item)),
|
||||
)
|
||||
return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
|
||||
}
|
||||
case "any": {
|
||||
const flipped = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
item instanceof CodeModePromise
|
||||
? Effect.flatMap(promises.await(item), (exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
|
||||
@@ -201,7 +201,7 @@ export const invokePromiseInstanceMethod = <R>(
|
||||
ref: PromiseInstanceMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const method = `Promise.prototype.${ref.name}`
|
||||
promises.markObserved(ref.promise)
|
||||
if (ref.name === "finally") {
|
||||
@@ -217,7 +217,7 @@ export const constructPromise = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
executor: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, unknown, R> => {
|
||||
): Effect.Effect<CodeModePromise, unknown, R> => {
|
||||
if (!(executor instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
|
||||
@@ -226,10 +226,10 @@ export const constructPromise = <R>(
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const box: { own?: CodeModePromise } = {}
|
||||
const promise = yield* promises.create(
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError(node))
|
||||
return runner.settlePromise(value)
|
||||
}),
|
||||
@@ -281,7 +281,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Reactio
|
||||
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
|
||||
const reactionExit = <R>(
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* promises.await(source)
|
||||
@@ -293,13 +293,13 @@ const reactionExit = <R>(
|
||||
const chainReaction = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
onFulfilled: ReactionHandler | undefined,
|
||||
onRejected: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> => {
|
||||
const box: { derived?: SandboxPromise } = {}
|
||||
): Effect.Effect<CodeModePromise, never, R> => {
|
||||
const box: { derived?: CodeModePromise } = {}
|
||||
const body = Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
|
||||
@@ -307,7 +307,7 @@ const chainReaction = <R>(
|
||||
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
|
||||
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
|
||||
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
|
||||
if (result instanceof SandboxPromise) return yield* runner.settlePromise(result)
|
||||
if (result instanceof CodeModePromise) return yield* runner.settlePromise(result)
|
||||
return result
|
||||
})
|
||||
return Effect.map(promises.create(body), (derived) => {
|
||||
@@ -319,17 +319,17 @@ const chainReaction = <R>(
|
||||
const chainFinally = <R>(
|
||||
runner: CallbackRunner<R>,
|
||||
promises: PromiseRuntime<R>,
|
||||
source: SandboxPromise,
|
||||
source: CodeModePromise,
|
||||
cleanup: ReactionHandler | undefined,
|
||||
method: string,
|
||||
node: AstNode,
|
||||
): Effect.Effect<SandboxPromise, never, R> =>
|
||||
): Effect.Effect<CodeModePromise, never, R> =>
|
||||
promises.create(
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* reactionExit(promises, source)
|
||||
if (cleanup !== undefined) {
|
||||
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
|
||||
if (result instanceof SandboxPromise) yield* runner.settlePromise(result)
|
||||
if (result instanceof CodeModePromise) yield* runner.settlePromise(result)
|
||||
}
|
||||
return yield* exit
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxPromise } from "../values.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
@@ -26,13 +26,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseInstanceMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CodeModePromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
isCodeModeValue(value)
|
||||
|
||||
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
@@ -46,9 +46,9 @@ export const containsRuntimeReference = (value: unknown, seen = new Set<object>(
|
||||
return contains
|
||||
}
|
||||
|
||||
// Sandbox values are data here, not opaque interpreter references.
|
||||
// CodeMode values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isCodeModeValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
|
||||
@@ -73,14 +73,14 @@ import {
|
||||
valueConstructors,
|
||||
} from "../stdlib/value.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
@@ -91,24 +91,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof SandboxDate
|
||||
return lhs instanceof CodeModeDate
|
||||
case "RegExp":
|
||||
return lhs instanceof SandboxRegExp
|
||||
return lhs instanceof CodeModeRegExp
|
||||
case "Map":
|
||||
return lhs instanceof SandboxMap
|
||||
return lhs instanceof CodeModeMap
|
||||
case "Set":
|
||||
return lhs instanceof SandboxSet
|
||||
return lhs instanceof CodeModeSet
|
||||
case "URL":
|
||||
return lhs instanceof SandboxURL
|
||||
return lhs instanceof CodeModeURL
|
||||
case "URLSearchParams":
|
||||
return lhs instanceof SandboxURLSearchParams
|
||||
return lhs instanceof CodeModeURLSearchParams
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
@@ -226,7 +226,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// The implicit async body adopts returned promises before copy-out.
|
||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||
if (value instanceof CodeModePromise) value = yield* self.settlePromise(value)
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||
}
|
||||
@@ -235,16 +235,16 @@ export class Interpreter<R> {
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args)))
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
|
||||
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
|
||||
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
|
||||
const promises = this.promises
|
||||
return Effect.suspend(() => {
|
||||
promises.markObserved(promise)
|
||||
@@ -971,7 +971,7 @@ export class Interpreter<R> {
|
||||
// Await always suspends, including for plain values.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
|
||||
value instanceof CodeModePromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
@@ -1019,23 +1019,23 @@ export class Interpreter<R> {
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>): SandboxDate {
|
||||
if (args.length === 0) return new SandboxDate(Date.now())
|
||||
private constructDate(args: Array<unknown>): CodeModeDate {
|
||||
if (args.length === 0) return new CodeModeDate(Date.now())
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
|
||||
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new SandboxDate(Date.parse(arg))
|
||||
return new SandboxDate(Number.NaN)
|
||||
if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
|
||||
if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
|
||||
return new CodeModeDate(Number.NaN)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return new SandboxDate(new Date(...(parts as [number, number])).getTime())
|
||||
return new CodeModeDate(new Date(...(parts as [number, number])).getTime())
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1043,9 +1043,9 @@ export class Interpreter<R> {
|
||||
node,
|
||||
)
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
|
||||
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new SandboxRegExp(pattern, flags)
|
||||
return new CodeModeRegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1057,12 +1057,12 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): SandboxMap {
|
||||
const target = new SandboxMap()
|
||||
private constructMap(init: unknown, node: AstNode): CodeModeMap {
|
||||
const target = new CodeModeMap()
|
||||
if (init === undefined || init === null) return target
|
||||
const entries = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxMap
|
||||
: init instanceof CodeModeMap
|
||||
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
: undefined
|
||||
if (entries === undefined) {
|
||||
@@ -1080,12 +1080,12 @@ export class Interpreter<R> {
|
||||
return target
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): SandboxSet {
|
||||
const target = new SandboxSet()
|
||||
private constructSet(init: unknown, node: AstNode): CodeModeSet {
|
||||
const target = new CodeModeSet()
|
||||
if (init === undefined || init === null) return target
|
||||
const items = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxSet
|
||||
: init instanceof CodeModeSet
|
||||
? Array.from(init.set.values())
|
||||
: typeof init === "string"
|
||||
? Array.from(init)
|
||||
@@ -1097,7 +1097,7 @@ export class Interpreter<R> {
|
||||
return target
|
||||
}
|
||||
|
||||
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
|
||||
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
|
||||
"TypeError",
|
||||
@@ -1106,7 +1106,7 @@ export class Interpreter<R> {
|
||||
const input = urlArgument(args[0], "new URL input")
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
|
||||
try {
|
||||
return new SandboxURL(new URL(input, base))
|
||||
return new CodeModeURL(new URL(input, base))
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
@@ -1115,16 +1115,16 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
|
||||
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
if (init instanceof SandboxURLSearchParams) {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(init.params))
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams {
|
||||
if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
if (init instanceof CodeModeURLSearchParams) {
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(init.params))
|
||||
}
|
||||
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
|
||||
if (typeof init === "string") return new CodeModeURLSearchParams(new URLSearchParams(init))
|
||||
if (init === null || typeof init === "number" || typeof init === "boolean") {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init)))
|
||||
}
|
||||
if (init instanceof SandboxMap) {
|
||||
if (init instanceof CodeModeMap) {
|
||||
return this.constructURLSearchParams(
|
||||
Array.from(init.map.entries(), ([key, value]) => [key, value]),
|
||||
node,
|
||||
@@ -1143,9 +1143,9 @@ export class Interpreter<R> {
|
||||
string,
|
||||
]
|
||||
})
|
||||
return new SandboxURLSearchParams(new URLSearchParams(entries))
|
||||
return new CodeModeURLSearchParams(new URLSearchParams(entries))
|
||||
}
|
||||
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
@@ -1153,7 +1153,7 @@ export class Interpreter<R> {
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new SandboxURLSearchParams(
|
||||
return new CodeModeURLSearchParams(
|
||||
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
|
||||
)
|
||||
}
|
||||
@@ -1176,7 +1176,7 @@ export class Interpreter<R> {
|
||||
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
|
||||
// Dates use string coercion for `+` and epoch time elsewhere.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
if (operand instanceof CodeModeDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
}
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
@@ -1261,7 +1261,7 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
const operand =
|
||||
value instanceof SandboxDate
|
||||
value instanceof CodeModeDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
@@ -1520,11 +1520,11 @@ export class Interpreter<R> {
|
||||
})
|
||||
if (!fn.async) return run
|
||||
// The initial yield assigns `box.own` before the body can self-resolve.
|
||||
const box: { own?: SandboxPromise } = {}
|
||||
const box: { own?: CodeModePromise } = {}
|
||||
return Effect.map(
|
||||
this.createPromise(
|
||||
Effect.flatMap(run, (value) => {
|
||||
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
|
||||
if (!(value instanceof CodeModePromise)) return Effect.succeed(value)
|
||||
if (value === box.own) return Effect.fail(selfResolutionError())
|
||||
return invocation.settlePromise(value)
|
||||
}),
|
||||
@@ -1546,7 +1546,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
|
||||
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object spread requires a data object in CodeMode.",
|
||||
@@ -1748,28 +1748,28 @@ export class Interpreter<R> {
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
if (objectValue instanceof SandboxDate) {
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxRegExp) {
|
||||
if (objectValue instanceof CodeModeRegExp) {
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
}
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxMap) {
|
||||
if (objectValue instanceof CodeModeMap) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxSet) {
|
||||
if (objectValue instanceof CodeModeSet) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURL) {
|
||||
if (objectValue instanceof CodeModeURL) {
|
||||
if (key === "searchParams") {
|
||||
return new ComputedValue(objectValue.searchParams)
|
||||
}
|
||||
@@ -1777,7 +1777,7 @@ export class Interpreter<R> {
|
||||
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURLSearchParams) {
|
||||
if (objectValue instanceof CodeModeURLSearchParams) {
|
||||
if (key === "size") return new ComputedValue(objectValue.params.size)
|
||||
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
|
||||
return new IntrinsicReference(objectValue, key)
|
||||
@@ -1786,7 +1786,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (objectValue instanceof SandboxPromise) {
|
||||
if (objectValue instanceof CodeModePromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
return new PromiseInstanceMethodReference(objectValue, key)
|
||||
}
|
||||
@@ -1851,7 +1851,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
|
||||
}
|
||||
return reference.target[String(reference.key)]
|
||||
@@ -1891,7 +1891,7 @@ export class Interpreter<R> {
|
||||
}
|
||||
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
|
||||
const current =
|
||||
reference.target instanceof SandboxURL
|
||||
reference.target instanceof CodeModeURL
|
||||
? (reference.target.url as unknown as Record<string, unknown>)[key]
|
||||
: (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
const { write, next, result } = yield* compute(current)
|
||||
@@ -1915,7 +1915,7 @@ export class Interpreter<R> {
|
||||
target[index] = next
|
||||
return
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
if (reference.target instanceof CodeModeURL) {
|
||||
const property = key as string
|
||||
if (!urlWritableProperties.has(property)) {
|
||||
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
|
||||
|
||||
@@ -43,9 +43,9 @@ export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "
|
||||
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string") return Array.from(value)
|
||||
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof SandboxSet) return Array.from(value.set.values())
|
||||
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof CodeModeSet) return Array.from(value.set.values())
|
||||
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
||||
return undefined
|
||||
}
|
||||
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -34,14 +34,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (value instanceof SandboxURL) return coerceToString(value)
|
||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
||||
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof CodeModeDate) return coerceToString(value)
|
||||
if (value instanceof CodeModeRegExp) return coerceToString(value)
|
||||
if (value instanceof CodeModeURL) return coerceToString(value)
|
||||
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
if (value instanceof CodeModeMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
@@ -50,7 +50,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
if (value instanceof CodeModeSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
@@ -100,14 +100,14 @@ const consoleTableRows = (
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
|
||||
@@ -36,7 +36,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
|
||||
export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNode): unknown => {
|
||||
const hosted = new Date(value.time)
|
||||
switch (name) {
|
||||
case "getTime":
|
||||
@@ -88,5 +88,5 @@ export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { SandboxDate } from "../values.js"
|
||||
import { CodeModeDate } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
@@ -9,8 +9,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
|
||||
if (isSandboxValue(input)) return {}
|
||||
if (input instanceof SandboxPromise) {
|
||||
if (isCodeModeValue(input)) return {}
|
||||
if (input instanceof CodeModePromise) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
|
||||
node,
|
||||
@@ -46,12 +46,12 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const target = args[0]
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) {
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isSandboxValue(source)) continue
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
@@ -60,17 +60,17 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
return out
|
||||
}
|
||||
case "fromEntries": {
|
||||
if (args[0] instanceof SandboxMap) {
|
||||
if (args[0] instanceof CodeModeMap) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
|
||||
return out
|
||||
}
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
if (args[0] instanceof CodeModeURLSearchParams) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
||||
return out
|
||||
}
|
||||
const pairs = args[0] instanceof SandboxSet ? Array.from(args[0].set.values()) : args[0]
|
||||
const pairs = args[0] instanceof CodeModeSet ? Array.from(args[0].set.values()) : args[0]
|
||||
if (!Array.isArray(pairs)) {
|
||||
boundedData(args[0], "Object.fromEntries input")
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
@@ -78,7 +78,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
const validated = boundedData(pair, "Object.fromEntries entry")
|
||||
if (validated === null || typeof validated !== "object" || isSandboxValue(validated))
|
||||
if (validated === null || typeof validated !== "object" || isCodeModeValue(validated))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
|
||||
const entry = pair as Record<string, unknown>
|
||||
addEntry(out, entry[0], entry[1])
|
||||
|
||||
@@ -19,7 +19,7 @@ export const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
if (arg instanceof SandboxRegExp) return arg.regex
|
||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
@@ -50,7 +50,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||
}
|
||||
|
||||
export const invokeRegExpMethod = (
|
||||
value: SandboxRegExp,
|
||||
value: CodeModeRegExp,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
@@ -70,5 +70,5 @@ export const invokeRegExpMethod = (
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { SandboxRegExp } from "../values.js"
|
||||
import { CodeModeRegExp } from "../values.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
|
||||
}
|
||||
|
||||
export const urlArgument = (value: unknown, label: string): string =>
|
||||
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
|
||||
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
|
||||
|
||||
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
|
||||
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
|
||||
try {
|
||||
const url = new URL(input, base)
|
||||
return name === "canParse" ? true : new SandboxURL(url)
|
||||
return name === "canParse" ? true : new CodeModeURL(url)
|
||||
} catch {
|
||||
return name === "canParse" ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
|
||||
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
|
||||
if (name === "toString" || name === "toJSON") return value.url.href
|
||||
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { SandboxURL } from "../values.js"
|
||||
import { CodeModeURL } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
|
||||
export const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (value instanceof SandboxDate)
|
||||
if (value instanceof CodeModeDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof SandboxMap) return "[object Map]"
|
||||
if (value instanceof SandboxSet) return "[object Set]"
|
||||
if (value instanceof SandboxURL) return value.url.href
|
||||
if (value instanceof SandboxURLSearchParams) return value.params.toString()
|
||||
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof CodeModeMap) return "[object Map]"
|
||||
if (value instanceof CodeModeSet) return "[object Set]"
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
|
||||
if (typeof value === "object") {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
@@ -50,14 +50,14 @@ export const coerceToString = (value: unknown): string => {
|
||||
}
|
||||
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof SandboxDate) return value.time
|
||||
if (isSandboxValue(value)) return Number.NaN
|
||||
if (value instanceof CodeModeDate) return value.time
|
||||
if (isCodeModeValue(value)) return Number.NaN
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const raw = args[0]
|
||||
if (isSandboxValue(raw)) {
|
||||
if (isCodeModeValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
@@ -80,11 +80,11 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
isCodeModeValue,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
} from "./tool-schema.js"
|
||||
import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
|
||||
import {
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
CodeModeDate,
|
||||
CodeModeMap,
|
||||
CodeModePromise,
|
||||
CodeModeRegExp,
|
||||
CodeModeSet,
|
||||
CodeModeURL,
|
||||
CodeModeURLSearchParams,
|
||||
} from "./values.js"
|
||||
|
||||
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
|
||||
@@ -141,16 +141,16 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
// Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
|
||||
export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
|
||||
|
||||
const copyBounded = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
depth: number,
|
||||
seen: Set<object>,
|
||||
preserveSandboxValues: boolean,
|
||||
preserveCodeModeValues: boolean,
|
||||
): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
@@ -169,55 +169,55 @@ const copyBounded = (
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
if (value instanceof SandboxPromise) {
|
||||
if (value instanceof CodeModePromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (preserveSandboxValues) {
|
||||
if (preserveCodeModeValues) {
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURL ||
|
||||
value instanceof SandboxURLSearchParams
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||
if (value instanceof Date) return new CodeModeDate(value.getTime())
|
||||
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new SandboxMap()
|
||||
const wrapped = new CodeModeMap()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new SandboxSet()
|
||||
const wrapped = new CodeModeSet()
|
||||
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof URL) return new SandboxURL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
|
||||
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
|
||||
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
|
||||
}
|
||||
|
||||
if (value instanceof SandboxDate) {
|
||||
if (value instanceof CodeModeDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (value instanceof SandboxURL) return value.url.href
|
||||
if (value instanceof CodeModeURL) return value.url.href
|
||||
if (value instanceof URL) return value.href
|
||||
if (
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURLSearchParams ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURLSearchParams ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set ||
|
||||
@@ -233,8 +233,8 @@ const copyBounded = (
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||
if (preserveSandboxValues) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
|
||||
if (preserveCodeModeValues) {
|
||||
// Checkpoint copies retain array metadata that boundary copies omit.
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (Object.hasOwn(copied, key)) continue
|
||||
@@ -258,7 +258,7 @@ const copyBounded = (
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveCodeModeValues)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import type { Fiber } from "effect"
|
||||
|
||||
export class SandboxPromise {
|
||||
export class CodeModePromise {
|
||||
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
|
||||
}
|
||||
|
||||
export class SandboxDate {
|
||||
export class CodeModeDate {
|
||||
constructor(readonly time: number) {}
|
||||
}
|
||||
|
||||
export class SandboxRegExp {
|
||||
export class CodeModeRegExp {
|
||||
readonly regex: RegExp
|
||||
constructor(pattern: string, flags: string) {
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxMap {
|
||||
export class CodeModeMap {
|
||||
readonly map = new Map<unknown, unknown>()
|
||||
}
|
||||
|
||||
export class SandboxSet {
|
||||
export class CodeModeSet {
|
||||
readonly set = new Set<unknown>()
|
||||
}
|
||||
|
||||
export class SandboxURLSearchParams {
|
||||
export class CodeModeURLSearchParams {
|
||||
constructor(readonly params: URLSearchParams) {}
|
||||
}
|
||||
|
||||
export class SandboxURL {
|
||||
readonly searchParams: SandboxURLSearchParams
|
||||
export class CodeModeURL {
|
||||
readonly searchParams: CodeModeURLSearchParams
|
||||
constructor(readonly url: URL) {
|
||||
this.searchParams = new SandboxURLSearchParams(url.searchParams)
|
||||
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
|
||||
}
|
||||
}
|
||||
|
||||
export const isSandboxValue = (
|
||||
export const isCodeModeValue = (
|
||||
value: unknown,
|
||||
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof SandboxURL ||
|
||||
value instanceof SandboxURLSearchParams
|
||||
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
|
||||
value instanceof CodeModeDate ||
|
||||
value instanceof CodeModeRegExp ||
|
||||
value instanceof CodeModeMap ||
|
||||
value instanceof CodeModeSet ||
|
||||
value instanceof CodeModeURL ||
|
||||
value instanceof CodeModeURLSearchParams
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("CodeMode console capture", () => {
|
||||
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
|
||||
})
|
||||
|
||||
test("renders sandbox values nested inside logged containers", async () => {
|
||||
test("renders CodeMode values nested inside logged containers", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
@@ -311,7 +311,7 @@ describe("CodeMode console capture", () => {
|
||||
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}'])
|
||||
})
|
||||
|
||||
test("console.table renders sandbox value cells", async () => {
|
||||
test("console.table renders CodeMode value cells", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
|
||||
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds.
|
||||
//
|
||||
// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when
|
||||
// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox
|
||||
// it crosses out of CodeMode (results are JSON data), so tests asserting an in-CodeMode
|
||||
// `undefined` read check `=== undefined` inside the program and `null` at the boundary.
|
||||
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
const value = async (code: string) => {
|
||||
@@ -108,7 +108,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0)
|
||||
})
|
||||
|
||||
test("a non-finite value becomes null when it leaves the sandbox", async () => {
|
||||
test("a non-finite value becomes null when it leaves CodeMode", async () => {
|
||||
expect(await value(`return 5/0`)).toBeNull()
|
||||
expect(await value(`return 0/0`)).toBeNull()
|
||||
expect(await value(`return Math.max()`)).toBeNull()
|
||||
@@ -116,12 +116,12 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] })
|
||||
})
|
||||
|
||||
test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => {
|
||||
test("NaN and Infinity are usable identifiers and inspectable in-CodeMode", async () => {
|
||||
expect(await value(`return Number.isNaN(NaN)`)).toBe(true)
|
||||
expect(await value(`return Infinity > 1e9`)).toBe(true)
|
||||
expect(await value(`return Number.isFinite(1/0)`)).toBe(false)
|
||||
expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3)
|
||||
// JSON.stringify inside the sandbox matches JS: non-finite serializes to null
|
||||
// JSON.stringify inside CodeMode matches JS: non-finite serializes to null
|
||||
expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}')
|
||||
})
|
||||
|
||||
@@ -321,12 +321,12 @@ describe("compound assignment matches its binary operator", () => {
|
||||
return a
|
||||
}
|
||||
|
||||
test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
|
||||
test("CodeMode Date += concatenates its string form, like d = d + 1", async () => {
|
||||
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`)
|
||||
expect(result).toBe("1970-01-01T00:00:01.000Z1")
|
||||
})
|
||||
|
||||
test("sandbox Date numeric compound ops use its time value", async () => {
|
||||
test("CodeMode Date numeric compound ops use its time value", async () => {
|
||||
expect(
|
||||
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
|
||||
).toBe(600)
|
||||
|
||||
@@ -212,7 +212,7 @@ describe("Test262 Promise statics", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("Promise.resolve adopts values and preserves sandbox-promise identity", async () => {
|
||||
test("Promise.resolve adopts values and preserves CodeMode-promise identity", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
|
||||
// test/built-ins/Promise/resolve/resolve-non-obj.js
|
||||
@@ -289,7 +289,7 @@ describe("Test262 Promise statics", () => {
|
||||
// test/built-ins/Promise/all/reject-immed.js
|
||||
// test/built-ins/Promise/allSettled/reject-immed.js
|
||||
// test/built-ins/Promise/race/reject-immed.js
|
||||
// (adapted: immediately-rejecting thenables become sandbox promises that settled,
|
||||
// (adapted: immediately-rejecting thenables become CodeMode promises that settled,
|
||||
// and were even observed, before the combinator call)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -360,7 +360,7 @@ describe("Test262 Promise statics", () => {
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A2.1_T1.js
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A5.1_T1.js
|
||||
// (adapted: upstream requires Promise.race([]) to never settle; CodeMode intentionally
|
||||
// rejects with a catchable diagnostic instead of hanging, so this asserts the sandbox
|
||||
// rejects with a catchable diagnostic instead of hanging, so this asserts CodeMode
|
||||
// divergence rather than the spec never-settles behavior)
|
||||
expect(
|
||||
await value(`
|
||||
@@ -375,7 +375,7 @@ describe("Test262 Promise statics", () => {
|
||||
).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("Promise.resolve passes the same sandbox promise through nested chains", async () => {
|
||||
test("Promise.resolve passes the same CodeMode promise through nested chains", async () => {
|
||||
// Source: test/built-ins/Promise/resolve/S25.4.4.5_A2.2_T1.js
|
||||
// (adapted: no executor construction, and identity is observed with Array includes
|
||||
// because promises are not comparable data values in CodeMode)
|
||||
@@ -1208,7 +1208,7 @@ describe("Test262 AggregateError", () => {
|
||||
|
||||
test("coerces a non-string message to a string", async () => {
|
||||
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the
|
||||
// upstream object-with-toString case is omitted because the sandbox has no user toString dispatch)
|
||||
// upstream object-with-toString case is omitted because CodeMode has no user toString dispatch)
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
@@ -1408,7 +1408,7 @@ describe("Test262 Promise constructor", () => {
|
||||
|
||||
test.failing("calling Promise without new throws TypeError", async () => {
|
||||
// Source: test/built-ins/Promise/undefined-newtarget.js
|
||||
// The sandbox currently reports a generic Error ("Only tools are callable in CodeMode.").
|
||||
// CodeMode currently reports a generic Error ("Only tools are callable in CodeMode.").
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
|
||||
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
|
||||
@@ -17,18 +17,41 @@ type Trace = {
|
||||
|
||||
const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 })
|
||||
|
||||
/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */
|
||||
const sleepyTool = (trace: Trace) =>
|
||||
/**
|
||||
* Deterministic tool set: ordering and interruption are structural, never temporal.
|
||||
*
|
||||
* - `echo` settles immediately with its id.
|
||||
* - `gated` blocks until `open` releases the same id. Tool fibers start eagerly at the
|
||||
* call site, so several gated calls are provably live at once before any `open` runs.
|
||||
* - `pending` never settles; tests assert its interruption instead of racing a timer.
|
||||
*
|
||||
* Real clocks remain only in the wall-clock timeout tests (`timeoutMs`, `stubborn`
|
||||
* cleanup), where elapsed time is the behavior under test.
|
||||
*/
|
||||
const echoTool = (trace: Trace) =>
|
||||
Tool.make({
|
||||
description: "Echo an id after a delay",
|
||||
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
|
||||
description: "Echo an id immediately",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id, ms }) =>
|
||||
run: ({ id }) =>
|
||||
Effect.sync(() => {
|
||||
trace.starts.push(id)
|
||||
trace.completed += 1
|
||||
return id
|
||||
}),
|
||||
})
|
||||
|
||||
const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred<void>) =>
|
||||
Tool.make({
|
||||
description: "Echo an id once its gate opens",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id }) =>
|
||||
Effect.gen(function* () {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
||||
yield* Effect.sleep(ms ?? 20)
|
||||
yield* Deferred.await(gate(id))
|
||||
trace.active -= 1
|
||||
trace.completed += 1
|
||||
return id
|
||||
@@ -42,6 +65,35 @@ const sleepyTool = (trace: Trace) =>
|
||||
),
|
||||
})
|
||||
|
||||
const openTool = (gate: (id: number) => Deferred.Deferred<void>) =>
|
||||
Tool.make({
|
||||
description: "Open the gate for an id",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Boolean,
|
||||
run: ({ id }) => Deferred.succeed(gate(id), undefined),
|
||||
})
|
||||
|
||||
const pendingTool = (trace: Trace) =>
|
||||
Tool.make({
|
||||
description: "Never settle",
|
||||
input: Schema.Struct({ id: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
run: ({ id }) =>
|
||||
Effect.gen(function* () {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
||||
return yield* Effect.never
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
trace.active -= 1
|
||||
trace.interrupted += 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const failingTool = Tool.make({
|
||||
description: "Always refuse",
|
||||
input: Schema.Struct({}),
|
||||
@@ -58,7 +110,7 @@ const interruptedTool = Tool.make({
|
||||
|
||||
const completedTool = (trace: Trace) =>
|
||||
Tool.make({
|
||||
description: "Return the number of completed sleepy calls",
|
||||
description: "Return the number of completed calls",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(trace.completed),
|
||||
@@ -88,11 +140,22 @@ const run = (
|
||||
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
|
||||
): Promise<CodeMode.Result> => {
|
||||
const trace = options.trace ?? makeTrace()
|
||||
const gates = new Map<number, Deferred.Deferred<void>>()
|
||||
const gate = (id: number): Deferred.Deferred<void> => {
|
||||
const existing = gates.get(id)
|
||||
if (existing) return existing
|
||||
const created = Deferred.makeUnsafe<void>()
|
||||
gates.set(id, created)
|
||||
return created
|
||||
}
|
||||
return Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: {
|
||||
host: {
|
||||
sleepy: sleepyTool(trace),
|
||||
echo: echoTool(trace),
|
||||
gated: gatedTool(trace, gate),
|
||||
open: openTool(gate),
|
||||
pending: pendingTool(trace),
|
||||
fail: failingTool,
|
||||
interrupt: interruptedTool,
|
||||
completed: completedTool(trace),
|
||||
@@ -122,7 +185,7 @@ describe("first-class promise values", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const load = async (id) => {
|
||||
const result = await tools.host.sleepy({ id, ms: 20 })
|
||||
const result = await tools.host.echo({ id })
|
||||
return [id, result]
|
||||
}
|
||||
const first = load(1)
|
||||
@@ -158,8 +221,10 @@ describe("first-class promise values", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
const a = tools.host.sleepy({ id: 1, ms: 40 })
|
||||
const b = tools.host.sleepy({ id: 2, ms: 40 })
|
||||
const a = tools.host.gated({ id: 1 })
|
||||
const b = tools.host.gated({ id: 2 })
|
||||
await tools.host.open({ id: 1 })
|
||||
await tools.host.open({ id: 2 })
|
||||
const rb = await b
|
||||
const ra = await a
|
||||
return [ra, rb]
|
||||
@@ -174,7 +239,7 @@ describe("first-class promise values", () => {
|
||||
|
||||
test("awaiting the same promise twice settles once and never re-runs the call", async () => {
|
||||
const result = await run(`
|
||||
const p = tools.host.sleepy({ id: 7 })
|
||||
const p = tools.host.echo({ id: 7 })
|
||||
const x = await p
|
||||
const y = await p
|
||||
return [x, y]
|
||||
@@ -182,7 +247,7 @@ describe("first-class promise values", () => {
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toEqual([7, 7])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
|
||||
})
|
||||
|
||||
test("await of a non-promise value passes it through unchanged", async () => {
|
||||
@@ -193,7 +258,7 @@ describe("first-class promise values", () => {
|
||||
})
|
||||
|
||||
test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => {
|
||||
expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9)
|
||||
expect(await value(`return tools.host.echo({ id: 9 })`)).toBe(9)
|
||||
})
|
||||
|
||||
test("typeof a promise is 'object', and console.log renders it sensibly", async () => {
|
||||
@@ -228,7 +293,7 @@ describe("first-class promise values", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
tools.host.sleepy({ id: 1, ms: 30 })
|
||||
tools.host.pending({ id: 1 })
|
||||
return "done"
|
||||
`,
|
||||
{ trace },
|
||||
@@ -307,7 +372,7 @@ describe("first-class promise values", () => {
|
||||
const result = await run(
|
||||
`
|
||||
const run = async () => {
|
||||
await tools.host.sleepy({ id: 1, ms: 60000 })
|
||||
await tools.host.pending({ id: 1 })
|
||||
tools.host.fail({})
|
||||
}
|
||||
run()
|
||||
@@ -373,7 +438,7 @@ describe("first-class promise values", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
tools.host.sleepy({ id: 1, ms: 1_000 })
|
||||
tools.host.pending({ id: 1 })
|
||||
throw new Error("boom")
|
||||
`,
|
||||
{ trace },
|
||||
@@ -392,8 +457,8 @@ describe("first-class promise values", () => {
|
||||
await value(
|
||||
`
|
||||
const launch = async () => {
|
||||
tools.host.sleepy({ id: 1, ms: 60000 })
|
||||
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })])
|
||||
tools.host.pending({ id: 1 })
|
||||
Promise.all([tools.host.pending({ id: 2 })])
|
||||
return "returned"
|
||||
}
|
||||
return await launch()
|
||||
@@ -411,7 +476,7 @@ describe("first-class promise values", () => {
|
||||
|
||||
describe("promises at data boundaries", () => {
|
||||
test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => {
|
||||
const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`)
|
||||
const diagnostic = await error(`return { result: tools.host.echo({ id: 1 }) }`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
expect(diagnostic.message).toContain("un-awaited Promise")
|
||||
expect(diagnostic.message).toContain("await tools.ns.tool(...)")
|
||||
@@ -427,7 +492,7 @@ describe("promises at data boundaries", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
|
||||
const pending = tools.host.pending({ id: 1 })
|
||||
return { pending }
|
||||
`,
|
||||
{ trace, limits: { timeoutMs: 100 } },
|
||||
@@ -440,7 +505,7 @@ describe("promises at data boundaries", () => {
|
||||
})
|
||||
|
||||
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
|
||||
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
|
||||
const diagnostic = await error(`return await tools.host.echo({ id: tools.host.echo({ id: 1 }) })`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
expect(diagnostic.message).toContain("un-awaited Promise")
|
||||
})
|
||||
@@ -475,8 +540,10 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
|
||||
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
|
||||
const first = Promise.all([tools.host.gated({ id: 1 })])
|
||||
const second = Promise.all([tools.host.gated({ id: 2 })])
|
||||
await tools.host.open({ id: 1 })
|
||||
await tools.host.open({ id: 2 })
|
||||
return [await first, await second]
|
||||
`,
|
||||
{ trace },
|
||||
@@ -502,19 +569,19 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
|
||||
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
|
||||
const result = await run(`
|
||||
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
|
||||
const aggregate = Promise.all([tools.host.echo({ id: 7 })])
|
||||
return [await aggregate, await aggregate]
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toEqual([[7], [7]])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.echo" }])
|
||||
})
|
||||
|
||||
test("mixes promises and plain values, preserving order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
|
||||
return await Promise.all([tools.host.echo({ id: 1 }), "plain", tools.host.echo({ id: 2 }), 42])
|
||||
`),
|
||||
).toEqual([1, "plain", 2, 42])
|
||||
})
|
||||
@@ -523,9 +590,9 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const calls = []
|
||||
calls.push(tools.host.sleepy({ id: 1 }))
|
||||
calls.push(tools.host.echo({ id: 1 }))
|
||||
calls.push(7)
|
||||
const more = [tools.host.sleepy({ id: 2 })]
|
||||
const more = [tools.host.echo({ id: 2 })]
|
||||
const batch = [...calls, ...more, "x"]
|
||||
return await Promise.all(batch)
|
||||
`),
|
||||
@@ -537,7 +604,9 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
const result = await value(
|
||||
`
|
||||
const ids = [1, 2, 3, 4]
|
||||
return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 })))
|
||||
const calls = ids.map((id) => tools.host.gated({ id }))
|
||||
for (const id of ids) await tools.host.open({ id })
|
||||
return await Promise.all(calls)
|
||||
`,
|
||||
{ trace },
|
||||
)
|
||||
@@ -552,7 +621,9 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
const result = await value(
|
||||
`
|
||||
const ids = [1, 2, 3, 4]
|
||||
return await Promise.all(ids.map(async (id) => await tools.host.sleepy({ id, ms: 40 })))
|
||||
const calls = ids.map(async (id) => await tools.host.gated({ id }))
|
||||
for (const id of ids) await tools.host.open({ id })
|
||||
return await Promise.all(calls)
|
||||
`,
|
||||
{ trace },
|
||||
)
|
||||
@@ -566,7 +637,9 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
`
|
||||
const ids = []
|
||||
for (let i = 0; i < 20; i += 1) ids.push(i)
|
||||
const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 })))
|
||||
const calls = ids.map((id) => tools.host.gated({ id }))
|
||||
for (const id of ids) await tools.host.open({ id })
|
||||
const results = await Promise.all(calls)
|
||||
return results.length
|
||||
`,
|
||||
{ trace },
|
||||
@@ -582,7 +655,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
test("rejects with the first failure, catchable in-program", async () => {
|
||||
const result = await run(`
|
||||
try {
|
||||
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
||||
await Promise.all([tools.host.echo({ id: 1 }), tools.host.fail({})])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
@@ -601,7 +674,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
`
|
||||
try {
|
||||
await Promise.all([
|
||||
tools.host.sleepy({ id: 1, ms: 100 }),
|
||||
tools.host.pending({ id: 1 }),
|
||||
tools.host.fail({}),
|
||||
])
|
||||
return -1
|
||||
@@ -623,11 +696,12 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const slow = tools.host.sleepy({ id: 1, ms: 40 })
|
||||
const slow = tools.host.gated({ id: 1 })
|
||||
try {
|
||||
await Promise.all([slow, tools.host.fail({})])
|
||||
return "no"
|
||||
} catch {}
|
||||
await tools.host.open({ id: 1 })
|
||||
return await slow
|
||||
`,
|
||||
{ trace },
|
||||
@@ -643,7 +717,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
await value(
|
||||
`
|
||||
const failLater = async () => {
|
||||
await tools.host.sleepy({ id: 1, ms: 40 })
|
||||
await tools.host.pending({ id: 1 })
|
||||
throw new Error("later")
|
||||
}
|
||||
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
|
||||
@@ -668,7 +742,7 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
|
||||
test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
|
||||
const diagnostic = await error(
|
||||
`return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`,
|
||||
`return await Promise.all([tools.host.echo({ id: 1 }), tools.host.echo({ id: 2 }), tools.host.echo({ id: 3 })])`,
|
||||
{ limits: { maxToolCalls: 2 } },
|
||||
)
|
||||
expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
|
||||
@@ -680,7 +754,7 @@ describe("Promise.allSettled", () => {
|
||||
expect(
|
||||
await value(`
|
||||
return await Promise.allSettled([
|
||||
tools.host.sleepy({ id: 5 }),
|
||||
tools.host.echo({ id: 5 }),
|
||||
tools.host.fail({}),
|
||||
"plain",
|
||||
Promise.reject(new Error("boom")),
|
||||
@@ -711,8 +785,8 @@ describe("Promise.race", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||
const fast = tools.host.echo({ id: 1 })
|
||||
const slow = tools.host.pending({ id: 2 })
|
||||
return await Promise.race([fast, slow])
|
||||
`,
|
||||
{ trace },
|
||||
@@ -726,9 +800,10 @@ describe("Promise.race", () => {
|
||||
test("a direct loser remains awaitable after the race settles", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||
const fast = tools.host.echo({ id: 1 })
|
||||
const slow = tools.host.gated({ id: 2 })
|
||||
const winner = await Promise.race([fast, slow])
|
||||
await tools.host.open({ id: 2 })
|
||||
return { winner, loser: await slow }
|
||||
`),
|
||||
).toEqual({ winner: 1, loser: 2 })
|
||||
@@ -740,8 +815,8 @@ describe("Promise.race", () => {
|
||||
await value(
|
||||
`
|
||||
const nested = Promise.all([
|
||||
tools.host.sleepy({ id: 1, ms: 40 }),
|
||||
tools.host.sleepy({ id: 2, ms: 40 }),
|
||||
tools.host.pending({ id: 1 }),
|
||||
tools.host.pending({ id: 2 }),
|
||||
])
|
||||
return await Promise.race(["immediate", nested])
|
||||
`,
|
||||
@@ -757,7 +832,7 @@ describe("Promise.race", () => {
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
|
||||
await Promise.race([tools.host.fail({}), tools.host.pending({ id: 1 })])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
@@ -768,9 +843,9 @@ describe("Promise.race", () => {
|
||||
|
||||
test("a plain value wins over pending promises", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
|
||||
).toBe("immediate")
|
||||
expect(await value(`return await Promise.race([tools.host.pending({ id: 1 }), "immediate"])`, { trace })).toBe(
|
||||
"immediate",
|
||||
)
|
||||
expect(trace.completed).toBe(0)
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
@@ -793,7 +868,7 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||
test("resolve wraps plain values and passes promises through", async () => {
|
||||
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
||||
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
|
||||
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
|
||||
expect(await value(`return await Promise.resolve(tools.host.echo({ id: 3 }))`)).toBe(3)
|
||||
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
|
||||
true,
|
||||
)
|
||||
@@ -816,7 +891,7 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const rejected = Promise.reject(new Error("handled"))
|
||||
await tools.host.sleepy({ id: 1 })
|
||||
await tools.host.echo({ id: 1 })
|
||||
try {
|
||||
await rejected
|
||||
return "no"
|
||||
@@ -846,8 +921,8 @@ describe("timeout interruption of forked calls", () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
const a = tools.host.sleepy({ id: 1, ms: 60000 })
|
||||
const b = tools.host.sleepy({ id: 2, ms: 60000 })
|
||||
const a = tools.host.pending({ id: 1 })
|
||||
const b = tools.host.pending({ id: 2 })
|
||||
return await a
|
||||
`,
|
||||
{ trace, limits: { timeoutMs: 100 } },
|
||||
@@ -864,7 +939,7 @@ describe("timeout interruption of forked calls", () => {
|
||||
test("the timeout also interrupts calls inside Promise.all", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`,
|
||||
`return await Promise.all([tools.host.pending({ id: 1 }), tools.host.pending({ id: 2 })])`,
|
||||
{ trace, limits: { timeoutMs: 100 } },
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
@@ -875,7 +950,7 @@ describe("timeout interruption of forked calls", () => {
|
||||
|
||||
test("a non-settling race loser cannot hold the execution to the timeout", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
|
||||
const result = await run(`return await Promise.race(["winner", tools.host.pending({ id: 1 })])`, {
|
||||
trace,
|
||||
limits: { timeoutMs: 100 },
|
||||
})
|
||||
@@ -940,8 +1015,8 @@ describe("promise chaining", () => {
|
||||
expect(
|
||||
await value(`
|
||||
return await tools.host
|
||||
.sleepy({ id: 2 })
|
||||
.then((id) => tools.host.sleepy({ id: id + 1 }))
|
||||
.echo({ id: 2 })
|
||||
.then((id) => tools.host.echo({ id: id + 1 }))
|
||||
.then((id) => id * 10)
|
||||
`),
|
||||
).toBe(30)
|
||||
@@ -966,7 +1041,7 @@ describe("promise chaining", () => {
|
||||
await value(`
|
||||
return [
|
||||
await tools.host.fail({}).catch((error) => error.message),
|
||||
await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
|
||||
await tools.host.echo({ id: 4 }).catch(() => "unused"),
|
||||
]
|
||||
`),
|
||||
).toEqual(["Lookup refused", 4])
|
||||
@@ -976,7 +1051,7 @@ describe("promise chaining", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = []
|
||||
const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
|
||||
const result = await tools.host.echo({ id: 5 }).finally(() => events.push("cleanup"))
|
||||
return [result, events]
|
||||
`),
|
||||
).toEqual([5, ["cleanup"]])
|
||||
@@ -1009,12 +1084,12 @@ describe("promise chaining", () => {
|
||||
})
|
||||
|
||||
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
|
||||
const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
|
||||
const diagnostic = await error(`return await tools.host.echo({ id: 1 }).then(tools.host.completed)`)
|
||||
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
|
||||
})
|
||||
|
||||
test("chaining methods are opaque references until called", async () => {
|
||||
expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
|
||||
expect(await value(`return typeof tools.host.echo({ id: 1 }).then`)).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1039,7 +1114,7 @@ describe("combinator settlement timing", () => {
|
||||
// cannot beat it into rejection.
|
||||
expect(
|
||||
await value(`
|
||||
const pending = tools.host.sleepy({ id: 9, ms: 60000 })
|
||||
const pending = tools.host.pending({ id: 9 })
|
||||
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
|
||||
try {
|
||||
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
|
||||
@@ -1054,7 +1129,7 @@ describe("combinator settlement timing", () => {
|
||||
|
||||
describe("unsupported promise surface", () => {
|
||||
test("other property reads on a promise hint at the missing await", async () => {
|
||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
|
||||
const diagnostic = await error(`return tools.host.echo({ id: 1 }).value`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
expect(diagnostic.message).toContain("un-awaited Promise")
|
||||
expect(diagnostic.message).toContain("await it first")
|
||||
@@ -1074,8 +1149,8 @@ describe("Promise.any", () => {
|
||||
`
|
||||
const winner = await Promise.any([
|
||||
tools.host.fail({}),
|
||||
tools.host.sleepy({ id: 1, ms: 5 }),
|
||||
tools.host.sleepy({ id: 2, ms: 60000 }),
|
||||
tools.host.echo({ id: 1 }),
|
||||
tools.host.pending({ id: 2 }),
|
||||
])
|
||||
return winner
|
||||
`,
|
||||
@@ -1141,7 +1216,7 @@ describe("promise construction", () => {
|
||||
const id = await gate
|
||||
return id * 2
|
||||
})()
|
||||
openGate(await tools.host.sleepy({ id: 21, ms: 5 }))
|
||||
openGate(await tools.host.echo({ id: 21 }))
|
||||
return await worker
|
||||
`),
|
||||
).toBe(42)
|
||||
@@ -1151,7 +1226,7 @@ describe("promise construction", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const bridged = new Promise((resolve, reject) => {
|
||||
tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject)
|
||||
tools.host.echo({ id: 7 }).then(resolve, reject)
|
||||
})
|
||||
return await bridged
|
||||
`),
|
||||
@@ -1163,7 +1238,7 @@ describe("promise construction", () => {
|
||||
await value(`
|
||||
let settle
|
||||
const manual = new Promise((resolve) => { settle = resolve })
|
||||
const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })])
|
||||
const race = Promise.race([manual, tools.host.pending({ id: 3 })])
|
||||
const all = Promise.all([manual, "plain"])
|
||||
const any = Promise.any([manual, new Promise(() => {})])
|
||||
settle("manual")
|
||||
@@ -1193,7 +1268,7 @@ describe("promise construction", () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = new Promise(async (resolve) => {
|
||||
const id = await tools.host.sleepy({ id: 5, ms: 5 })
|
||||
const id = await tools.host.echo({ id: 5 })
|
||||
resolve(id * 2)
|
||||
})
|
||||
return await result
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
|
||||
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
|
||||
// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
|
||||
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
|
||||
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
|
||||
@@ -69,7 +69,7 @@ describe("Date", () => {
|
||||
).toEqual([2024, 2, 5, 6, 7, 8, 9])
|
||||
})
|
||||
|
||||
test("invalid dates yield NaN times, guardable in-sandbox", async () => {
|
||||
test("invalid dates yield NaN times, guardable in-CodeMode", async () => {
|
||||
expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true)
|
||||
expect(await value(`return new Date("garbage").toJSON()`)).toBeNull()
|
||||
})
|
||||
@@ -702,11 +702,11 @@ describe("stdlib integration", () => {
|
||||
expect(await value(`const fn = () => 1; return !fn`)).toBe(false)
|
||||
})
|
||||
|
||||
test("object spread of sandbox values is a no-op, like JS", async () => {
|
||||
test("object spread of CodeMode values is a no-op, like JS", async () => {
|
||||
expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true })
|
||||
})
|
||||
|
||||
test("dates inside Map values survive in-sandbox reads", async () => {
|
||||
test("dates inside Map values survive in-CodeMode reads", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const m = new Map([["start", new Date(1000)]])
|
||||
@@ -748,7 +748,7 @@ describe("stdlib integration", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
describe("CodeMode values at intra-CodeMode checkpoints", () => {
|
||||
test("Object.values/entries keep Dates usable", async () => {
|
||||
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
|
||||
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
|
||||
@@ -799,7 +799,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("object and array spread keep sandbox values usable", async () => {
|
||||
test("object and array spread keep CodeMode values usable", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const src = { m: new Map([["a", 1]]) }
|
||||
@@ -811,7 +811,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
|
||||
})
|
||||
|
||||
test("Array.from over arrays keeps nested sandbox values usable", async () => {
|
||||
test("Array.from over arrays keeps nested CodeMode values usable", async () => {
|
||||
expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5)
|
||||
})
|
||||
|
||||
@@ -866,7 +866,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true)
|
||||
})
|
||||
|
||||
test("Object.* helpers see sandbox values as empty objects, never internals", async () => {
|
||||
test("Object.* helpers see CodeMode values as empty objects, never internals", async () => {
|
||||
expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([])
|
||||
expect(await value(`return Object.values(new Date(0))`)).toEqual([])
|
||||
expect(await value(`return Object.entries(new Set([1]))`)).toEqual([])
|
||||
|
||||
+20
-12
@@ -25,7 +25,10 @@ export interface EntryPoint {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: {
|
||||
@@ -47,13 +50,18 @@ export function sanitize(pkg: string) {
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||
let entrypoint: string | undefined
|
||||
try {
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {
|
||||
entrypoint = undefined
|
||||
}
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
try {
|
||||
return typeof Bun !== "undefined"
|
||||
? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir)
|
||||
: import.meta.resolve(dir)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.find((entrypoint) => entrypoint !== undefined)
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
@@ -112,7 +120,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const dir = directory(pkg)
|
||||
const name = (() => {
|
||||
try {
|
||||
@@ -123,17 +131,17 @@ const layer = Layer.effect(
|
||||
})()
|
||||
|
||||
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
|
||||
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
}
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
|
||||
|
||||
@@ -54,12 +54,6 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
const PluginPackage = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.String),
|
||||
module: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
@@ -165,7 +159,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target)).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
@@ -194,40 +188,10 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
const directories = yield* fs
|
||||
.glob("{plugin,plugins}/*", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "all",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), {
|
||||
concurrency: "unbounded",
|
||||
}).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
||||
return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
||||
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
||||
|
||||
return yield* Effect.forEach(entries, (entry) => {
|
||||
if (!entry) return Effect.succeed(undefined)
|
||||
const file = path.resolve(directory, entry)
|
||||
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
||||
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Token } from "../util/token"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
@@ -320,7 +321,7 @@ const make = (dependencies: Dependencies) => {
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = input.model.route.defaults.limits?.output ?? 0
|
||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
|
||||
@@ -543,7 +543,8 @@ const layer = Layer.effect(
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "folder-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("folder", (agent) => {
|
||||
agent.description = "Loaded from plugin folder"
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -134,7 +134,7 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin files and packages", () =>
|
||||
it.live("loads auto-discovered plugin files", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
@@ -143,9 +143,6 @@ describe("PluginSupervisor config", () => {
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
|
||||
description: "Loaded from plugin directory",
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({
|
||||
description: "Loaded from plugin folder",
|
||||
})
|
||||
}),
|
||||
true,
|
||||
),
|
||||
@@ -195,7 +192,6 @@ describe("PluginSupervisor config", () => {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -41,19 +41,27 @@ describe("Npm.add", () => {
|
||||
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
|
||||
await writePackage(path.join(tmp.path, "fixture-provider"), {
|
||||
name: "fixture-provider",
|
||||
main: "index.js",
|
||||
exports: {
|
||||
".": "./index.js",
|
||||
"./tui": "./tui.js",
|
||||
},
|
||||
})
|
||||
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
|
||||
await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n")
|
||||
|
||||
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
|
||||
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
return {
|
||||
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
|
||||
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.entrypoint).toBeDefined()
|
||||
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -139,6 +139,11 @@ const compactModel = Model.make({
|
||||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
|
||||
})
|
||||
const fullOutputModel = Model.make({
|
||||
id: "full-output",
|
||||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 262_144, output: 262_144 } }),
|
||||
})
|
||||
const undersizedContextModel = Model.make({
|
||||
id: "undersized-context",
|
||||
provider: "fake",
|
||||
@@ -1614,14 +1619,14 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
|
||||
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.text("Active complete", "text-active"),
|
||||
reply.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
reply.text("Steer complete", "text-steer"),
|
||||
reply.text("Queue complete", "text-queue"),
|
||||
@@ -1865,6 +1870,25 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not compact immediately when the advertised output limit fills the context", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = fullOutputModel
|
||||
response = reply.textWithUsage("Earlier answer", "text-full-output-first", 9_500)
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
requests.length = 0
|
||||
response = reply.text("Continued", "text-full-output-final")
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toContain("Continue")
|
||||
expect(yield* session.context(sessionID)).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops after required automatic compaction fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
Vendored
+14
-5
@@ -1,12 +1,21 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed an OpenCode host in an Effect application."
|
||||
description: "Embed OpenCode directly in your application."
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
|
||||
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
|
||||
OpenCode server and routes API calls through its HTTP router in memory. It opens
|
||||
no HTTP listener and adds no network hop between the client and server.
|
||||
We're working on a general-purpose SDK for embedding OpenCode directly inside
|
||||
your application. The regular SDK is coming soon.
|
||||
|
||||
An Effect-native version is available now for applications built with Effect.
|
||||
Its current documentation is below. For other applications, run OpenCode as a
|
||||
server and use the [TypeScript client](/build/client) in the meantime.
|
||||
|
||||
## Effect
|
||||
|
||||
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
permissionRun,
|
||||
} from "@opencode-ai/cli/mini/permission.shared"
|
||||
|
||||
function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
|
||||
function req(input: Partial<PermissionV2Request> = {}): PermissionV2Request {
|
||||
return {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: [],
|
||||
action: "read",
|
||||
resources: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
save: [],
|
||||
...input,
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ describe("run permission shared", () => {
|
||||
expect(
|
||||
permissionInfo(
|
||||
req({
|
||||
permission: "bash",
|
||||
action: "bash",
|
||||
metadata: {
|
||||
input: {
|
||||
command: "git status --short",
|
||||
@@ -96,7 +96,7 @@ describe("run permission shared", () => {
|
||||
expect(
|
||||
permissionInfo(
|
||||
req({
|
||||
permission: "task",
|
||||
action: "task",
|
||||
metadata: {
|
||||
description: "investigate stream",
|
||||
subagent_type: "general",
|
||||
@@ -111,8 +111,8 @@ describe("run permission shared", () => {
|
||||
expect(
|
||||
permissionInfo(
|
||||
req({
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
|
||||
action: "external_directory",
|
||||
resources: ["/tmp/work/**/*.ts", "/tmp/work/**/*.tsx"],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
@@ -120,22 +120,22 @@ describe("run permission shared", () => {
|
||||
lines: ["- /tmp/work/**/*.ts", "- /tmp/work/**/*.tsx"],
|
||||
})
|
||||
|
||||
expect(permissionInfo(req({ permission: "doom_loop" }))).toMatchObject({
|
||||
expect(permissionInfo(req({ action: "doom_loop" }))).toMatchObject({
|
||||
title: "Continue after repeated failures",
|
||||
})
|
||||
|
||||
expect(permissionInfo(req({ permission: "custom_tool" }))).toMatchObject({
|
||||
expect(permissionInfo(req({ action: "custom_tool" }))).toMatchObject({
|
||||
title: "Call tool custom_tool",
|
||||
lines: ["Tool: custom_tool"],
|
||||
})
|
||||
})
|
||||
|
||||
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
||||
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
|
||||
expect(permissionAlwaysLines(req({ action: "bash", save: ["*"] }))).toEqual([
|
||||
"This will allow bash until OpenCode is restarted.",
|
||||
])
|
||||
|
||||
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
|
||||
expect(permissionAlwaysLines(req({ save: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
"- src/**/*.ts",
|
||||
"- src/**/*.tsx",
|
||||
|
||||
@@ -136,14 +136,14 @@ test("theme swaps restyle active reasoning without resetting the stream", async
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: previousSyntax,
|
||||
syntax: previousSyntax,
|
||||
},
|
||||
}
|
||||
const next = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: nextSyntax,
|
||||
syntax: nextSyntax,
|
||||
},
|
||||
}
|
||||
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data"
|
||||
import type { StreamCommit } from "@opencode-ai/cli/mini/types"
|
||||
|
||||
function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
|
||||
return reduceSessionData({
|
||||
data,
|
||||
event: event as Event,
|
||||
sessionID: "session-1",
|
||||
thinking,
|
||||
limits: {},
|
||||
})
|
||||
}
|
||||
|
||||
function assistant(id: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
info: {
|
||||
id,
|
||||
role: "assistant",
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function user(id: string) {
|
||||
return {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
info: {
|
||||
id,
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
|
||||
return {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: input.id,
|
||||
messageID: input.messageID,
|
||||
sessionID: "session-1",
|
||||
type: "text",
|
||||
text: input.text,
|
||||
...(input.time ? { time: input.time } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
|
||||
return {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: input.id,
|
||||
messageID: input.messageID,
|
||||
sessionID: "session-1",
|
||||
type: "reasoning",
|
||||
text: input.text,
|
||||
...(input.time ? { time: input.time } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function delta(messageID: string, partID: string, value: string) {
|
||||
return {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
partID,
|
||||
field: "text",
|
||||
delta: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
|
||||
return {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: input.id,
|
||||
messageID: input.messageID,
|
||||
sessionID: "session-1",
|
||||
type: "tool",
|
||||
tool: input.tool,
|
||||
...(input.callID ? { callID: input.callID } : {}),
|
||||
state: input.state,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shellInfo(id: string, status: "running" | "exited", completed?: number) {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
command: "pwd",
|
||||
cwd: "/tmp/demo",
|
||||
shell: "/bin/sh",
|
||||
file: `/tmp/${id}.log`,
|
||||
...(status === "exited" ? { exit: 0 } : {}),
|
||||
metadata: {},
|
||||
time: { started: 1, ...(completed === undefined ? {} : { completed }) },
|
||||
}
|
||||
}
|
||||
|
||||
function shellStarted(id = "call-1") {
|
||||
return {
|
||||
type: "session.shell.started",
|
||||
properties: { sessionID: "session-1", shell: shellInfo(id, "running") },
|
||||
}
|
||||
}
|
||||
|
||||
function shellEnded(id = "call-1") {
|
||||
const output = "/tmp/demo\n"
|
||||
return {
|
||||
type: "session.shell.ended",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
shell: shellInfo(id, "exited", 2),
|
||||
output: { output, cursor: Buffer.byteLength(output), size: Buffer.byteLength(output), truncated: false },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("run session data", () => {
|
||||
test("buffers delayed assistant text until the role is known", () => {
|
||||
let data = createSessionData()
|
||||
data = reduce(data, delta("msg-1", "txt-1", "hello")).data
|
||||
data = reduce(data, assistant("msg-1")).data
|
||||
|
||||
const out = reduce(
|
||||
data,
|
||||
text({
|
||||
id: "txt-1",
|
||||
messageID: "msg-1",
|
||||
text: "",
|
||||
time: { end: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(out.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "hello",
|
||||
partID: "txt-1",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps leading whitespace buffered until real assistant content arrives", () => {
|
||||
let data = createSessionData()
|
||||
data = reduce(data, assistant("msg-1")).data
|
||||
data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
|
||||
|
||||
let out = reduce(data, delta("msg-1", "txt-1", " "))
|
||||
expect(out.commits).toEqual([])
|
||||
|
||||
out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
|
||||
expect(out.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: " Found",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("drops delayed text once the message resolves to a user role", () => {
|
||||
let data = createSessionData()
|
||||
data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
|
||||
|
||||
const out = reduce(data, user("msg-user-1"))
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.data.ids.has("txt-user-1")).toBe(true)
|
||||
})
|
||||
|
||||
test("suppresses reasoning commits when thinking is disabled", () => {
|
||||
const out = reduce(
|
||||
createSessionData(),
|
||||
reasoning({
|
||||
id: "reason-1",
|
||||
messageID: "msg-1",
|
||||
text: "hidden",
|
||||
time: { end: 1 },
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
expect(out.commits).toEqual([])
|
||||
expect(out.data.ids.has("reason-1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps permission precedence over queued questions", () => {
|
||||
let data = createSessionData()
|
||||
data = reduce(data, {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["/tmp/file.txt"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
}).data
|
||||
|
||||
const ask = reduce(data, {
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "session-1",
|
||||
questions: [
|
||||
{
|
||||
question: "Mode?",
|
||||
header: "Mode",
|
||||
options: [{ label: "chunked", description: "Incremental output" }],
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(ask.footer).toEqual({
|
||||
patch: { status: "awaiting permission" },
|
||||
view: {
|
||||
type: "permission",
|
||||
request: expect.objectContaining({ id: "perm-1" }),
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
reduce(ask.data, {
|
||||
type: "permission.replied",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
requestID: "perm-1",
|
||||
reply: "reject",
|
||||
},
|
||||
}).footer,
|
||||
).toEqual({
|
||||
patch: { status: "awaiting answer" },
|
||||
view: {
|
||||
type: "question",
|
||||
request: expect.objectContaining({ id: "question-1" }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("refreshes the active permission view when tool input arrives later", () => {
|
||||
const data = reduce(createSessionData(), {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "session-1",
|
||||
permission: "bash",
|
||||
patterns: ["src/**/*.ts"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
tool: {
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
},
|
||||
}).data
|
||||
|
||||
const out = reduce(
|
||||
data,
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
command: "git status --short",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(out.footer).toEqual({
|
||||
view: {
|
||||
type: "permission",
|
||||
request: expect.objectContaining({
|
||||
id: "perm-1",
|
||||
metadata: expect.objectContaining({
|
||||
input: {
|
||||
command: "git status --short",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("strips bash echo only from the first assistant flush", () => {
|
||||
let data = createSessionData()
|
||||
data = reduce(data, assistant("msg-1")).data
|
||||
data = reduce(
|
||||
data,
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
command: "printf hi",
|
||||
},
|
||||
output: "echoed\n",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
).data
|
||||
|
||||
const first = reduce(
|
||||
data,
|
||||
text({
|
||||
id: "txt-1",
|
||||
messageID: "msg-1",
|
||||
text: "echoed\nanswer",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(first.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "answer",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant",
|
||||
text: "\nechoed\nagain",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("renders direct shell mode from first-class shell events", () => {
|
||||
let data = createSessionData()
|
||||
const started = reduce(data, shellStarted())
|
||||
|
||||
expect(started.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
phase: "start",
|
||||
partID: "shell:call-1",
|
||||
tool: "bash",
|
||||
shell: {
|
||||
callID: "call-1",
|
||||
command: "pwd",
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
data = started.data
|
||||
const ended = reduce(data, shellEnded())
|
||||
|
||||
expect(ended.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
phase: "progress",
|
||||
partID: "shell:call-1",
|
||||
tool: "bash",
|
||||
text: "/tmp/demo\n",
|
||||
toolState: "completed",
|
||||
shell: {
|
||||
callID: "call-1",
|
||||
command: "pwd",
|
||||
},
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("suppresses legacy bash part updates once shell events claim the call", () => {
|
||||
let data = reduce(createSessionData(), shellStarted()).data
|
||||
|
||||
expect(
|
||||
reduce(
|
||||
data,
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
command: "pwd",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
}),
|
||||
).commits,
|
||||
).toEqual([])
|
||||
|
||||
data = reduce(data, shellEnded()).data
|
||||
|
||||
expect(
|
||||
reduce(
|
||||
data,
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
command: "pwd",
|
||||
},
|
||||
output: "/tmp/demo\n",
|
||||
title: "",
|
||||
metadata: {
|
||||
output: "/tmp/demo\n",
|
||||
},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
).commits,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("suppresses shell events when the legacy bash part claimed the call first", () => {
|
||||
let data = reduce(
|
||||
createSessionData(),
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
command: "pwd",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
}),
|
||||
).data
|
||||
|
||||
expect(
|
||||
reduce(data, shellStarted()).commits,
|
||||
).toEqual([])
|
||||
|
||||
data = reduce(
|
||||
data,
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
command: "pwd",
|
||||
},
|
||||
output: "/tmp/demo\n",
|
||||
title: "",
|
||||
metadata: {
|
||||
output: "/tmp/demo\n",
|
||||
},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
).data
|
||||
|
||||
expect(
|
||||
reduce(data, shellEnded()).commits,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("synthesizes a glob start before an error when the running update is missed", () => {
|
||||
expect(
|
||||
reduce(
|
||||
createSessionData(),
|
||||
tool({
|
||||
id: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "glob",
|
||||
state: {
|
||||
status: "error",
|
||||
input: {
|
||||
pattern: "**/*tool*",
|
||||
path: "/tmp/demo/run",
|
||||
},
|
||||
error: "No such file or directory: '/tmp/demo/run'",
|
||||
},
|
||||
}),
|
||||
).commits,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
tool: "glob",
|
||||
phase: "start",
|
||||
partID: "tool-1",
|
||||
text: "running glob",
|
||||
toolState: "running",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "tool",
|
||||
tool: "glob",
|
||||
phase: "final",
|
||||
partID: "tool-1",
|
||||
text: "No such file or directory: '/tmp/demo/run'",
|
||||
toolState: "error",
|
||||
toolError: "No such file or directory: '/tmp/demo/run'",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("surfaces session errors as error commits", () => {
|
||||
const out = reduce(createSessionData(), {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: "permission denied",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.commits).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "error",
|
||||
text: "permission denied",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { OpenCode, type SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createSession,
|
||||
resolveCurrentSession,
|
||||
@@ -9,12 +9,6 @@ import {
|
||||
type SessionMessages,
|
||||
} from "@opencode-ai/cli/mini/session.shared"
|
||||
|
||||
type Message = SessionMessages[number]
|
||||
type Part = Message["parts"][number]
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type AgentPart = Extract<Part, { type: "agent" }>
|
||||
type FilePart = Extract<Part, { type: "file" }>
|
||||
|
||||
const model = {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
@@ -24,107 +18,30 @@ afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
role: "user",
|
||||
time: {
|
||||
created: 1,
|
||||
},
|
||||
agent: "build",
|
||||
model: {
|
||||
...model,
|
||||
variant,
|
||||
},
|
||||
},
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, parts: Message["parts"]): Message {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: 1,
|
||||
},
|
||||
parentID: "msg-user-1",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
mode: "chat",
|
||||
agent: "build",
|
||||
path: {
|
||||
cwd: "/tmp",
|
||||
root: "/tmp",
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
|
||||
function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): SessionMessageUser {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "text",
|
||||
type: "user",
|
||||
text,
|
||||
synthetic: input.synthetic,
|
||||
}
|
||||
}
|
||||
|
||||
function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "agent",
|
||||
name,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "file",
|
||||
mime: input.mime ?? "text/plain",
|
||||
filename: input.filename,
|
||||
url,
|
||||
source: input.source,
|
||||
time: { created: 1 },
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
describe("run session shared", () => {
|
||||
test("builds user prompt text from text, file, and agent parts", () => {
|
||||
test("builds user prompts from projected text and attachments", () => {
|
||||
const msgs: SessionMessages = [
|
||||
assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
|
||||
userMessage("msg-user-1", [
|
||||
textPart("txt-user-1", "msg-user-1", "look @scan"),
|
||||
textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
|
||||
agentPart("agent-user-1", "msg-user-1", "scan", {
|
||||
start: 5,
|
||||
end: 10,
|
||||
value: "@scan",
|
||||
}),
|
||||
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
|
||||
]),
|
||||
userMessage("msg-user-1", "look @scan @note.ts", {
|
||||
agents: [{ name: "scan", mention: { start: 5, end: 10, text: "@scan" } }],
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||
mention: { start: 11, end: 19, text: "@note.ts" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
const out = createSession(msgs)
|
||||
@@ -132,15 +49,6 @@ describe("run session shared", () => {
|
||||
expect(out.turns).toHaveLength(1)
|
||||
expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
|
||||
expect(out.turns[0]?.prompt.parts).toEqual([
|
||||
{
|
||||
type: "agent",
|
||||
name: "scan",
|
||||
source: {
|
||||
start: 5,
|
||||
end: 10,
|
||||
value: "@scan",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
@@ -156,44 +64,40 @@ describe("run session shared", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
name: "scan",
|
||||
source: {
|
||||
start: 5,
|
||||
end: 10,
|
||||
value: "@scan",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("reuses existing mentions when file and agent parts have no source", () => {
|
||||
test("leaves attachment sources undefined when projected mentions are absent", () => {
|
||||
const out = createSession([
|
||||
userMessage("msg-user-1", [
|
||||
textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
|
||||
agentPart("agent-user-1", "msg-user-1", "scan"),
|
||||
filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
|
||||
]),
|
||||
userMessage("msg-user-1", "look @scan @note.ts", {
|
||||
agents: [{ name: "scan" }],
|
||||
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/note.ts" } }],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(out.turns[0]?.prompt).toEqual({
|
||||
text: "look @scan @note.ts",
|
||||
parts: [
|
||||
{
|
||||
type: "agent",
|
||||
name: "scan",
|
||||
source: {
|
||||
start: 5,
|
||||
end: 10,
|
||||
value: "@scan",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: undefined,
|
||||
url: "file:///tmp/note.ts",
|
||||
source: {
|
||||
type: "file",
|
||||
path: "file:///tmp/note.ts",
|
||||
text: {
|
||||
start: 11,
|
||||
end: 19,
|
||||
value: "@note.ts",
|
||||
},
|
||||
},
|
||||
source: undefined,
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
name: "scan",
|
||||
source: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -513,11 +513,8 @@ describe("V2 mini transport", () => {
|
||||
request: {
|
||||
id: "per_1",
|
||||
sessionID: "ses_1",
|
||||
permission: "read",
|
||||
patterns: ["/tmp/file"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
tool: undefined,
|
||||
action: "read",
|
||||
resources: ["/tmp/file"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,9 +67,7 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
|
||||
try {
|
||||
expect(theme.block.syntax).toBeDefined()
|
||||
expect(theme.block.subtleSyntax).toBeDefined()
|
||||
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expectIndexed(theme.splash.left)
|
||||
expectIndexed(theme.splash.right)
|
||||
expectIndexed(theme.splash.leftShadow)
|
||||
@@ -82,7 +80,6 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,7 +100,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
|
||||
expectIndexed(theme.block.warning)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,9 +115,7 @@ test("uses refreshed background brightness when cached renderer mode is stale",
|
||||
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
|
||||
} finally {
|
||||
stale.block.syntax?.destroy()
|
||||
stale.block.subtleSyntax?.destroy()
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,9 +132,7 @@ test("keeps renderer mode when refreshed default background is unavailable", asy
|
||||
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
|
||||
} finally {
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
dark.block.syntax?.destroy()
|
||||
dark.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
pickVariant,
|
||||
resolveVariant,
|
||||
} from "@opencode-ai/cli/mini/variant.shared"
|
||||
import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared"
|
||||
import type { RunSession } from "@opencode-ai/cli/mini/session.shared"
|
||||
import type { RunProvider } from "@opencode-ai/cli/mini/types"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
@@ -79,25 +79,6 @@ const providers: RunProvider[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function userMessage(
|
||||
id: string,
|
||||
input: { providerID: string; modelID: string; variant?: string },
|
||||
): SessionMessages[number] {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
role: "user",
|
||||
time: {
|
||||
created: 1,
|
||||
},
|
||||
agent: "build",
|
||||
model: input,
|
||||
},
|
||||
parts: [],
|
||||
}
|
||||
}
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
|
||||
|
||||
function remap(root: string, file: string) {
|
||||
@@ -147,14 +128,17 @@ describe("run variant shared", () => {
|
||||
expect(formatModelLabel(model, "high", providers)).toBe("GPT-5 · OpenAI · high")
|
||||
})
|
||||
|
||||
test("picks the latest matching variant from raw session messages", () => {
|
||||
const msgs: SessionMessages = [
|
||||
userMessage("msg-1", { providerID: "openai", modelID: "gpt-5", variant: "high" }),
|
||||
userMessage("msg-2", { providerID: "anthropic", modelID: "sonnet", variant: "max" }),
|
||||
userMessage("msg-3", { providerID: "openai", modelID: "gpt-5", variant: "minimal" }),
|
||||
]
|
||||
test("picks the latest matching variant from session history", () => {
|
||||
const session: RunSession = {
|
||||
first: false,
|
||||
turns: [
|
||||
{ prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
|
||||
{ prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
|
||||
{ prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: "minimal" },
|
||||
],
|
||||
}
|
||||
|
||||
expect(pickVariant(model, msgs)).toBe("minimal")
|
||||
expect(pickVariant(model, session)).toBe("minimal")
|
||||
})
|
||||
|
||||
it.live("reads and writes saved variants through a runtime-backed app fs layer", () =>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"./tui": "./src/tui.ts",
|
||||
"./v2/effect": "./src/v2/effect/index.ts",
|
||||
"./v2/effect/*": "./src/v2/effect/*.ts",
|
||||
"./v2/tui/*": "./src/v2/tui/*.ts",
|
||||
"./v2/tui": "./src/v2/tui/index.ts",
|
||||
"./v2": "./src/v2/promise/index.ts",
|
||||
"./v2/*": "./src/v2/promise/*.ts"
|
||||
},
|
||||
|
||||
@@ -358,6 +358,7 @@ export type TuiTheme = {
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
export type TuiKV = {
|
||||
get: <Value = unknown>(key: string, fallback?: Value) => Value
|
||||
set: (key: string, value: unknown) => void
|
||||
@@ -434,6 +435,7 @@ type TuiConfigView = {
|
||||
sidebar?: "auto" | "hide"
|
||||
scrollbar?: boolean
|
||||
thinking?: "show" | "hide"
|
||||
markdown?: "source" | "rendered"
|
||||
grouping?: "auto" | "none"
|
||||
}
|
||||
hints?: { tips?: boolean; onboarding?: boolean }
|
||||
@@ -628,6 +630,7 @@ export type TuiPluginApi = {
|
||||
dialog: TuiDialogStack
|
||||
}
|
||||
readonly tuiConfig: Frozen<TuiConfigView>
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
kv: TuiKV
|
||||
state: TuiState
|
||||
theme: TuiTheme
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface UI {
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
readonly options: Record<string, any>
|
||||
readonly options: Readonly<Record<string, unknown>>
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
readonly ui: UI
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Context } from "./context.js"
|
||||
|
||||
export type { Context }
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
||||
export interface Definition {
|
||||
readonly id: string
|
||||
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
|
||||
}
|
||||
|
||||
export function define(plugin: Definition) {
|
||||
return plugin
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Skill } from "@opencode-ai/schema/skill"
|
||||
|
||||
const Plugin = await import("../src/v2/effect/index")
|
||||
const PromisePlugin = await import("../src/v2/promise/index")
|
||||
const TuiPlugin = await import("../src/v2/tui/index")
|
||||
|
||||
test.each([
|
||||
["effect", Plugin],
|
||||
@@ -38,3 +39,8 @@ test.each([
|
||||
"Skill",
|
||||
])
|
||||
})
|
||||
|
||||
test("tui entrypoint exposes the V2 plugin definition", () => {
|
||||
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
|
||||
expect(plugin.id).toBe("demo")
|
||||
})
|
||||
|
||||
@@ -19,12 +19,10 @@
|
||||
"./context/args": "./src/context/args.tsx",
|
||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||
"./context/exit": "./src/context/exit.tsx",
|
||||
"./context/kv": "./src/context/kv.tsx",
|
||||
"./context/log": "./src/context/log.tsx",
|
||||
"./context/project": "./src/context/project.tsx",
|
||||
"./context/runtime": "./src/context/runtime.tsx",
|
||||
"./context/sdk": "./src/context/sdk.tsx",
|
||||
"./context/sync": "./src/context/sync.tsx",
|
||||
"./context/client": "./src/context/client.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
@@ -57,7 +55,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
|
||||
+109
-167
@@ -41,10 +41,9 @@ import { PluginRouteMissing } from "./component/plugin-route-missing"
|
||||
import { ProjectProvider, useProject } from "./context/project"
|
||||
import { EditorContextProvider } from "./context/editor"
|
||||
import { useEvent } from "./context/event"
|
||||
import { SDKProvider, useSDK } from "./context/sdk"
|
||||
import { ClientProvider, useClient } from "./context/client"
|
||||
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"
|
||||
@@ -56,7 +55,6 @@ import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -67,11 +65,8 @@ import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
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"
|
||||
@@ -181,23 +176,6 @@ 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
|
||||
}
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const log = input.log ?? (() => {})
|
||||
const global = yield* Global.Service
|
||||
@@ -217,7 +195,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const endpoint = await reconnectEndpoint(attempt)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return {
|
||||
client: createOpencodeClient({ ...next, directory }),
|
||||
api: OpenCode.make(next),
|
||||
}
|
||||
}
|
||||
@@ -343,67 +320,62 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
client={createOpencodeClient({ ...options, directory })}
|
||||
api={api}
|
||||
reconnect={reconnect}
|
||||
reload={input.server.reload}
|
||||
>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<LocationProvider>
|
||||
<App
|
||||
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>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider
|
||||
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>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
@@ -440,17 +412,16 @@ function App(props: {
|
||||
}) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
const config = useConfig().data
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
@@ -459,7 +430,7 @@ function App(props: {
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config, kv })
|
||||
const attention = createTuiAttention({ renderer, config, update: configState.update })
|
||||
const clipboard = useClipboard()
|
||||
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
@@ -496,12 +467,11 @@ function App(props: {
|
||||
tuiConfig: config,
|
||||
dialog,
|
||||
keymap,
|
||||
kv,
|
||||
route,
|
||||
routes: pluginRuntime.routes,
|
||||
event,
|
||||
sdk,
|
||||
sync,
|
||||
client,
|
||||
project,
|
||||
data,
|
||||
theme: themeState,
|
||||
toast,
|
||||
@@ -549,8 +519,8 @@ function App(props: {
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
|
||||
const terminalTitleEnabled = () => config.terminal?.title ?? true
|
||||
const pasteSummaryEnabled = () => config.prompt?.paste !== "full"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
|
||||
@@ -610,7 +580,7 @@ function App(props: {
|
||||
if (continued || !args.continue) return
|
||||
continued = true
|
||||
const location = data.location.default()
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.list({
|
||||
limit: 1,
|
||||
order: "desc",
|
||||
@@ -625,7 +595,7 @@ function App(props: {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
return
|
||||
}
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.fork({ sessionID: match })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.catch(toast.error)
|
||||
@@ -638,7 +608,7 @@ function App(props: {
|
||||
createEffect(() => {
|
||||
if (forked || !args.sessionID || !args.fork) return
|
||||
forked = true
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.fork({ sessionID: args.sessionID })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.catch(toast.error)
|
||||
@@ -840,7 +810,7 @@ function App(props: {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(sdk.reload
|
||||
...(client.reload
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
@@ -851,7 +821,7 @@ function App(props: {
|
||||
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
|
||||
// reload resolves once the replacement service is healthy; the
|
||||
// event stream reattaches through the reconnect loop.
|
||||
await sdk.reload!()
|
||||
await client.reload!()
|
||||
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
@@ -880,6 +850,7 @@ function App(props: {
|
||||
{
|
||||
name: "theme.switch_mode",
|
||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -889,6 +860,7 @@ function App(props: {
|
||||
{
|
||||
name: "theme.mode.lock",
|
||||
title: locked() ? "Unlock theme mode" : "Lock theme mode",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
if (locked()) unlock()
|
||||
else lock()
|
||||
@@ -956,41 +928,57 @@ function App(props: {
|
||||
name: "terminal.title.toggle",
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setTerminalTitleEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("terminal_title_enabled", next)
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
return next
|
||||
})
|
||||
const next = !terminalTitleEnabled()
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.terminal = { ...draft.terminal, title: next }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.animations",
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
title: (config.animations ?? true) ? "Disable animations" : "Enable animations",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.animations = !(config.animations ?? true)
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.file_context",
|
||||
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
|
||||
title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.diffwrap",
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -998,12 +986,13 @@ function App(props: {
|
||||
name: "app.toggle.paste_summary",
|
||||
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setPasteSummaryEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("paste_summary_enabled", next)
|
||||
return next
|
||||
})
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1098,53 +1087,6 @@ function App(props: {
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.data.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
if (skipped && !isVersionGreater(version, skipped)) return
|
||||
|
||||
const choice = await DialogConfirm.show(
|
||||
dialog,
|
||||
`Update Available`,
|
||||
`A new release v${version} is available. Would you like to update now?`,
|
||||
"skip",
|
||||
)
|
||||
|
||||
if (choice === false) {
|
||||
kv.set("skipped_version", version)
|
||||
return
|
||||
}
|
||||
|
||||
if (choice !== true) return
|
||||
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Updating to v${version}...`,
|
||||
duration: 30000,
|
||||
})
|
||||
|
||||
const result = await sdk.client.global.upgrade({ target: version })
|
||||
|
||||
if (result.error || !result.data?.success) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Update Failed",
|
||||
message: "Update failed",
|
||||
duration: 10000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await DialogAlert.show(
|
||||
dialog,
|
||||
"Update Complete",
|
||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
void exit()
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (!ready()) return
|
||||
if (route.data.type !== "plugin") return
|
||||
@@ -1162,7 +1104,7 @@ function App(props: {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = undefined
|
||||
}
|
||||
const status = sdk.connection.status()
|
||||
const status = client.connection.status()
|
||||
if (status === "connected") {
|
||||
setShowReconnecting(false)
|
||||
return
|
||||
@@ -1225,7 +1167,7 @@ function App(props: {
|
||||
<StartupLoading ready={ready} />
|
||||
</Show>
|
||||
<Show when={showReconnecting()}>
|
||||
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
||||
<Reconnecting attempt={client.connection.attempt()} error={client.connection.error()} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -38,9 +38,8 @@ type TuiAttentionHost = TuiAttention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "opencode"
|
||||
const DEFAULT_TITLE = "OpenCode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const KV_SOUND_PACK = "attention_sound_pack"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
@@ -114,6 +113,8 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
update?: Config.Interface["update"]
|
||||
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
@@ -134,8 +135,7 @@ export function createTuiAttention(input: {
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
return activePackID ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
@@ -234,7 +234,12 @@ export function createTuiAttention(input: {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
if (options?.persist)
|
||||
void input
|
||||
.update?.((draft) => {
|
||||
draft.attention = { ...draft.attention, sound_pack: pack.id }
|
||||
})
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { tint, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings } from "../keymap"
|
||||
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[]
|
||||
@@ -26,18 +21,12 @@ 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"],
|
||||
@@ -45,7 +34,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Animations",
|
||||
category: "Appearance",
|
||||
description: "Interface motion",
|
||||
path: ["animations"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -54,7 +42,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Tips",
|
||||
category: "Appearance",
|
||||
description: "Home screen hints",
|
||||
path: ["hints", "tips"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -63,7 +50,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
description: "Getting-started guidance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -72,7 +58,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
description: "Session sidebar visibility",
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
@@ -80,7 +65,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
description: "Transcript scrollbar",
|
||||
path: ["session", "scrollbar"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -89,15 +73,20 @@ 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"],
|
||||
@@ -105,7 +94,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
description: "Diff presentation",
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
@@ -113,7 +101,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Wrapping",
|
||||
category: "Diffs",
|
||||
description: "Long diff lines",
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
@@ -121,7 +108,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "File tree",
|
||||
category: "Diffs",
|
||||
description: "Diff file navigation",
|
||||
path: ["diffs", "tree"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -130,7 +116,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Single patch",
|
||||
category: "Diffs",
|
||||
description: "Only the selected patch",
|
||||
path: ["diffs", "single"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -139,7 +124,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scroll speed",
|
||||
category: "Input",
|
||||
description: "Distance per input tick",
|
||||
path: ["scroll", "speed"],
|
||||
default: 3,
|
||||
step: 0.25,
|
||||
@@ -150,7 +134,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Acceleration",
|
||||
category: "Input",
|
||||
description: "Repeated scrolling",
|
||||
path: ["scroll", "acceleration"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -159,7 +142,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Mouse",
|
||||
category: "Input",
|
||||
description: "Terminal mouse capture",
|
||||
path: ["mouse"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -168,7 +150,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Editor context",
|
||||
category: "Input",
|
||||
description: "Active selection in prompts",
|
||||
path: ["prompt", "editor"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -177,7 +158,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Large pastes",
|
||||
category: "Input",
|
||||
description: "Paste display style",
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
@@ -185,7 +165,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
description: "Wait after leader key",
|
||||
path: ["leader", "timeout"],
|
||||
default: 2000,
|
||||
step: 250,
|
||||
@@ -196,7 +175,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
description: "Alerts when input is needed",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -205,7 +183,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
description: "System notifications",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -214,7 +191,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
description: "Attention sounds",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -223,7 +199,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Volume",
|
||||
category: "Alerts",
|
||||
description: "Attention sound level",
|
||||
path: ["attention", "volume"],
|
||||
default: 0.4,
|
||||
step: 0.1,
|
||||
@@ -234,7 +209,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Window title",
|
||||
category: "Terminal",
|
||||
description: "Update terminal title",
|
||||
path: ["terminal", "title"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -244,18 +218,10 @@ const settings: Setting[] = [
|
||||
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme } = themeState
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
onMount(() => {
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
const current = setting.path.reduce<unknown>((result, key) => {
|
||||
@@ -275,34 +241,18 @@ export function DialogConfig() {
|
||||
const index = setting.values?.indexOf(current)
|
||||
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
|
||||
}
|
||||
const rows = createMemo(() =>
|
||||
const options = createMemo(() =>
|
||||
settings.map((setting, index) => ({
|
||||
setting,
|
||||
index,
|
||||
heading: index === 0 || settings[index - 1].category !== setting.category,
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
footer: display(setting),
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
const split = createMemo(() => dimensions().width >= 110)
|
||||
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
|
||||
|
||||
function move(direction: number) {
|
||||
const next = (selected() + direction + settings.length) % settings.length
|
||||
setSelected(next)
|
||||
queueMicrotask(() => {
|
||||
if (!scroll) return
|
||||
const row =
|
||||
next +
|
||||
settings.slice(0, next + 1).filter((setting, index) => {
|
||||
return index === 0 || settings[index - 1].category !== setting.category
|
||||
}).length
|
||||
if (row < scroll.scrollTop) scroll.scrollTo(row)
|
||||
if (row >= scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(row - scroll.viewport.height + 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function change(direction: number) {
|
||||
async function change(direction: number, index = selected()) {
|
||||
if (saving()) return
|
||||
const setting = settings[selected()]
|
||||
const setting = settings[index]
|
||||
const current = value(setting)
|
||||
const choices = values(setting)
|
||||
const next = choices
|
||||
@@ -322,99 +272,27 @@ export function DialogConfig() {
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(-1) },
|
||||
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
{ key: "return", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" height={height() + 1}>
|
||||
<box width={split() ? "54%" : "100%"} paddingLeft={4} paddingRight={split() ? 3 : 4} paddingBottom={1}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD} paddingBottom={1}>
|
||||
Settings
|
||||
</text>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<>
|
||||
<Show when={row.heading}>
|
||||
<box paddingTop={row.index === 0 ? 0 : 1}>
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{row.setting.category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" height={1}>
|
||||
<text
|
||||
width={25}
|
||||
fg={row.index === selected() ? theme.text : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{row.setting.title}
|
||||
</text>
|
||||
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? "‹ " : " "}</text>
|
||||
<text
|
||||
fg={row.index === selected() ? theme.primary : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{display(row.setting)}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? " ›" : " "}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</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}>
|
||||
{settings[selected()].title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{settings[selected()].detail ?? settings[selected()].description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
@@ -102,7 +102,7 @@ function manageConnections(
|
||||
) {
|
||||
dialog.replace(() => {
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
return (
|
||||
<DialogSelect
|
||||
@@ -121,7 +121,7 @@ function manageConnections(
|
||||
title: `Disconnect ${connection.label}`,
|
||||
value: connection.id,
|
||||
onSelect: () => {
|
||||
void sdk.api.credential
|
||||
void client.api.credential
|
||||
.remove({ credentialID: connection.id, location: location(data) })
|
||||
.then(() => disconnected(integration.name, data, dialog, toast))
|
||||
.catch(toast.error)
|
||||
@@ -172,7 +172,7 @@ function KeyMethod(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
@@ -183,7 +183,7 @@ function KeyMethod(props: {
|
||||
placeholder="API key"
|
||||
onConfirm={(key) => {
|
||||
if (!key) return
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.connect.key({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
@@ -218,11 +218,11 @@ function OAuthStarting(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.connect.oauth({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
@@ -267,7 +267,7 @@ function OAuthAuto(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -291,7 +291,7 @@ function OAuthAuto(props: {
|
||||
}))
|
||||
|
||||
const poll = () => {
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
.then((result) => {
|
||||
const status = result.data
|
||||
@@ -318,7 +318,7 @@ function OAuthAuto(props: {
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (settled) return
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -340,7 +340,7 @@ function OAuthCode(props: {
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
@@ -348,7 +348,7 @@ function OAuthCode(props: {
|
||||
|
||||
onCleanup(() => {
|
||||
if (settled) return
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -357,7 +357,7 @@ function OAuthCode(props: {
|
||||
placeholder="Authorization code"
|
||||
onConfirm={(code) => {
|
||||
if (!code) return
|
||||
void sdk.api.integration
|
||||
void client.api.integration
|
||||
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
.then(() => {
|
||||
settled = true
|
||||
|
||||
@@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
{
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect integration" : "View all integrations",
|
||||
selection: "none",
|
||||
onTrigger() {
|
||||
dialog.replace(() => (
|
||||
<DialogIntegration
|
||||
@@ -154,6 +155,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
focusCurrent={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMemo, createResource, createSignal, onMount, Show } from "solid-j
|
||||
import path from "path"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
@@ -35,7 +35,7 @@ type DialogMoveSessionProps = {
|
||||
|
||||
export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sessionData = useData()
|
||||
@@ -63,7 +63,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const [loadedProject] = createResource(
|
||||
() => (projectContext.project() === undefined ? props.projectID : undefined),
|
||||
(projectID) =>
|
||||
sdk.api.project
|
||||
client.api.project
|
||||
.current({ location: { directory: projectContext.instance.directory() || paths.cwd } })
|
||||
.then((project) => (project.id === projectID ? project.directory : undefined))
|
||||
.catch(() => undefined),
|
||||
@@ -78,11 +78,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||
try {
|
||||
const location = { directory: projectContext.instance.directory() || paths.cwd }
|
||||
await sdk.api.projectCopy.refresh({
|
||||
await client.api.projectCopy.refresh({
|
||||
projectID,
|
||||
location,
|
||||
})
|
||||
const directories = await sdk.api.project.directories({
|
||||
const directories = await client.api.project.directories({
|
||||
projectID,
|
||||
location,
|
||||
})
|
||||
@@ -232,7 +232,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
setToDelete(undefined)
|
||||
setRemoving(selected.directory)
|
||||
setWorking(true)
|
||||
const error = await sdk.api.projectCopy
|
||||
const error = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
@@ -247,7 +247,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
setRemoving(undefined)
|
||||
setWorking(false)
|
||||
if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) {
|
||||
const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
|
||||
const status = await client.api.vcs
|
||||
.status({ location: { directory: selected.directory } })
|
||||
.catch(() => undefined)
|
||||
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
|
||||
title: "Delete working copy?",
|
||||
message: "This working copy has file changes. Do you want to delete it anyway?",
|
||||
@@ -257,7 +259,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return
|
||||
}
|
||||
reopen(selected.directory)
|
||||
const forcedError = await sdk.api.projectCopy
|
||||
const forcedError = await client.api.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: projectContext.instance.directory() || paths.cwd },
|
||||
@@ -345,6 +347,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
selection: "none",
|
||||
onTrigger: () => void create(),
|
||||
},
|
||||
{
|
||||
@@ -360,6 +363,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.refresh",
|
||||
title: "refresh",
|
||||
selection: "none",
|
||||
onTrigger: () => void refetch(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -13,7 +13,7 @@ export type DialogPairCredentials = {
|
||||
}
|
||||
|
||||
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
@@ -25,7 +25,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
sdk.api.server
|
||||
client.api.server
|
||||
.get()
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useData } from "../context/data"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useProject } from "../context/project"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocal } from "../context/local"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -23,7 +23,7 @@ export function DialogSessionList() {
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
@@ -36,7 +36,7 @@ export function DialogSessionList() {
|
||||
if (!query) return
|
||||
const location = data.location.default()
|
||||
try {
|
||||
const response = await sdk.api.session.list({
|
||||
const response = await client.api.session.list({
|
||||
search: query,
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
@@ -140,7 +140,7 @@ export function DialogSessionList() {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
void sdk.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { type DialogContext, useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
|
||||
return (
|
||||
@@ -17,7 +17,7 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s
|
||||
onConfirm={(value) => {
|
||||
const title = value.trim()
|
||||
if (!title) return
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.rename({ sessionID: props.sessionID, title })
|
||||
.then(() => dialog.clear())
|
||||
.catch((error) =>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useClient } from "../context/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.api.file
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: store.filter,
|
||||
type: "file",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { tint, useTheme } from "../context/theme"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
@@ -84,7 +84,7 @@ export function Autocomplete(props: {
|
||||
promptPartTypeId: () => number
|
||||
}) {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
@@ -315,7 +315,7 @@ export function Autocomplete(props: {
|
||||
if (referenceMatch()) return []
|
||||
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
|
||||
|
||||
const result = await sdk.api.file
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: baseQuery,
|
||||
limit: 20,
|
||||
|
||||
@@ -15,12 +15,13 @@ import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { tint, useTheme } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { EmptyBorder, SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useEvent } from "../../context/event"
|
||||
@@ -43,7 +44,6 @@ import { useDialog } from "../../ui/dialog"
|
||||
import { DialogIntegration } from "../dialog-integration"
|
||||
import { useConnected } from "../use-connected"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
@@ -148,7 +148,7 @@ export function Prompt(props: PromptProps) {
|
||||
const paths = useTuiPaths()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const clipboard = useClipboard()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
@@ -177,11 +177,10 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
|
||||
const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true)
|
||||
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
|
||||
const editorContext = createMemo(() => {
|
||||
const selection = fileContextEnabled() ? editor.selection() : undefined
|
||||
@@ -422,7 +421,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 5000)
|
||||
|
||||
if (store.interrupt >= 2) {
|
||||
void sdk.api.session.interrupt({
|
||||
void client.api.session.interrupt({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
setStore("interrupt", 0)
|
||||
@@ -441,7 +440,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!input.focused) return
|
||||
if (!props.sessionID) return
|
||||
|
||||
void sdk.api.session.background({
|
||||
void client.api.session.background({
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
@@ -966,7 +965,7 @@ export function Prompt(props: PromptProps) {
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
const location = data.location.default()
|
||||
|
||||
const created = await sdk.api.session
|
||||
const created = await client.api.session
|
||||
.create({
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
@@ -1010,7 +1009,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.shell({
|
||||
void client.api.session.shell({
|
||||
sessionID,
|
||||
command: inputText,
|
||||
})
|
||||
@@ -1029,7 +1028,7 @@ export function Prompt(props: PromptProps) {
|
||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
|
||||
void sdk.api.session
|
||||
void client.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
@@ -1049,7 +1048,7 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
) {
|
||||
move.startSubmit()
|
||||
void sdk.api.session.skill({
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
})
|
||||
@@ -1060,20 +1059,20 @@ export function Prompt(props: PromptProps) {
|
||||
session = data.session.get(sessionID)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await sdk.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session.model.variant !== variant
|
||||
) {
|
||||
await sdk.api.session.switchModel({
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await sdk.api.session.revert.commit({ sessionID }).then(
|
||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
@@ -1084,7 +1083,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (pendingEditorSelection) {
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
const error = await sdk.api.session
|
||||
const error = await client.api.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
text: formatEditorContext(pendingEditorSelection),
|
||||
@@ -1099,7 +1098,7 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const error = await sdk.api.session
|
||||
const error = await client.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: inputText,
|
||||
@@ -1191,7 +1190,7 @@ export function Prompt(props: PromptProps) {
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", true)
|
||||
config.prompt?.paste !== "full"
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
@@ -1495,7 +1494,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
@@ -17,7 +17,7 @@ function moveReminderText(directory: string) {
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const project = useProject()
|
||||
@@ -33,7 +33,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await sdk.api.projectCopy.create({
|
||||
const result = await client.api.projectCopy.create({
|
||||
projectID,
|
||||
location: { directory: project.instance.directory() || paths.cwd },
|
||||
strategy: "git_worktree",
|
||||
@@ -44,7 +44,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
|
||||
// Call a location-based route to make sure it's bootstrapped before moving on.
|
||||
await sdk.api.location.get({ location: { directory } })
|
||||
await client.api.location.get({ location: { directory } })
|
||||
|
||||
setProgress("Creating session")
|
||||
return directory
|
||||
@@ -98,7 +98,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
const session = await resolveSession(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
|
||||
const status = await client.api.vcs
|
||||
.status({ location: session?.location.directory ? { directory: session.location.directory } : undefined })
|
||||
.catch(() => undefined)
|
||||
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
|
||||
if (!choice) return
|
||||
dialog.clear()
|
||||
@@ -110,8 +112,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
}
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
||||
await sdk.api.session
|
||||
await client.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
||||
await client.api.session
|
||||
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
||||
.catch(() => undefined)
|
||||
dialog.clear()
|
||||
@@ -129,7 +131,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
if (projectID) return projectID
|
||||
const sessionID = input.sessionID()
|
||||
if (sessionID) return (await resolveSession(sessionID))?.projectID
|
||||
return sdk.api.project
|
||||
return client.api.project
|
||||
.current({ location: { directory: project.instance.directory() || paths.cwd } })
|
||||
.then((project) => project.id)
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useConfig } from "../config"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
@@ -11,10 +11,13 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
const kv = useKV()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
|
||||
@@ -118,6 +118,9 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type SDKConnectionEvent = {
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
@@ -17,27 +16,25 @@ export type SDKConnectionEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
type SDKEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: {
|
||||
client: OpencodeClient
|
||||
api: OpenCodeClient
|
||||
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||
reconnect?: (attempt: number) => Promise<{ api: OpenCodeClient }>
|
||||
// Stops and starts the managed service; present only in service mode.
|
||||
reload?: () => Promise<void>
|
||||
}) => {
|
||||
const log = useLog({ component: "sdk" })
|
||||
const log = useLog({ component: "client" })
|
||||
const abort = new AbortController()
|
||||
const history: SDKConnectionEvent[] = []
|
||||
let client = props.client
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
const events = createGlobalEmitter<SDKEventMap>()
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: SDKConnectionStatus
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
@@ -46,7 +43,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
|
||||
function record(status: SDKConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
@@ -121,7 +118,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
const next = await props.reconnect(attempt).catch(() => undefined)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
client = next.client
|
||||
api = next.api
|
||||
}
|
||||
}
|
||||
@@ -144,9 +140,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
return {
|
||||
get client() {
|
||||
return client
|
||||
},
|
||||
get api() {
|
||||
return api
|
||||
},
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user