Compare commits

...

47 Commits

Author SHA1 Message Date
Kit Langton 13ef9924fa fix(tui): preserve shell status spacing 2026-07-14 19:13:50 +00:00
Kit Langton 746f941b40 fix(tui): space background shell status 2026-07-14 19:13:50 +00:00
Kit Langton 4df54601df fix(tui): pad background shell status 2026-07-14 19:13:50 +00:00
Kit Langton e2614f2be4 fix(tui): keep background shell status inline 2026-07-14 19:13:50 +00:00
opencode-agent[bot] 016545513f feat(tui): group sequential thinking (#36901)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-07-14 15:13:16 -04:00
Dax Raad b67bed061a feat(tui): port diff viewer to v2 plugins 2026-07-14 15:10:40 -04:00
Dax Raad b2741d2f97 fix(tui): preserve legacy keybind lookup 2026-07-14 12:49:56 -04:00
Dax Raad 4a93972a78 feat(tui): add v2 plugin runtime 2026-07-14 12:49:56 -04:00
Kit Langton 5c5579e90c fix(llm): reject unterminated provider streams (#36881) 2026-07-14 12:44:45 -04:00
opencode-agent[bot] cd9be63484 fix(tui): hide initial instruction sync (#36891)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-14 12:42:09 -04:00
Shoubhit Dash 40fedf086e fix(llm): port provider retry classification (#36887) 2026-07-14 22:09:50 +05:30
Aiden Cline a6b5cf94b0 fix(tui): refresh MCP status for active location (#36882) 2026-07-14 10:49:56 -05:00
opencode-agent[bot] 634386fe0f chore: merge dev into v2 (#36770)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
2026-07-14 09:58:18 -05:00
Kit Langton 26e27c7a7f fix(tui): distinguish asynchronous UI states (#36759) 2026-07-14 10:56:12 -04:00
Shoubhit Dash ea386fba1f feat(llm): add compatible responses provider (#35908) 2026-07-14 19:32:29 +05:30
opencode-agent[bot] aa8fc4234d fix(core): add session correlation header (#36842)
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
2026-07-14 14:59:22 +02:00
Dax Raad 9b8282ad3d feat(plugin): add v2 tui entrypoint 2026-07-13 23:08:34 -04:00
Dax Raad 2a08cd3b96 refactor(core): simplify plugin entrypoint resolution 2026-07-13 22:45:15 -04:00
Dax Raad 5cf24bf185 tui: prevent crash when opening session switcher from an active session 2026-07-13 21:05:03 -04:00
Aiden Cline c15e3487b2 test(codemode): make promise tests deterministic (#36780) 2026-07-13 18:53:20 -05:00
Aiden Cline 6963f2f6da fix(cli): update mini tests for v2 types (#36779) 2026-07-13 18:38:25 -05:00
Aiden Cline 547e0148c7 chore(codemode): run tests in CI (#36772) 2026-07-13 18:09:10 -05:00
Dax Raad 358d4746a9 refactor(cli): remove legacy sdk dependency 2026-07-13 18:03:16 -04:00
Kit Langton ce2e301e24 feat(tui): style background subagent indicator (#36751) 2026-07-13 17:57:12 -04:00
opencode-agent[bot] fbe7f26e71 fix(core): clamp compaction output budget (#36745)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 17:50:20 -04:00
Aiden Cline ecb5754f4c refactor(codemode): rename Sandbox terminology to CodeMode (#36768) 2026-07-13 16:38:11 -05:00
Dax Raad 36f8cb7054 refactor(tui): remove legacy sdk client 2026-07-13 17:35:40 -04:00
James Long 4c45a5cf23 refactor(tui): extract system theme generation (#36753) 2026-07-13 16:39:06 -04:00
James Long 748b0d8836 refactor(tui): remove subtle syntax styles (#36746) 2026-07-13 16:14:56 -04:00
Kit Langton ed1636e3bd fix(tui): clamp dialog selection after options shrink (#36712) 2026-07-13 15:59:04 -04:00
Dax Raad 90a87b2a0c refactor(tui): remove legacy sync context 2026-07-13 15:46:25 -04:00
Kit Langton af8480b2e2 fix(tui): pin queued compaction below output (#36738) 2026-07-13 15:45:27 -04:00
Kit Langton 2096adae12 refactor(tui): remove unused subagent formatters (#36732) 2026-07-13 15:20:03 -04:00
Kit Langton 41349ff20a fix(tui): run dialog actions without selection (#36711) 2026-07-13 15:19:40 -04:00
Kit Langton a2f5a0df5d fix(core): compact after tool settlement (#36718) 2026-07-13 15:17:45 -04:00
Kit Langton 3bc924252b fix(www): generate collections before typecheck (#36730) 2026-07-13 15:07:45 -04:00
Kit Langton 8c1808ab74 fix(tui): standardize OpenCode product casing (#36717) 2026-07-13 15:01:48 -04:00
Dax Raad 685f887771 docs: clarify embedded sdk roadmap 2026-07-13 14:54:40 -04:00
Dax Raad 9db5073eab fix(plugin): retain legacy kv api shape 2026-07-13 12:57:31 -04:00
Dax Raad 775fce0177 refactor(tui): consolidate settings in cli config 2026-07-13 12:55:54 -04:00
Dax Raad 4e74f77e44 delete context.md 2026-07-13 11:49:34 -04:00
Dax Raad b980f74ed9 refactor(tui): reuse dialog select for settings 2026-07-13 11:16:05 -04:00
opencode-agent[bot] 2987c30238 revert(tui): restore list highlights (#36680)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:29:44 -04:00
opencode-agent[bot] 50a1add183 fix(tui): open model selector at top (#36679)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 10:20:15 -04:00
Dax Raad 3533047421 sync 2026-07-13 10:19:04 -04:00
opencode-agent[bot] 823bc20427 fix(tui): use gray list highlights (#36669)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
2026-07-13 10:15:30 -04:00
opencode-agent[bot] 339fd50cb9 fix(tui): preserve modal focus across dialog replacement (#36656)
Co-authored-by: Simon Klee <52795+simonklee@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-13 14:54:59 +02:00
320 changed files with 9091 additions and 8022 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"], desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
-245
View File
@@ -1,245 +0,0 @@
# OpenCode Session Runtime
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
## Language
**Model Context**:
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
_Avoid_: System Context
**Instructions**:
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
_Avoid_: Session Context
**Instruction Source**:
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**InstructionEntry**:
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**Instruction State**:
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
**Instruction Update**:
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
_Avoid_: Correction, stored prose, raw text diff
**Initial Instructions**:
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Instruction Epoch**:
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
**Instruction Values**:
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
**Unavailable Instruction Source**:
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
**Safe Step Boundary**:
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
**Prompt Promotion**:
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Step**:
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
**Assistant Turn**:
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
**Settlement**:
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
**Execution**:
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
**Session Drain**:
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**Native Continuation Metadata**:
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
+82 -50
View File
@@ -32,7 +32,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@corvu/drawer": "catalog:", "@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0", "@dnd-kit/abstract": "0.5.0",
@@ -108,7 +108,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -158,7 +157,7 @@
}, },
"packages/codemode": { "packages/codemode": {
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"acorn": "8.15.0", "acorn": "8.15.0",
"effect": "catalog:", "effect": "catalog:",
@@ -172,7 +171,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -208,7 +207,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -235,7 +234,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -257,7 +256,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -281,7 +280,7 @@
}, },
"packages/console/support": { "packages/console/support": {
"name": "@opencode-ai/console-support", "name": "@opencode-ai/console-support",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
@@ -301,7 +300,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.17.18", "version": "1.17.20",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -309,7 +308,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@@ -318,7 +317,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@@ -355,7 +354,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -397,7 +396,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"effect": "catalog:", "effect": "catalog:",
@@ -457,7 +456,7 @@
}, },
"packages/effect-drizzle-sqlite": { "packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite", "name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -471,7 +470,7 @@
}, },
"packages/effect-sqlite-node": { "packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node", "name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -483,7 +482,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@hono/standard-validator": "catalog:", "@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -515,7 +514,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -531,7 +530,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83",
}, },
@@ -563,7 +562,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
@@ -582,7 +581,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.17.18", "version": "1.17.20",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -593,7 +592,7 @@
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82", "@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.60", "@ai-sdk/cerebras": "2.0.60",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
@@ -602,7 +601,7 @@
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
@@ -657,7 +656,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.10.0", "gitlab-ai-provider": "6.11.1",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -715,7 +714,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
@@ -802,7 +801,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -817,7 +816,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@opencode-ai/server", "name": "@opencode-ai/server",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -834,7 +833,7 @@
}, },
"packages/session-ui": { "packages/session-ui": {
"name": "@opencode-ai/session-ui", "name": "@opencode-ai/session-ui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -895,7 +894,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -908,9 +907,10 @@
}, },
"packages/stats/app": { "packages/stats/app": {
"name": "@opencode-ai/stats-app", "name": "@opencode-ai/stats-app",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/stats-core": "workspace:*", "@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
@@ -941,7 +941,7 @@
}, },
"packages/stats/core": { "packages/stats/core": {
"name": "@opencode-ai/stats-core", "name": "@opencode-ai/stats-core",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-athena": "3.933.0", "@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
@@ -960,7 +960,7 @@
}, },
"packages/stats/server": { "packages/stats/server": {
"name": "@opencode-ai/stats-server", "name": "@opencode-ai/stats-server",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@aws-sdk/client-firehose": "3.933.0", "@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -1001,12 +1001,11 @@
}, },
"packages/tui": { "packages/tui": {
"name": "@opencode-ai/tui", "name": "@opencode-ai/tui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1032,7 +1031,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -1083,7 +1082,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.17.18", "version": "1.17.20",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -1154,6 +1153,7 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -1161,10 +1161,8 @@
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
}, },
"overrides": { "overrides": {
@@ -1200,7 +1198,7 @@
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28", "@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
@@ -1220,7 +1218,7 @@
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.1", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.7",
"remeda": "2.26.0", "remeda": "2.26.0",
@@ -1262,7 +1260,7 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
@@ -1272,7 +1270,7 @@
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
@@ -3124,7 +3122,7 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="], "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.13", "", { "dependencies": { "@tanstack/router-core": "1.171.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.16", "seroval": "^1.5.4" } }, "sha512-o37M3msIK5ec87kPrIYJWXb1XPnjIe5/jrkGLXiXpFuVL99z7mhoBCzftKtVPtzqI8EElnRE/VGFYT9BHNnWcw=="],
@@ -3138,7 +3136,7 @@
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
@@ -4346,7 +4344,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], "gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4856,7 +4854,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
@@ -6342,7 +6340,11 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -6354,9 +6356,9 @@
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6846,7 +6848,7 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6886,6 +6888,8 @@
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -7232,6 +7236,10 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
@@ -7536,7 +7544,7 @@
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -8228,6 +8236,10 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
@@ -8472,6 +8484,14 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8784,6 +8804,10 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -9118,6 +9142,8 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
@@ -9304,6 +9330,10 @@
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -9404,6 +9434,8 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=", "x86_64-linux": "sha256-N4zM1zNufSg8DrDWOHWJYgVpn6vDghX/CJ0pym9ItxI=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=", "aarch64-linux": "sha256-Votrb6IbVt6OS5pcAlBd3L2btkZHa62Eu3mAFzKSlGM=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=", "aarch64-darwin": "sha256-Ofmy6plO4CFt/DoVdyt3Sr2rk6VJhas4zXq3DnvP/6A=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk=" "x86_64-darwin": "sha256-LOeqfqlPbhp1c0Gq56fvKSzve7dvcCwlooTmDMFMznw="
} }
} }
+4 -5
View File
@@ -49,7 +49,7 @@
"@opentui/core": "0.4.3", "@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3", "@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3", "@opentui/solid": "0.4.3",
"@tanstack/solid-virtual": "3.13.28", "@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0", "@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
@@ -76,7 +76,7 @@
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "17.0.1", "marked": "17.0.6",
"marked-shiki": "1.2.1", "marked-shiki": "1.2.1",
"remend": "1.3.0", "remend": "1.3.0",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
@@ -161,10 +161,9 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
} }
} }
@@ -0,0 +1,150 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/FileBrowserSidebar"
const projectID = "proj_file_browser_sidebar"
const sessionID = "ses_file_browser_sidebar"
const title = "File browser sidebar"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The file-browser sidebar must stay mounted across preview/pinned file-tab
// switches. Remounting resets scroll and filter state.
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const panel = page.locator("#review-panel")
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
await expect(sidebar).toBeVisible()
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
await panel.getByRole("button", { name: "file-00.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const scrolled = await viewport.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await writeProbe(page)
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "file-79.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
await panel.getByRole("tab", { name: "file-78.ts" }).click()
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
expect(await readProbe(page)).toBe(PROBE)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function writeProbe(page: Page) {
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
;(el as Probed).__e2eProbe = probe
}, PROBE)
}
async function readProbe(page: Page) {
return page
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
.evaluate((el) => (el as Probed).__e2eProbe)
}
async function setup(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "file-browser-sidebar",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
vcsDiff: [],
fileList: (path) => {
if (path) return []
return files.map((name) => ({
name,
path: name,
absolute: `${directory}/${name}`,
type: "file" as const,
ignored: false,
}))
},
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
)
}
@@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
const panel = page.locator("#review-panel") const panel = page.locator("#review-panel")
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
const contextButton = page.getByRole("button", { name: "View context usage" }) const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await expect(sidebar).toBeVisible() await expect(sidebar).toBeVisible()
await contextButton.click() await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
await expect(sidebar).toHaveCount(0) await expect(sidebar).toBeHidden()
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
const filter = panel.getByRole("combobox", { name: "Filter files" }) const filter = panel.getByRole("combobox", { name: "Filter files" })
await expect(filter).toBeFocused() await expect(filter).toBeFocused()
@@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click() await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0) await expect(sidebar).toHaveCount(0)
@@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter") await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w") await page.keyboard.press("Control+w")
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ }) const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ }) const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]') const review = page.locator('#review-panel [data-component="session-review-v2"]')
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await expectTree(page, 8, "git-0.ts") await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes") await selectMode(page, "Git changes", "Branch changes")
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "action.yml")
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test" import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/RequestDocks" const directory = "C:/OpenCode/RequestDocks"
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
expect(request.postDataJSON()).toEqual({ response: "once" }) expect(request.postDataJSON()).toEqual({ response: "once" })
}) })
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
const transport = await installSseTransport(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
retry: 20,
})
await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
const draft = "keep the caret at the end"
await editor.fill(draft)
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
const cursor = draft.length - 4
await expect
.poll(() =>
editor.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(element)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}),
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "question.asked",
properties: {
id: "question-caret",
sessionID,
questions: [
{
header: "Continue",
question: "Continue?",
options: [{ label: "Yes", description: "Continue the session" }],
},
],
tool: { messageID: "message-caret", callID: "call-caret" },
},
},
})
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
})
async function mockServer( async function mockServer(
page: Page, page: Page,
requests: { requests: {
@@ -150,7 +150,10 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_diff_next_user", parentID: "msg_2000_diff_next_user",
created: 1700000011000, created: 1700000011000,
}) })
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] }) await setupTimeline(page, {
messages: [user, assistantMessage(), nextUser, nextAssistant],
settings: { newLayoutDesigns: false },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.18", "version": "1.17.20",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -0,0 +1,156 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Field } from "@opencode-ai/ui/v2/field-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
const language = useLanguage()
const model = createEditProjectModel(props)
return (
<Dialog fit>
<form onSubmit={model.submit} class="contents">
<DialogHeader>
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInputV2
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
</span>
</button>
<input
ref={(element) => {
model.setIconInput(element)
}}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<TextareaV2
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={3}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</DialogBody>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</ButtonV2>
</DialogFooter>
</form>
</Dialog>
)
}
@@ -1,123 +1,32 @@
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js" import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { type LocalProject, getAvatarColors } from "@/context/layout" import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers" import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global" import { createEditProjectModel } from "./edit-project"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const model = createEditProjectModel(props)
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setStore("dragOver", false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setStore("dragOver", true)
}
function handleDragLeave() {
setStore("dragOver", false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (saveMutation.isPending) return
saveMutation.mutate()
}
return ( return (
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto"> <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0"> <form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<TextField <TextField
autofocus autofocus
type="text" type="text"
label={language.t("dialog.project.edit.name")} label={language.t("dialog.project.edit.name")}
placeholder={folderName()} placeholder={model.folderName()}
value={store.name} value={model.store.name}
onChange={(v) => setStore("name", v)} onChange={(v) => model.setStore("name", v)}
/> />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div class="flex gap-3 items-start"> <div class="flex gap-3 items-start">
<div <div
class="relative" class="relative"
onMouseEnter={() => setStore("iconHover", true)} onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)} onMouseLeave={() => model.setStore("iconHover", false)}
> >
<div <div
class="relative size-16 rounded-md transition-colors cursor-pointer" class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{ classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver, "border-border-base hover:border-border-strong": !model.store.dragOver,
"overflow-hidden": !!store.iconOverride, "overflow-hidden": !!model.store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
}
}} }}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
> >
<Show <Show
when={getProjectAvatarSource(props.project.id, { when={getProjectAvatarSource(props.project.id, {
color: store.color, color: model.store.color,
url: props.project.icon?.url, url: props.project.icon?.url,
override: store.iconOverride, override: model.store.iconOverride,
})} })}
fallback={ fallback={
<div class="size-full flex items-center justify-center"> <div class="size-full flex items-center justify-center">
<Avatar <Avatar
fallback={store.name || defaultName()} fallback={model.store.name || model.defaultName()}
{...getAvatarColors(store.color)} {...getAvatarColors(model.store.color)}
class="size-full text-[32px]" class="size-full text-[32px]"
/> />
</div> </div>
@@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div <div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity" class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{ classList={{
"opacity-100": store.iconHover && !store.iconOverride, "opacity-100": model.store.iconHover && !model.store.iconOverride,
"opacity-0": !(store.iconHover && !store.iconOverride), "opacity-0": !(model.store.iconHover && !model.store.iconOverride),
}} }}
> >
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<div <div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity" class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{ classList={{
"opacity-100": store.iconHover && !!store.iconOverride, "opacity-100": model.store.iconHover && !!model.store.iconOverride,
"opacity-0": !(store.iconHover && !!store.iconOverride), "opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
}} }}
> >
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
@@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<input <input
id="icon-upload" id="icon-upload"
ref={(el) => { ref={(el) => {
iconInput = el model.setIconInput(el)
}} }}
type="file" type="file"
accept="image/*" accept="image/*"
class="hidden" class="hidden"
onChange={handleInputChange} onChange={model.inputChange}
/> />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center"> <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span> <span>{language.t("dialog.project.edit.icon.hint")}</span>
@@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
</div> </div>
</div> </div>
<Show when={!store.iconOverride}> <Show when={!model.store.iconOverride}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label> <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
@@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
<button <button
type="button" type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })} aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={store.color === color} aria-pressed={model.store.color === color}
classList={{ classList={{
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true, "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
store.color === color, model.store.color === color,
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color, model.store.color !== color,
}} }}
onClick={() => { onClick={() => {
if (store.color === color && !props.project.icon?.url) return if (model.store.color === color && !props.project.icon?.url) return
setStore("color", store.color === color ? undefined : color) model.setStore("color", model.store.color === color ? undefined : color)
}} }}
> >
<Avatar <Avatar
fallback={store.name || defaultName()} fallback={model.store.name || model.defaultName()}
{...getAvatarColors(color)} {...getAvatarColors(color)}
class="size-full rounded" class="size-full rounded"
/> />
@@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
label={language.t("dialog.project.edit.worktree.startup")} label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")} description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")} placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={store.startup} value={model.store.startup}
onChange={(v) => setStore("startup", v)} onChange={(v) => model.setStore("startup", v)}
spellcheck={false} spellcheck={false}
class="max-h-14 w-full overflow-y-auto font-mono text-xs" class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/> />
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}> <Button type="button" variant="ghost" size="large" onClick={model.close}>
{language.t("common.cancel")} {language.t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}> <Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")} {model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
+119
View File
@@ -0,0 +1,119 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color,
iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (event) => {
const result = event.target?.result
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
},
}
}
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model" import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@opencode-ai/sdk/v2"
describe("file tree v2 model", () => { describe("buildFileTreeV2Model", () => {
test("builds sorted depth-first rows", () => { test("builds a sorted tree and flattens expanded directories", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8) expect(model.total).toBe(8)
@@ -18,7 +19,7 @@ describe("file tree v2 model", () => {
]) ])
}) })
test("omits descendants of collapsed directories", () => { test("skips children of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
@@ -28,19 +29,46 @@ describe("file tree v2 model", () => {
]) ])
}) })
test("normalizes separators and duplicate paths", () => { test("normalizes duplicate and messy paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true) const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
}) })
test("supports paths deeper than the legacy recursion limit", () => { test("handles deeply nested paths", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts` const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
const model = buildFileTreeV2Model([file]) const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
}) })
}) })
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
"": [
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
],
src: [
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
],
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
}
expect(
flattenLiveFileTreeV2(
(path) => nodes[path] ?? [],
(path) => path === "src",
).map((row) => [row.node.path, row.node.originalPath, row.level]),
).toEqual([
["src", "src", 0],
["src/a.ts", "src/a.ts", 1],
["src/lib", "src/lib", 1],
["README.md", "README.md", 0],
])
})
})
@@ -75,3 +75,33 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows return rows
} }
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
) {
const rows: FileTreeV2Row[] = []
const stack = children("")
.toReversed()
.map((node) => ({ node: toLiveNode(node), level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const nested = children(row.node.originalPath)
for (let index = nested.length - 1; index >= 0; index--) {
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
}
}
return rows
}
function toLiveNode(node: FileNode): FileTreeV2Node {
return {
...node,
path: normalizeFileTreeV2Path(node.path),
originalPath: node.path,
}
}
+60 -29
View File
@@ -16,7 +16,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import {
buildFileTreeV2Model,
flattenFileTreeV2,
flattenLiveFileTreeV2,
normalizeFileTreeV2Path,
type FileTreeV2Node,
} from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element" import { virtualScrollElement } from "@/components/virtual-scroll-element"
export type { Kind } from "@/components/file-tree" export type { Kind } from "@/components/file-tree"
@@ -36,7 +42,7 @@ function guideLineLeft(level: number) {
export const kindLabel = (kind: Kind) => { export const kindLabel = (kind: Kind) => {
if (kind === "add") return "A" if (kind === "add") return "A"
if (kind === "del") return "D" if (kind === "del") return "D"
return "" return "M"
} }
export const kindChange = (kind: Kind) => { export const kindChange = (kind: Kind) => {
@@ -68,7 +74,7 @@ const FileTreeNodeV2 = (
"class", "class",
"classList", "classList",
]) ])
const kind = () => local.kinds?.get(local.node.path) const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path))
return ( return (
<Dynamic <Dynamic
@@ -110,12 +116,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) { function GuideLines(props: { level: number }) {
return ( return (
<For each={Array.from({ length: props.level })}> <For each={Array.from({ length: props.level })}>
{(_, index) => ( {(_, index) => <div data-slot="file-tree-v2-guide" style={`left: ${guideLineLeft(index())}px`} />}
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For> </For>
) )
} }
@@ -126,12 +127,18 @@ export default function FileTreeV2(props: {
kinds?: ReadonlyMap<string, Kind> kinds?: ReadonlyMap<string, Kind>
draggable?: boolean draggable?: boolean
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
onFileDoubleClick?: (file: FileNode) => void
}) { }) {
const file = useFile() const file = useFile()
const live = () => props.allowed === undefined
const draggable = () => props.draggable ?? true const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "") const active = () => normalizeFileTreeV2Path(props.active ?? "")
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? [])))
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live()
const rows = createMemo(() => {
if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded)
return flattenFileTreeV2(model()!, expanded)
})
const [root, setRoot] = createSignal<HTMLDivElement>() const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>() const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -155,16 +162,49 @@ export default function FileTreeV2(props: {
return [...indexes, index].sort((a, b) => a - b) return [...indexes, index].sort((a, b) => a - b)
}, },
}) })
createEffect(() => {
if (!live()) return
void file.tree.list("")
})
// Only scroll when the active path changes (or first appears in the tree).
// Do not re-scroll when expand/collapse reshuffles `rows()`.
let scrolledActive: string | undefined
createEffect(() => { createEffect(() => {
const path = active() const path = active()
if (!path) return if (!path) {
scrolledActive = undefined
return
}
const index = rows().findIndex((row) => row.node.path === path) const index = rows().findIndex((row) => row.node.path === path)
if (index < 0) return if (index < 0) return
if (scrolledActive === path) return
scrolledActive = path
queueMicrotask(() => { queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return const next = rows().findIndex((row) => row.node.path === path)
virtualizer.scrollToIndex(index, { align: "auto" }) if (next < 0) return
if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(next, { align: "auto" })
}) })
}) })
const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => {
action?.({
...node,
path: node.originalPath,
absolute: node.originalPath,
})
}
const toggleDirectory = (path: string, originalPath: string) => {
if (expanded(path)) {
file.tree.collapse(originalPath)
return
}
file.tree.expand(originalPath, live() ? undefined : { list: false })
}
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
const virtualItemByKey = createMemo( const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
@@ -175,7 +215,7 @@ export default function FileTreeV2(props: {
<div <div
ref={setRoot} ref={setRoot}
data-component="file-tree-v2" data-component="file-tree-v2"
data-total-rows={model().total} data-total-rows={live() ? rows().length : model()!.total}
class="group/file-tree-v2" class="group/file-tree-v2"
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }} style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
> >
@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative" class="relative"
onFocus={() => setFocused(row().node.path)} onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)} onBlur={() => setFocused(undefined)}
onClick={() => onClick={() => selectFile(row().node, props.onFileClick)}
props.onFileClick?.({ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
...row().node,
path: row().node.originalPath,
absolute: row().node.originalPath,
})
}
> >
<GuideLines level={row().level} /> <GuideLines level={row().level} />
<Show when={row().level > 0}> <Show when={row().level > 0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative" class="relative"
onFocus={() => setFocused(row().node.path)} onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)} onBlur={() => setFocused(undefined)}
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} aria-expanded={expanded(row().node.path)}
onClick={() => onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
file.tree.state(row().node.path)?.expanded === false
? file.tree.expand(row().node.path, { list: false })
: file.tree.collapse(row().node.path)
}
> >
<GuideLines level={row().level} /> <GuideLines level={row().level} />
<div <div
data-slot="file-tree-v2-chevron" data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""} data-expanded={expanded(row().node.path) ? "" : undefined}
class="size-4 flex items-center justify-center" class="size-4 flex items-center justify-center"
> >
<Icon name="chevron-down" /> <Icon name="chevron-down" />
+20 -3
View File
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import { import {
ContentPart, ContentPart,
DEFAULT_PROMPT, DEFAULT_PROMPT,
isCommentItem,
isPromptEqual, isPromptEqual,
Prompt, Prompt,
usePrompt, usePrompt,
@@ -632,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => { const handleBlur = () => {
savedCursor = currentCursor() const cursor = currentCursor()
savedCursor = cursor
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover() closePopover()
setComposing(false) setComposing(false)
} }
@@ -1626,7 +1629,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)} )}
/> />
<PromptContextItems <PromptContextItems
items={contextItems()} items={contextItems().filter((item) => !isCommentItem(item))}
active={(item) => { active={(item) => {
const active = comments.active() const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1636,6 +1639,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID) if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key) prompt.context.remove(item.key)
}} }}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<PromptImageAttachments <PromptImageAttachments
@@ -1645,6 +1649,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
comments={contextItems().filter(isCommentItem)}
commentActive={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
onOpenComment={openComment}
onRemoveComment={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
/> />
<div <div
class="relative min-h-[52px]" class="relative min-h-[52px]"
@@ -1852,6 +1867,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID) if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key) prompt.context.remove(item.key)
}} }}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<PromptImageAttachments <PromptImageAttachments
@@ -1861,6 +1877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
onRemove={removeAttachment} onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={props.controls.newLayoutDesigns}
/> />
<div <div
class="relative" class="relative"
@@ -2341,7 +2358,7 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
/> />
)} )}
</Show> </Show>
<span class="truncate">{props.state.modelName}</span> <span class="truncate leading-4">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}> <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" /> <Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span> </span>
@@ -1,6 +1,8 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
import type { ContextItem } from "@/context/prompt" import type { ContextItem } from "@/context/prompt"
@@ -12,6 +14,7 @@ type ContextItemsProps = {
active: (item: PromptContextItem) => boolean active: (item: PromptContextItem) => boolean
openComment: (item: PromptContextItem) => void openComment: (item: PromptContextItem) => void
remove: (item: PromptContextItem) => void remove: (item: PromptContextItem) => void
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@@ -27,10 +30,17 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
const selected = props.active(item) const selected = props.active(item)
return ( return (
<TooltipV2 <Dynamic
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
value={ value={
<span class="flex max-w-[300px]"> <span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0"> <span
classList={{
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-invert-base": !props.newLayoutDesigns,
}}
>
{directory} {directory}
</span> </span>
<span class="shrink-0">{filename}</span> <span class="shrink-0">{filename}</span>
@@ -78,7 +88,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show> </Show>
</div> </div>
</TooltipV2> </Dynamic>
) )
}} }}
</For> </For>
@@ -0,0 +1,43 @@
@keyframes prompt-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-slot="prompt-attachments"] {
timeline-scope: --prompt-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
[data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-attachments-scroll x;
}
[data-slot="prompt-attachments-fade-left"] {
animation: prompt-attachments-fade-left linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: 0 0.1px;
}
[data-slot="prompt-attachments-fade-right"] {
animation: prompt-attachments-fade-right linear both;
animation-timeline: --prompt-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -1,61 +1,168 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { ImageAttachmentPart } from "@/context/prompt" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
import "./image-attachments.css"
type PromptCommentItem = ContextItem & { key: string }
type PromptImageAttachmentsProps = { type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[] attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void onOpen: (attachment: ImageAttachmentPart) => void
onRemove: (id: string) => void onRemove: (id: string) => void
removeLabel: string removeLabel: string
newLayoutDesigns: boolean
comments?: PromptCommentItem[]
commentActive?: (item: PromptCommentItem) => boolean
onOpenComment?: (item: PromptCommentItem) => void
onRemoveComment?: (item: PromptCommentItem) => void
} }
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass = const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
const imageHairlineClassV2 =
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
const removeClass = const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const removeClassV2 =
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => { export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return ( return (
<Show when={props.attachments.length > 0}> <Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
<div
data-slot="prompt-attachments-scroll"
classList={{
"flex gap-2": true,
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
}}
>
<Show when={props.newLayoutDesigns}>
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
selection={item.selection}
active={props.commentActive?.(item)}
onClick={() => props.onOpenComment?.(item)}
/>
</TooltipV2>
<button
type="button"
onClick={() => props.onRemoveComment?.(item)}
class={removeClassV2}
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</Show>
<For each={props.attachments}> <For each={props.attachments}>
{(attachment) => ( {(attachment) => {
<Tooltip value={attachment.filename} placement="top" contentClass="break-all"> const image = attachment.mime.startsWith("image/")
<div class="relative group"> const media = () => (
<Show <Show
when={attachment.mime.startsWith("image/")} when={image}
fallback={
<Show
when={props.newLayoutDesigns}
fallback={ fallback={
<div class={fallbackClass}> <div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" /> <Icon name="folder" class="size-6 text-text-weak" />
</div> </div>
} }
>
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2>
</Show>
}
> >
<img <img
src={attachment.dataUrl} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class={imageClass} class={props.newLayoutDesigns ? imageClassV2 : imageClass}
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
/> />
</Show> </Show>
<button )
type="button" const name = () => (
onClick={() => props.onRemove(attachment.id)}
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class={nameClass}> <div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div> </div>
)
const remove = () => (
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
aria-label={props.removeLabel}
>
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</Show>
</button>
)
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
return (
<Show
when={props.newLayoutDesigns}
fallback={
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<div class="relative group">
{media()}
{name()}
{remove()}
</div> </div>
</Tooltip> </Tooltip>
)} }
>
<div class="relative group shrink-0">
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
{media()}
<Show when={image}>
<div class={imageHairlineClassV2} />
</Show>
</TooltipV2>
{remove()}
</div>
</Show>
)
}}
</For> </For>
</div> </div>
<Show when={props.newLayoutDesigns}>
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</Show>
</div>
</Show> </Show>
) )
} }
@@ -1,5 +1,6 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
@@ -96,10 +97,17 @@ export function PromptWorkspaceSelector(props: {
{(branch) => ( {(branch) => (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={branch()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]"> <div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span> <span class="min-w-0 truncate">{branch()}</span>
</div> </div>
</TooltipV2>
</> </>
)} )}
</Show> </Show>
@@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header" export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab" export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view" export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view" export { NewSessionDesignView } from "./session-new-design-view"
@@ -0,0 +1,98 @@
import { For, Show } from "solid-js"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
export function OpenInAppV2(props: { directory: () => string }) {
const language = useLanguage()
const state = useOpenInApp(props)
return (
<Show when={props.directory() && state.canOpen()}>
<SplitButtonV2 class="session-review-v2-open-in-app" onPointerDown={(event) => event.stopPropagation()}>
<TooltipV2
placement="bottom"
value={language.t("session.header.open.ariaLabel", { app: state.current().label })}
class="flex items-center"
>
<SplitButtonV2Action
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
state.openDir(state.current().id)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
>
<Show when={state.opening()} fallback={<AppIcon id={state.current().icon} class="size-[18px]" />}>
<Spinner class="size-3.5" />
</Show>
</SplitButtonV2Action>
</TooltipV2>
<MenuV2
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
>
<MenuV2.Trigger
as={SplitButtonV2MenuTrigger}
disabled={state.opening()}
aria-label={language.t("session.header.open.menu")}
onPointerDown={(event) => event.stopPropagation()}
>
<IconV2 name="chevron-down" size="small" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="open-in-app-v2-menu">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.header.openIn")}</MenuV2.GroupLabel>
<MenuV2.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<MenuV2.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
<MenuV2.Separator />
<MenuV2.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</SplitButtonV2>
</Show>
)
}
@@ -0,0 +1,229 @@
import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
export const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
export type OpenApp = (typeof OPEN_APPS)[number]
export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
export const MAC_OPEN_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const WINDOWS_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export const LINUX_OPEN_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): OpenAppOS {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
export function openAppFileManager(os: OpenAppOS) {
if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
}
export function openAppsForOS(os: OpenAppOS) {
if (os === "macos") return MAC_OPEN_APPS
if (os === "windows") return WINDOWS_OPEN_APPS
return LINUX_OPEN_APPS
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function useOpenInApp(input: { directory: () => string }) {
const platform = usePlatform()
const server = useServer()
const language = useLanguage()
const os = createMemo(() => detectOpenAppOS(platform))
const apps = createMemo(() => openAppsForOS(os()))
const fileManager = createMemo(() => openAppFileManager(os()))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const selectApp = (app: OpenApp | "finder") => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp | "finder") => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = input.directory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = input.directory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
return {
canOpen,
opening,
current,
options,
menu,
setMenu,
openDir,
selectApp,
copyPath,
}
}
@@ -0,0 +1,66 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { FileVisual } from "./session-sortable-tab"
export function SortableTabV2(props: {
tab: string
index: () => number
temporary?: boolean
onTabClose: (tab: string) => void
onTabDoubleClick?: (tab: string) => void
}): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = useSortable({
get id() {
return props.tab
},
get index() {
return props.index()
},
})
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} temporary={props.temporary} />
})
return (
<div ref={sortable.ref} class="h-full flex items-center">
<div class="relative">
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
test("uses the critical token only after the server connection drops", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
})
test("stays neutral before status is ready", () => {
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
})
})
describe("hasNonBlockingServiceIssue", () => {
test("detects MCP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
})
test("detects LSP failures that do not block chatting", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
@@ -0,0 +1,19 @@
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpStatus["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
+25 -43
View File
@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +20,14 @@ export function StatusPopover() {
const global = useGlobal() const global = useGlobal()
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready) const serverHealth = () => global.servers.health[server.key]?.healthy
const mcpIssue = createMemo(() => { const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const mcp = Object.values(sync().data.mcp ?? {}) const issue = createMemo(() =>
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") hasNonBlockingServiceIssue({
const warn = mcp.some((item) => item.status === "needs_auth") mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
if (failed) return "critical" as const lsp: (sync().data.lsp ?? []).map((item) => item.status),
if (warn) return "warning" as const }),
}) )
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
return ( return (
<Popover <Popover
@@ -47,13 +46,11 @@ export function StatusPopover() {
<Icon name={shown() ? "status-active" : "status"} size="small" /> <Icon name={shown() ? "status-active" : "status"} size="small" />
</div> </div>
<div <div
classList={{ class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
"absolute -top-px -right-px size-1.5 rounded-full": true, ready: ready(),
"bg-icon-success-base": ready() && healthy(), serverHealth: serverHealth(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning", issue: issue(),
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"), })}`}
"bg-border-weak-base": serverHealthy() || !ready(),
}}
/> />
</div> </div>
} }
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
const sync = useSync() const sync = useSync()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const mcpIssue = createMemo(() => { const issue = createMemo(() =>
const mcp = Object.values(sync().data.mcp ?? {}) hasNonBlockingServiceIssue({
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
const warn = mcp.some((item) => item.status === "needs_auth") lsp: (sync().data.lsp ?? []).map((item) => item.status),
if (failed) return "critical" as const }),
if (warn) return "warning" as const )
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
shown: shown(), shown: shown(),
ready: ready(), ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(), serverHealth: serverHealth(),
issue: mcpIssue(), issue: issue(),
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
const state = createMemo<StatusPopoverState>(() => ({ const state = createMemo<StatusPopoverState>(() => ({
shown: shown(), shown: shown(),
ready: serverHealth() !== undefined, ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(), serverHealth: serverHealth(),
issue: false,
label: language.t("status.popover.trigger"), label: language.t("status.popover.trigger"),
onOpenChange: setShown, onOpenChange: setShown,
body: () => ( body: () => (
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
type StatusPopoverState = { type StatusPopoverState = {
shown: boolean shown: boolean
ready: boolean ready: boolean
healthy: boolean
serverHealth: boolean | undefined serverHealth: boolean | undefined
issue?: "critical" | "warning" issue: boolean
label: string label: string
onOpenChange: (value: boolean) => void onOpenChange: (value: boolean) => void
body: () => JSX.Element body: () => JSX.Element
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
} }
function StatusPopoverView(props: { state: StatusPopoverState }) { function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = { const popoverProps = {
class: class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
<div class="relative size-4"> <div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} /> <IconV2 name={props.state.shown ? "status-active" : "status"} />
<div <div
classList={statusDotClass()} class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/> />
</div> </div>
} }
+12 -4
View File
@@ -47,12 +47,20 @@ export function getAvatarColors(key?: string) {
} }
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
if (key === "orange") return "orange"
if (key === "pink") return "pink"
if (key === "cyan") return "cyan"
if (key === "purple") return "purple"
if (key === "mint") return "cyan" if (key === "mint") return "cyan"
if (key === "lime") return "green" if (key === "lime") return "green"
if (
key === "orange" ||
key === "yellow" ||
key === "cyan" ||
key === "green" ||
key === "red" ||
key === "pink" ||
key === "blue" ||
key === "purple" ||
key === "gray"
)
return key
return "gray" return "gray"
} }
+3
View File
@@ -37,6 +37,9 @@ type PlatformBase = {
/** Open a local path in a local app (desktop only) */ /** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void> openPath?(path: string, app?: string): Promise<void>
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>
/** Restart the app */ /** Restart the app */
restart(): Promise<void> restart(): Promise<void>
+1 -1
View File
@@ -145,7 +145,7 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}` return `${key}:c=${digest.slice(0, 8)}`
} }
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim() return item.type === "file" && !!item.comment?.trim()
} }
+1
View File
@@ -24,6 +24,7 @@ export {
createPromptSession, createPromptSession,
createPromptState, createPromptState,
DEFAULT_PROMPT, DEFAULT_PROMPT,
isCommentItem,
isPromptEqual, isPromptEqual,
} from "./prompt-state" } from "./prompt-state"
export type { export type {
+1 -1
View File
@@ -638,7 +638,7 @@ export const dict = {
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab", "session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server", "session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "{{count}} Files Changed", "session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change", "session.review.change.one": "Change",
"session.review.change.other": "Changes", "session.review.change.other": "Changes",
"session.review.loadingChanges": "Loading changes...", "session.review.loadingChanges": "Loading changes...",
@@ -2,11 +2,30 @@ import { describe, expect, test } from "bun:test"
import { shouldOpenSessionInBackground } from "./home-session-open" import { shouldOpenSessionInBackground } from "./home-session-open"
describe("shouldOpenSessionInBackground", () => { describe("shouldOpenSessionInBackground", () => {
test("opens middle clicks in the background", () => {
expect(
shouldOpenSessionInBackground({ button: 1, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 2, mac: true, meta: false, ctrl: false, shift: false, alt: false }),
).toBe(false)
})
test("requires only the platform primary modifier", () => { test("requires only the platform primary modifier", () => {
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) expect(
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: false, alt: false }),
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) ).toBe(true)
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) expect(
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: false }),
).toBe(true)
expect(
shouldOpenSessionInBackground({ button: 0, mac: true, meta: true, ctrl: false, shift: true, alt: false }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: false, ctrl: true, shift: false, alt: true }),
).toBe(false)
expect(
shouldOpenSessionInBackground({ button: 0, mac: false, meta: true, ctrl: false, shift: false, alt: false }),
).toBe(false)
}) })
}) })
@@ -1,10 +1,13 @@
export function shouldOpenSessionInBackground(input: { export function shouldOpenSessionInBackground(input: {
button: number
mac: boolean mac: boolean
meta: boolean meta: boolean
ctrl: boolean ctrl: boolean
shift: boolean shift: boolean
alt: boolean alt: boolean
}) { }) {
if (input.button === 1) return true
if (input.button !== 0) return false
if (input.shift || input.alt) return false if (input.shift || input.alt) return false
if (input.mac) return input.meta && !input.ctrl if (input.mac) return input.meta && !input.ctrl
return input.ctrl && !input.meta return input.ctrl && !input.meta
+1
View File
@@ -244,6 +244,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
// background without navigating, matching browser conventions. // background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) { function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({ return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey, meta: event.metaKey,
ctrl: event.ctrlKey, ctrl: event.ctrlKey,
+40 -3
View File
@@ -1,4 +1,5 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import { import {
@@ -57,6 +58,8 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal" import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { import {
createPromptInputController, createPromptInputController,
@@ -79,6 +82,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
@@ -1015,7 +1019,10 @@ export default function Page() {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || isChildSession()) return if (composer.blocked() || isChildSession()) return
inputRef?.focus() const input = inputRef
if (!input) return
input.focus()
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
} }
} }
@@ -1867,7 +1874,30 @@ export default function Page() {
.map((item) => ({ id: item.id, text: line(item.id) })) .map((item) => ({ id: item.id, text: line(item.id) }))
}) })
const actions = { revert } // attachment bytes are embedded as a data URL, so downloading always works;
// revealing requires the on-disk path captured by the client that attached the file
const openAttachment = (file: FilePart) => {
const download = () => {
const anchor = document.createElement("a")
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.click()
}
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
return
}
download()
}
const actions = { revert, openAttachment }
createEffect(() => { createEffect(() => {
const sessionID = params.id const sessionID = params.id
@@ -2232,6 +2262,13 @@ export default function Page() {
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount} reviewCount={reviewCount}
reviewPanel={reviewPanelV2} reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State} fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()} activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff} focusReviewDiff={focusReviewDiff}
+329 -1
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js" import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,9 +6,12 @@ import type { FileSearchHandle } from "@opencode-ai/session-ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations" import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode-ai/core/util/encode" import { sampledChecksum } from "@opencode-ai/core/util/encode"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
@@ -16,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSettings } from "@/context/settings"
import { getSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
@@ -53,6 +57,30 @@ function FileCommentMenu(props: {
) )
} }
function FileCommentMenuV2(props: {
moreLabel: string
editLabel: string
deleteLabel: string
onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<MenuV2 gutter={4}>
<MenuV2.Trigger as="button" type="button" data-slot="line-comment-v2-overflow" aria-label={props.moreLabel}>
<LineCommentV2OverflowIcon />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={props.onEdit}>{props.editLabel}</MenuV2.Item>
<MenuV2.Item onSelect={props.onDelete}>{props.deleteLabel}</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
)
}
type ScrollPos = { x: number; y: number } type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) { function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
@@ -180,6 +208,15 @@ export function FileTabContent(props: { tab: string }) {
} }
export function SessionFileView(props: { tab: string }) { export function SessionFileView(props: { tab: string }) {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
<SessionFileViewV2 tab={props.tab} />
</Show>
)
}
function SessionFileViewV1(props: { tab: string }) {
const file = useFile() const file = useFile()
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
@@ -463,3 +500,294 @@ export function SessionFileView(props: { tab: string }) {
return content() return content()
} }
function SessionFileViewV2(props: { tab: string }) {
const file = useFile()
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
const fileComponent = useFileComponent()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let find: FileSearchHandle | null = null
const search = {
register: (handle: FileSearchHandle | null) => {
find = handle
},
}
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
})
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
const activeSelection = () => note.selected ?? selectedLines()
const commentsUi = createLineCommentControllerV2({
comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
getSide: (range) => range.endSide ?? range.side ?? "additions",
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onUpdate: ({ id, comment, selection }) => {
const p = path()
if (!p) return
updateCommentInContext({ id, file: p, selection, comment })
},
onDelete: (comment) => {
const p = path()
if (!p) return
removeCommentFromContext({ id: comment.id, file: p })
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenuV2
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
onEdit={controls.edit}
onDelete={controls.remove}
/>
),
})
createEffect(() => {
if (typeof window === "undefined") return
const onKeyDown = (event: KeyboardEvent) => {
if (activeFileTab() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
event.preventDefault()
event.stopPropagation()
find?.focus()
}
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeFileTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
let prev = {
loaded: false,
ready: false,
active: false,
}
createEffect(() => {
const loaded = !!state()?.loaded
const ready = file.ready()
const active = activeFileTab() === props.tab
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
enableGutterUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderGutterUtility={commentsUi.renderGutterUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
commentsUi.note.select(null)
commentsUi.note.cancelDraft()
return
}
commentsUi.onLineSelectionEnd(range)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineNumberSelectionEnd(range)
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/>
</div>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</div>
)
return content()
}
@@ -1,28 +1,46 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import type { DragEvent } from "@thisbeyond/solid-dnd" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab" const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel" const reviewTabPanelID = "session-side-panel-review-tabpanel"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
@@ -47,6 +65,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: () => boolean reviewHasFocusableContent: () => boolean
reviewCount: () => number reviewCount: () => number
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
fileBrowserState?: SessionFileBrowserState fileBrowserState?: SessionFileBrowserState
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
@@ -60,7 +79,9 @@ export function SessionSidePanel(props: {
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const sdk = useSDK()
const { sessionKey, tabs, view, params } = useSessionLayout() const { sessionKey, tabs, view, params } = useSessionLayout()
const projectDirectory = createMemo(() => sdk().directory)
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = settings.visibility.fileTree const shown = settings.visibility.fileTree
@@ -92,11 +113,9 @@ export function SessionSidePanel(props: {
return "mix" as const return "mix" as const
} }
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">() const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) { for (const diff of diffs()) {
const file = normalize(diff.file) const file = normalizeFileTreeV2Path(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind) out.set(file, kind)
@@ -153,6 +172,7 @@ export function SessionSidePanel(props: {
fileBrowser: () => !!props.fileBrowserState, fileBrowser: () => !!props.fileBrowserState,
}) })
const contextOpen = tabState.contextOpen const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab const activeTab = tabState.activeTab
@@ -170,10 +190,8 @@ export function SessionSidePanel(props: {
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
} }
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
let fileFilter: HTMLInputElement | undefined let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview const temporaryTab = tabs().preview
const previewTab = (value: string) => { const previewTab = (value: string) => {
const next = normalizeTab(value) const next = normalizeTab(value)
@@ -196,10 +214,26 @@ export function SessionSidePanel(props: {
} }
const browserTab = createMemo(() => { const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined if (!props.fileBrowserState) return undefined
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab() return activeFileTab()
}) })
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => { const handleDragStart = (event: unknown) => {
const id = getDraggableId(event) const id = getDraggableId(event)
@@ -285,6 +319,9 @@ export function SessionSidePanel(props: {
"bg-background-base": !settings.general.newLayoutDesigns(), "bg-background-base": !settings.general.newLayoutDesigns(),
}} }}
> >
<Show
when={props.fileBrowserState}
fallback={
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -405,10 +442,6 @@ export function SessionSidePanel(props: {
iconSize="large" iconSize="large"
class="!rounded-md" class="!rounded-md"
onClick={() => { onClick={() => {
if (props.fileBrowserState) {
openFileBrowser()
return
}
void import("@/components/dialog-select-file").then((x) => { void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />) dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}) })
@@ -454,20 +487,7 @@ export function SessionSidePanel(props: {
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={browserTab()}> <Show when={activeFileTab()} keyed>
<SessionFileBrowserTab
tab={browserTab()!}
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
active={file.pathFromTab(browserTab()!)}
kinds={browserKinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</Show>
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />} {(tab) => <FileTabContent tab={tab} />}
</Show> </Show>
</Tabs> </Tabs>
@@ -486,6 +506,229 @@ export function SessionSidePanel(props: {
</Show> </Show>
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>
}
>
<DndKitProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
event.target instanceof Element &&
(!!event.target.closest('[data-slot="tabs-trigger-close-button"]') ||
!!event.target.closest(".session-review-v2-open-in-app-slot")),
}),
]}
modifiers={[
RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => tabList ?? null }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source) || source.initialIndex === source.index) return
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
<Show when={props.reviewSidebarToggle}>
{(toggle) => (
<div class="h-full shrink-0 flex items-center justify-center">
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
)}
</Show>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
{props.hasReview()
? language.t("session.review.filesChanged", { count: props.reviewCount() })
: language.t("session.tab.review")}
</Tabs.Trigger>
</Show>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<For each={panelTabs()}>
{(tab) => (
<Show
when={tab === SESSION_OPEN_FILE_TAB}
fallback={
<SortableTabV2
tab={tab}
index={() => tabs().all().indexOf(tab)}
temporary={temporaryTab() === tab}
onTabClose={tabs().close}
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
/>
}
>
<Tabs.Trigger
value={SESSION_OPEN_FILE_TAB}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
gutter={10}
>
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
>
<div class="flex items-center gap-1.5 italic">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Tabs.Trigger>
</Show>
)}
</For>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<TooltipV2
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
placement="bottom"
class="flex items-center"
>
<IconButtonV2
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</TooltipV2>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppV2 directory={projectDirectory} />
</div>
</div>
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
<div
id={reviewTabPanelID}
role="tabpanel"
aria-labelledby={reviewTabID}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
>
{props.reviewPanel()}
</div>
</Show>
<Show when={activeTab() === "empty"}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Tabs.Content>
</Show>
<Show when={activeTab() === "context"}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Tabs.Content>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
role="tabpanel"
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: !fileBrowserVisible() }}
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState!}
onSelect={(path) => previewTab(file.tab(path))}
onSelectPermanent={(path) => openTab(file.tab(path))}
filterRef={(element) => (fileFilter = element)}
/>
</div>
</Show>
</Tabs>
</DndKitProvider>
</Show>
</div> </div>
</div> </div>
</Show> </Show>
@@ -513,10 +756,19 @@ export function SessionSidePanel(props: {
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount()}{" "} {props.reviewCount()}{" "}
{language.t( {language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)} )}
</>
}
>
{language.t("session.review.filesChanged", { count: props.reviewCount() })}
</Show>
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")} {language.t("session.files.all")}
@@ -327,6 +327,7 @@ export function MessageTimeline(props: {
parts: getMsgParts, parts: getMsgParts,
status: sessionStatus, status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries, showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
}) })
const activeMessageID = projection.activeMessageID const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1135,6 +1136,10 @@ export function MessageTimeline(props: {
const m = messageByID().get(userMessageRow().userMessageID) const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m if (m?.role === "user") return m
}) })
const messageComments = createMemo(() => {
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return ( return (
<TimelineRowFrame row={userMessageRow}> <TimelineRowFrame row={userMessageRow}>
<Show when={message()}> <Show when={message()}>
@@ -1146,6 +1151,7 @@ export function MessageTimeline(props: {
parts={getMsgParts(userMessageRow().userMessageID)} parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions} actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()} useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/> />
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@ export function createTimelineProjection(input: {
parts: (messageID: string) => Part[] parts: (messageID: string) => Part[]
status: Accessor<SessionStatus> status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean> showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
}) { }) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => { const assistantMessagesByParent = createMemo(() => {
@@ -59,6 +60,7 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(), input.showReasoningSummaries(),
input.status().type, input.status().type,
activeMessageID() === userMessage.id, activeMessageID() === userMessage.id,
input.inlineComments(),
), ),
), ),
), ),
@@ -38,6 +38,8 @@ export namespace Timeline {
showReasoning: boolean, showReasoning: boolean,
status: SessionStatus["type"], status: SessionStatus["type"],
isActive: boolean, isActive: boolean,
// v2 renders comments inside the user message attachments row instead of a strip row
inlineComments: boolean,
) { ) {
const rows: TimelineRow.TimelineRow[] = [] const rows: TimelineRow.TimelineRow[] = []
@@ -74,7 +76,7 @@ export namespace Timeline {
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0) if (comments.length > 0 && !inlineComments)
rows.push( rows.push(
new TimelineRow.CommentStrip({ new TimelineRow.CommentStrip({
userMessageID: userMessage.id, userMessageID: userMessage.id,
@@ -84,7 +86,7 @@ export namespace Timeline {
rows.push( rows.push(
new TimelineRow.UserMessage({ new TimelineRow.UserMessage({
userMessageID: userMessage.id, userMessageID: userMessage.id,
anchor: comments.length === 0, anchor: inlineComments || comments.length === 0,
}), }),
) )
@@ -5,7 +5,6 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2, SessionReviewV2,
SessionReviewV2Sidebar, SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode-ai/session-ui/v2/session-review-v2" } from "@opencode-ai/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2" import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
@@ -65,6 +64,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
) )
const searching = createMemo(() => props.state.filter().trim().length > 0) const searching = createMemo(() => props.state.filter().trim().length > 0)
const kinds = createMemo(() => reviewDiffKinds(diffs())) const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
const activeDiff = createMemo(() => { const activeDiff = createMemo(() => {
// A focused comment takes over the preview until the preview applies it and // A focused comment takes over the preview until the preview applies it and
// clears the focus; the owner then persists the file as the active selection. // clears the focus; the owner then persists the file as the active selection.
@@ -112,9 +113,6 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
stats={<DiffChanges changes={diffs()} />} stats={<DiffChanges changes={diffs()} />}
empty={props.empty} empty={props.empty}
sidebarOpen={props.state.sidebarOpened()} sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
}
sidebar={ sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown, // Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs. // which must stay reachable when the current mode has zero diffs.
@@ -126,7 +124,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) {
diffs={diffs} diffs={diffs}
filteredFiles={filteredFiles} filteredFiles={filteredFiles}
searching={searching} searching={searching}
kinds={kinds} kinds={treeKinds}
activeDiff={activeDiff} activeDiff={activeDiff}
/> />
} }
@@ -1,14 +1,9 @@
import { createMemo, createSignal, createUniqueId, Show } from "solid-js" import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
import { createQuery } from "@tanstack/solid-query" import { createQuery } from "@tanstack/solid-query"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
SessionFilePanelV2, import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2"
SessionFilePanelV2Empty, import FileTreeV2, { type Kind } from "@/components/file-tree-v2"
SessionFilePanelV2Title,
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
import FileTree, { type Kind } from "@/components/file-tree"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -93,18 +88,11 @@ export function SessionFileBrowserTab(props: {
}) })
} }
// Keep the sidebar outside Kobalte Tabs.Content: a morphing content value
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return ( return (
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
<SessionFilePanelV2 <SessionFilePanelV2
toolbar toolbar={false}
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={ sidebar={
<SessionReviewV2Sidebar <SessionReviewV2Sidebar
open={sidebarOpened()} open={sidebarOpened()}
@@ -123,9 +111,7 @@ export function SessionFileBrowserTab(props: {
<Show <Show
when={query()} when={query()}
fallback={ fallback={
<FileTree <FileTreeV2
path=""
class="pt-1"
active={props.active} active={props.active}
kinds={props.kinds} kinds={props.kinds}
onFileClick={(node) => props.onSelect(node.path)} onFileClick={(node) => props.onSelect(node.path)}
@@ -189,6 +175,5 @@ export function SessionFileBrowserTab(props: {
</div> </div>
</Show> </Show>
</SessionFilePanelV2> </SessionFilePanelV2>
</Tabs.Content>
) )
} }
+41 -14
View File
@@ -1,8 +1,32 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js" import { createRoot, createSignal } from "solid-js"
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items" import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
test("end anchoring survives consecutive resizes when the first scroll write is clamped", () => {
const writes: { offset: number; adjustments?: number }[] = []
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 5,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 200 },
anchorTo: "end",
scrollEndThreshold: 1,
getScrollElement: () => null,
scrollToFn: (offset, options) => writes.push({ offset, adjustments: options.adjustments }),
observeElementRect: () => {},
observeElementOffset: () => {},
})
virtualizer.getTotalSize()
virtualizer.resizeItem(4, 120)
expect(writes).toEqual([{ offset: 50, adjustments: 70 }])
writes.length = 0
virtualizer.resizeItem(4, 200)
expect(writes).toEqual([{ offset: 120, adjustments: 80 }])
})
test("reactive count updates preserve measured row sizes", () => { test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => { createRoot((dispose) => {
const [count, setCount] = createSignal(2) const [count, setCount] = createSignal(2)
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
}) })
}) })
test("logical scroll offset includes pending measurement adjustments", () => { test("clamps oversized offsets with scroll margin and padding changes", () => {
createRoot((dispose) => { const options = (paddingEnd: number) => ({
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ count: 20,
count: 2,
getScrollElement: () => null,
estimateSize: () => 60, estimateSize: () => 60,
initialOffset: 100, initialOffset: Number.MAX_SAFE_INTEGER,
initialRect: { width: 800, height: 60 }, initialRect: { width: 800, height: 600 },
scrollMargin: 64,
paddingEnd,
overscan: 1,
getScrollElement: () => null,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
}) })
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
virtualizer.getTotalSize() expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100) virtualizer.setOptions(options(600))
expect(virtualizer.getLogicalScrollOffset()).toBe(140) expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
dispose()
})
}) })
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => { test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
-1
View File
@@ -36,7 +36,6 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*", "@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*", "@opencode-ai/tui": "workspace:*",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@@ -12,11 +12,11 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(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 response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write( process.stdout.write(
JSON.stringify( JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)), response.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null, null,
2, 2,
) + EOL, ) + EOL,
+7 -10
View File
@@ -1,7 +1,6 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui" import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Config } from "../../config" import { Config } from "../../config"
@@ -9,6 +8,7 @@ import { Effect, Option } from "effect"
import { Server } from "../../services/server" import { Server } from "../../services/server"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/core/npm"
export default Runtime.handler(Commands, (input) => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -36,7 +36,7 @@ export default Runtime.handler(Commands, (input) =>
) )
preflight.loading() preflight.loading()
const config = yield* Config.Service const config = yield* Config.Service
let disposeSlots: (() => void) | undefined const npm = yield* Npm.Service
const context = yield* Effect.context() const context = yield* Effect.context()
const runFork = Effect.runForkWith(context) const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context) const runPromise = Effect.runPromiseWith(context)
@@ -44,9 +44,14 @@ export default Runtime.handler(Commands, (input) =>
server, server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config: { config: {
path: config.path,
get: () => runPromise(config.get()), get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)), update: (update) => runPromise(config.update(update)),
}, },
packages: {
resolve: (spec) =>
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
},
terminalHandoff: () => preflight.finish(), terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => { log: (level, message, tags) => {
const effect = const effect =
@@ -59,14 +64,6 @@ export default Runtime.handler(Commands, (input) =>
: Effect.logInfo(message, tags) : Effect.logInfo(message, tags)
runFork(effect) runFork(effect)
}, },
pluginHost: {
async start(pluginInput) {
disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime)
},
async dispose() {
disposeSlots?.()
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}), }),
) )
+11 -11
View File
@@ -1,11 +1,11 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
createOpencodeClient, OpenCode,
type IntegrationAttemptStatus, type IntegrationAttemptStatus,
type IntegrationOAuthMethod, type IntegrationOAuthMethod,
type OpencodeClient, type OpenCodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@@ -20,7 +20,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(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) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) 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`)) return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() => 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 const attempt = started.data
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
if (attempt.mode === "code") if (attempt.mode === "code")
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support")) 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 = ( const poll = (
client: OpencodeClient, client: OpenCodeClient,
attemptID: string, attemptID: string,
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> => ): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location })) const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe(
const status = response.data?.data Effect.map((result) => result.data),
if (!status || status.status === "pending") { )
if (status.status === "pending") {
yield* Effect.sleep("1 second") yield* Effect.sleep("1 second")
return yield* poll(client, attemptID) return yield* poll(client, attemptID)
} }
@@ -1,6 +1,6 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" 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 { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@@ -12,9 +12,9 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(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 response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) { if (servers.length === 0) {
process.stdout.write("No MCP servers configured" + EOL) process.stdout.write("No MCP servers configured" + EOL)
return return
@@ -1,6 +1,6 @@
import { EOL } from "node:os" import { EOL } from "node:os"
import { Effect } from "effect" import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
@@ -15,7 +15,7 @@ export default Runtime.handler(
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.start(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) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) { if (!integration) {
@@ -31,7 +31,7 @@ export default Runtime.handler(
yield* Effect.forEach( yield* Effect.forEach(
credentials, credentials,
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })), (connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
{ discard: true }, { discard: true },
) )
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL) process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
@@ -1,17 +1,18 @@
import { Effect } from "effect" 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 // 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. // 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 // 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. // 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* () { Effect.gen(function* () {
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location })) const servers = yield* Effect.promise(() => client.mcp.list({ location }))
const server = (servers.data?.data ?? []).find((entry) => entry.name === name) const server = servers.data.find((entry) => entry.name === name)
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
const integrationID = server.integrationID const integrationID = server.integrationID
if (!integrationID) return undefined if (!integrationID) return undefined
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location })) return yield* Effect.promise(() => client.integration.get({ integrationID, location })).pipe(
return found.data?.data Effect.map((result) => result.data ?? undefined),
)
}) })
+4 -3
View File
@@ -4,6 +4,7 @@ import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater" import { Updater } from "../services/updater"
import { Config } from "../config" import { Config } from "../config"
import { Npm } from "@opencode-ai/core/npm"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -17,7 +18,7 @@ type RuntimeHandler = (
) => Effect.Effect< ) => Effect.Effect<
void, void,
unknown, unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
> >
type Loader<Node extends Spec.Any> = () => Promise<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: ( default: (
@@ -25,7 +26,7 @@ type Loader<Node extends Spec.Any> = () => Promise<{
) => Effect.Effect< ) => Effect.Effect<
void, void,
any, any,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
> >
}> }>
type ProvidedCommand = Command.Command< type ProvidedCommand = Command.Command<
@@ -33,7 +34,7 @@ type ProvidedCommand = Command.Command<
unknown, unknown,
unknown, unknown,
unknown, unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
> >
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
+2 -1
View File
@@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Config } from "./config" import { Config } from "./config"
import { Npm } from "@opencode-ai/core/npm"
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"), $: () => import("./commands/handlers/default"),
@@ -54,7 +55,7 @@ Effect.logInfo("cli starting", {
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer), Effect.provide(Config.layer),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
Effect.provide(Observability.layer), Effect.provide(Observability.layer),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
Effect.scoped, Effect.scoped,
+86 -263
View File
@@ -1,7 +1,7 @@
// Demo mode for testing direct interactive mode without a real SDK. // Demo mode for testing direct interactive mode without a real SDK.
// //
// Enabled with `--demo`. Intercepts prompt submissions and generates synthetic // Enabled with `--demo`. Intercepts prompt submissions and drives the same
// SDK events that feed through the real reducer and footer pipeline. This // presentation commits and footer actions as the live transport. This
// lets you test scrollback formatting, permission UI, question UI, and tool // lets you test scrollback formatting, permission UI, question UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as // snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately. // the initial interactive message to trigger a preview immediately.
@@ -15,10 +15,18 @@
// Demo mode also handles permission and question replies locally, completing // Demo mode also handles permission and question replies locally, completing
// or failing the synthetic tool parts as appropriate. // or failing the synthetic tool parts as appropriate.
import path from "path" import path from "path"
import type { Event, ToolPart } from "@opencode-ai/sdk/v2" import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream" 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 = [ const KINDS = [
"markdown", "markdown",
@@ -124,7 +132,7 @@ type Permit = {
ref: Ref ref: Ref
permission: string permission: string
patterns: string[] patterns: string[]
metadata?: Record<string, unknown> metadata?: PermissionV2Request["metadata"]
always: string[] always: string[]
done: Perm["done"] done: Perm["done"]
} }
@@ -132,9 +140,7 @@ type Permit = {
type State = { type State = {
id: string id: string
thinking: boolean thinking: boolean
data: SessionData
footer: FooterApi footer: FooterApi
limits: () => Record<string, number>
msg: number msg: number
part: number part: number
call: number call: number
@@ -142,12 +148,12 @@ type State = {
ask: number ask: number
perms: Map<string, Perm> perms: Map<string, Perm>
asks: Map<string, Ask> asks: Map<string, Ask>
started: Set<string>
} }
type Input = { type Input = {
sessionID: string sessionID: string
thinking: boolean thinking: boolean
limits: () => Record<string, number>
footer: FooterApi footer: FooterApi
} }
@@ -255,185 +261,69 @@ function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefi
return `demo_${prefix}_${state[key]}` return `demo_${prefix}_${state[key]}`
} }
function feed(state: State, event: Event): void { function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
const out = reduceSessionData({
data: state.data,
event,
sessionID: state.id,
thinking: state.thinking,
limits: state.limits(),
})
state.data = out.data
writeSessionOutput( 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 { function open(state: State): string {
const id = take(state, "msg", "msg") return take(state, "msg", "msg")
feed(state, {
type: "message.updated",
properties: {
sessionID: state.id,
info: {
id,
sessionID: state.id,
role: "assistant",
time: {
created: Date.now(),
},
parentID: `user_${id}`,
modelID: "demo",
providerID: "demo",
mode: "demo",
agent: "demo",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0.001,
tokens: {
input: 120,
output: 320,
reasoning: 80,
cache: {
read: 0,
write: 0,
},
},
},
},
} as Event)
return id
} }
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> { async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state) const msg = open(state)
const part = take(state, "part", "part") 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)) { for (const item of split(body)) {
if (signal?.aborted) { if (signal?.aborted) {
return return
} }
next += item present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
feed(state, {
type: "message.part.delta",
properties: {
sessionID: state.id,
messageID: msg,
partID: part,
field: "text",
delta: item,
},
} as Event)
await wait(45, signal) 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> { async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state) const msg = open(state)
const part = take(state, "part", "part") const part = take(state, "part", "part")
const start = Date.now() let first = true
feed(state, {
type: "message.part.updated",
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: part,
sessionID: state.id,
messageID: msg,
type: "reasoning",
text: "",
time: {
start,
},
},
},
} as Event)
let next = ""
for (const item of split(body)) { for (const item of split(body)) {
if (signal?.aborted) { if (signal?.aborted) {
return return
} }
next += item if (state.thinking) {
feed(state, { present(state, [
type: "message.part.delta", {
properties: { kind: "reasoning",
sessionID: state.id, source: "reasoning",
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
phase: "progress",
messageID: msg, messageID: msg,
partID: part, partID: part,
field: "text",
delta: item,
}, },
} as Event) ])
first = false
}
await wait(45, signal) 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 { 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 { function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
feed(state, { state.started.add(ref.part)
type: "message.part.updated", present(
properties: { state,
sessionID: state.id, [
time: Date.now(), toolCommit(
part: { {
id: ref.part, id: ref.part,
sessionID: state.id, sessionID: state.id,
messageID: ref.msg, messageID: ref.msg,
type: "tool",
callID: ref.call, callID: ref.call,
tool: ref.tool, tool: ref.tool,
state: { state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
status: "running",
input: ref.input,
metadata,
time: {
start: ref.start,
}, },
}, "start",
}, ),
}, ],
} as Event) )
} }
function askPermission(state: State, item: Permit): void { function askPermission(state: State, item: Permit): void {
@@ -482,21 +366,15 @@ function askPermission(state: State, item: Permit): void {
done: item.done, done: item.done,
}) })
feed(state, { present(state, [], {
type: "permission.asked",
properties: {
id, id,
sessionID: state.id, sessionID: state.id,
permission: item.permission, action: item.permission,
patterns: item.patterns, resources: item.patterns,
metadata: item.metadata ?? {}, metadata: item.metadata ?? {},
always: item.always, save: item.always,
tool: { source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
messageID: item.ref.msg, })
callID: item.ref.call,
},
},
} as Event)
} }
function doneTool( function doneTool(
@@ -508,16 +386,11 @@ function doneTool(
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
}, },
): void { ): void {
feed(state, { if (!state.started.has(ref.part)) startTool(state, ref)
type: "message.part.updated", const part: MiniToolPart = {
properties: {
sessionID: state.id,
time: Date.now(),
part: {
id: ref.part, id: ref.part,
sessionID: state.id, sessionID: state.id,
messageID: ref.msg, messageID: ref.msg,
type: "tool",
callID: ref.call, callID: ref.call,
tool: ref.tool, tool: ref.tool,
state: { state: {
@@ -526,27 +399,22 @@ function doneTool(
output: output.output, output: output.output,
title: output.title, title: output.title,
metadata: output.metadata ?? {}, metadata: output.metadata ?? {},
time: { time: { start: ref.start, end: Date.now() },
start: ref.start,
end: Date.now(),
}, },
}, }
}, present(state, [toolCommit(part, output.output ? "progress" : "final")])
},
} as Event)
} }
function failTool(state: State, ref: Ref, error: string): void { function failTool(state: State, ref: Ref, error: string): void {
feed(state, { if (!state.started.has(ref.part)) startTool(state, ref)
type: "message.part.updated", present(
properties: { state,
sessionID: state.id, [
time: Date.now(), toolCommit(
part: { {
id: ref.part, id: ref.part,
sessionID: state.id, sessionID: state.id,
messageID: ref.msg, messageID: ref.msg,
type: "tool",
callID: ref.call, callID: ref.call,
tool: ref.tool, tool: ref.tool,
state: { state: {
@@ -554,31 +422,17 @@ function failTool(state: State, ref: Ref, error: string): void {
input: ref.input, input: ref.input,
error, error,
metadata: {}, metadata: {},
time: { time: { start: ref.start, end: Date.now() },
start: ref.start,
end: Date.now(),
}, },
}, },
}, "final",
}, ),
} as Event) ],
)
} }
function emitError(state: State, text: string): void { function emitError(state: State, text: string): void {
const event = { present(state, [{ kind: "error", source: "system", text, phase: "start" }])
id: `session.error:${state.id}:${Date.now()}`,
type: "session.error",
properties: {
sessionID: state.id,
error: {
name: "UnknownError",
data: {
message: text,
},
},
},
} satisfies Event
feed(state, event)
} }
async function emitBash(state: State, signal?: AbortSignal): Promise<void> { async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
@@ -685,7 +539,7 @@ function emitTask(state: State): void {
start: Date.now(), start: Date.now(),
}, },
}, },
} satisfies ToolPart } satisfies MiniToolPart
showSubagent(state, { showSubagent(state, {
sessionID: "sub_demo_1", sessionID: "sub_demo_1",
partID: ref.part, partID: ref.part,
@@ -979,18 +833,12 @@ function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
const id = take(state, "ask", "ask") const id = take(state, "ask", "ask")
state.asks.set(id, { ref }) state.asks.set(id, { ref })
feed(state, { present(state, [], {
type: "question.asked",
properties: {
id, id,
sessionID: state.id, sessionID: state.id,
questions, questions,
tool: { tool: { messageID: ref.msg, callID: ref.call },
messageID: ref.msg, })
callID: ref.call,
},
},
} as Event)
} }
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> { 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 = { const state: State = {
id: input.sessionID, id: input.sessionID,
thinking: input.thinking, thinking: input.thinking,
data: createSessionData(),
footer: input.footer, footer: input.footer,
limits: input.limits,
msg: 0, msg: 0,
part: 0, part: 0,
call: 0, call: 0,
@@ -1099,6 +945,7 @@ export function createRunDemo(input: Input) {
ask: 0, ask: 0,
perms: new Map(), perms: new Map(),
asks: new Map(), asks: new Map(),
started: new Set(),
} }
const start = async (): Promise<void> => { const start = async (): Promise<void> => {
@@ -1166,16 +1013,7 @@ export function createRunDemo(input: Input) {
} }
state.perms.delete(input.requestID) state.perms.delete(input.requestID)
const event = { clearBlocker(state)
id: `permission.replied:${input.requestID}:${Date.now()}`,
type: "permission.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
reply: input.reply,
},
} satisfies Event
feed(state, event)
if (input.reply === "reject") { if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected") failTool(state, item.ref, input.message || "permission rejected")
@@ -1193,16 +1031,7 @@ export function createRunDemo(input: Input) {
} }
state.asks.delete(input.requestID) state.asks.delete(input.requestID)
const event = { clearBlocker(state)
id: `question.replied:${input.requestID}:${Date.now()}`,
type: "question.replied",
properties: {
sessionID: state.id,
requestID: input.requestID,
answers: input.answers,
},
} satisfies Event
feed(state, event)
doneTool(state, ask.ref, { doneTool(state, ask.ref, {
title: "question", title: "question",
output: "", output: "",
@@ -1220,13 +1049,7 @@ export function createRunDemo(input: Input) {
} }
state.asks.delete(input.requestID) state.asks.delete(input.requestID)
feed(state, { clearBlocker(state)
type: "question.rejected",
properties: {
sessionID: state.id,
requestID: input.requestID,
},
} as Event)
failTool(state, ask.ref, "question rejected") failTool(state, ask.ref, "question rejected")
return true return true
} }
+3 -3
View File
@@ -14,7 +14,7 @@
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js" 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 { import {
createPermissionBodyState, createPermissionBodyState,
permissionAlwaysLines, permissionAlwaysLines,
@@ -130,7 +130,7 @@ export function RejectField(props: {
} }
export function RunPermissionBody(props: { export function RunPermissionBody(props: {
request: PermissionRequest request: PermissionV2Request
theme: RunFooterTheme theme: RunFooterTheme
block: RunBlockTheme block: RunBlockTheme
diffStyle?: RunDiffStyle diffStyle?: RunDiffStyle
@@ -142,7 +142,7 @@ export function RunPermissionBody(props: {
const ft = createMemo(() => toolFiletype(info().file)) const ft = createMemo(() => toolFiletype(info().file))
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
const opts = createMemo(() => 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 busy = createMemo(() => state().submitting)
const title = createMemo(() => { const title = createMemo(() => {
+2 -2
View File
@@ -1164,13 +1164,13 @@ export function createPromptState(input: PromptInput): PromptState {
}, },
}, },
], ],
bindings: input.tuiConfig.keybinds.gather("run.prompt.autocomplete", [ bindings: [
"prompt.autocomplete.prev", "prompt.autocomplete.prev",
"prompt.autocomplete.next", "prompt.autocomplete.next",
"prompt.autocomplete.hide", "prompt.autocomplete.hide",
"prompt.autocomplete.select", "prompt.autocomplete.select",
"prompt.autocomplete.complete", "prompt.autocomplete.complete",
]), ].flatMap((command) => input.tuiConfig.keybinds.get(command)),
})) }))
const onKeyDown = (event: KeyEvent) => { const onKeyDown = (event: KeyEvent) => {
+2 -2
View File
@@ -16,7 +16,7 @@
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js" 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 { import {
createQuestionBodyState, createQuestionBodyState,
questionConfirm, questionConfirm,
@@ -45,7 +45,7 @@ import type { RunFooterTheme } from "./theme"
import type { QuestionReject, QuestionReply } from "./types" import type { QuestionReject, QuestionReply } from "./types"
export function RunQuestionBody(props: { export function RunQuestionBody(props: {
request: QuestionRequest request: QuestionV2Request
theme: RunFooterTheme theme: RunFooterTheme
onReply: (input: QuestionReply) => void | Promise<void> onReply: (input: QuestionReply) => void | Promise<void>
onReject: (input: QuestionReject) => void | Promise<void> onReject: (input: QuestionReject) => void | Promise<void>
-2
View File
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
this.themes.splice(index, 1) this.themes.splice(index, 1)
theme.block.syntax?.destroy() theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
} }
public close(): void { public close(): void {
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
void resolveRunTheme(this.renderer).then((theme) => { void resolveRunTheme(this.renderer).then((theme) => {
if (this.isGone) { if (this.isGone) {
theme.block.syntax?.destroy() theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
return return
} }
+10 -10
View File
@@ -1,8 +1,8 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" 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 { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os" import { EOL } from "node:os"
import { UI } from "./ui" import { UI } from "./ui"
import type { MiniToolPart } from "./types"
type Model = { type Model = {
providerID: string providerID: string
@@ -28,8 +28,8 @@ type Input = {
auto: boolean auto: boolean
/** True when the client is attached to a shared server rather than an exclusive in-process one. */ /** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean attached: boolean
renderTool: (part: ToolPart) => Promise<void> renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: ToolPart) => Promise<void> renderToolError: (part: MiniToolPart) => Promise<void>
} }
type StartedPart = { type StartedPart = {
@@ -77,7 +77,7 @@ export async function runNonInteractivePrompt(input: Input) {
return true return true
} }
const writeText = (part: TextPart, timestamp: number) => { const writeText = (part: { text: string; [key: string]: unknown }, timestamp: number) => {
if (emit("text", timestamp, { part })) return if (emit("text", timestamp, { part })) return
const text = part.text.trim() const text = part.text.trim()
if (!text) return if (!text) return
@@ -169,7 +169,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!promoted) continue if (!promoted) continue
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
const part: StepStartPart = { const part = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@@ -191,7 +191,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.text.ended") { if (event.type === "session.text.ended") {
const started = starts.get("text") const started = starts.get("text")
starts.delete("text") starts.delete("text")
const part: TextPart = { const part = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@@ -210,7 +210,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.reasoning.ended" && input.thinking) { if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get("reasoning") const started = starts.get("reasoning")
starts.delete("reasoning") starts.delete("reasoning")
const part: ReasoningPart = { const part = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@@ -263,7 +263,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.tool.success") { if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@@ -296,7 +296,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message const error = event.data.error.message
const part: ToolPart = { const part: MiniToolPart = {
id: current.id, id: current.id,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
@@ -325,7 +325,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.step.ended") { if (event.type === "session.step.ended") {
const part: StepFinishPart = { const part = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
+15 -14
View File
@@ -13,7 +13,7 @@
// //
// permissionInfo() extracts display info (icon, title, lines, diff) from // permissionInfo() extracts display info (icon, title, lines, diff) from
// the request, delegating to tool.ts for tool-specific formatting. // 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 type { PermissionReply } from "./types"
import { toolPath, toolPermissionInfo } from "./tool" import { toolPath, toolPermissionInfo } from "./tool"
@@ -55,7 +55,7 @@ function text(v: unknown): string {
return typeof v === "string" ? v : "" return typeof v === "string" ? v : ""
} }
function data(request: PermissionRequest): Dict { function data(request: PermissionV2Request): Dict {
const meta = dict(request.metadata) const meta = dict(request.metadata)
return { return {
...meta, ...meta,
@@ -63,8 +63,8 @@ function data(request: PermissionRequest): Dict {
} }
} }
function patterns(request: PermissionRequest): string[] { function patterns(request: PermissionV2Request): string[] {
return request.patterns.filter((item): item is string => typeof item === "string") return request.resources.filter((item): item is string => typeof item === "string")
} }
export function createPermissionBodyState(requestID: string): PermissionBodyState { export function createPermissionBodyState(requestID: string): PermissionBodyState {
@@ -89,15 +89,15 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return [] return []
} }
export function permissionInfo(request: PermissionRequest): PermissionInfo { export function permissionInfo(request: PermissionV2Request): PermissionInfo {
const pats = patterns(request) const pats = patterns(request)
const input = data(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) { if (info) {
return info return info
} }
if (request.permission === "external_directory") { if (request.action === "external_directory") {
const meta = dict(request.metadata) const meta = dict(request.metadata)
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || "" const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw 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 { return {
icon: "⟳", icon: "⟳",
title: "Continue after repeated failures", title: "Continue after repeated failures",
@@ -118,19 +118,20 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
return { return {
icon: "⚙", icon: "⚙",
title: `Call tool ${request.permission}`, title: `Call tool ${request.action}`,
lines: [`Tool: ${request.permission}`], lines: [`Tool: ${request.action}`],
} }
} }
export function permissionAlwaysLines(request: PermissionRequest): string[] { export function permissionAlwaysLines(request: PermissionV2Request): string[] {
if (request.always.length === 1 && request.always[0] === "*") { const save = request.save ?? []
return [`This will allow ${request.permission} until OpenCode is restarted.`] if (save.length === 1 && save[0] === "*") {
return [`This will allow ${request.action} until OpenCode is restarted.`]
} }
return [ return [
"This will allow the following patterns until OpenCode is restarted.", "This will allow the following patterns until OpenCode is restarted.",
...request.always.map((item) => `- ${item}`), ...save.map((item) => `- ${item}`),
] ]
} }
+15 -15
View File
@@ -13,7 +13,7 @@
// //
// Custom answers: if a question has custom=true, an extra "Type your own // 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. // 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" import type { QuestionReject, QuestionReply } from "./types"
export type QuestionBodyState = { export type QuestionBodyState = {
@@ -51,23 +51,23 @@ export function questionSync(state: QuestionBodyState, requestID: string): Quest
return createQuestionBodyState(requestID) 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 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 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 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] 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 return questionInfo(request, state)?.custom !== false
} }
@@ -84,7 +84,7 @@ export function questionPicked(state: QuestionBodyState): boolean {
return state.answers[state.tab]?.includes(value) ?? false 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) const info = questionInfo(request, state)
if (!info || info.custom === false) { if (!info || info.custom === false) {
return false return false
@@ -93,7 +93,7 @@ export function questionOther(request: QuestionRequest, state: QuestionBodyState
return state.selected === info.options.length 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) const info = questionInfo(request, state)
if (!info) { if (!info) {
return 0 return 0
@@ -156,7 +156,7 @@ export function questionStoreCustom(state: QuestionBodyState, tab: number, text:
function questionPick( function questionPick(
state: QuestionBodyState, state: QuestionBodyState,
request: QuestionRequest, request: QuestionV2Request,
answer: string, answer: string,
custom = false, custom = false,
): QuestionStep { ): QuestionStep {
@@ -204,7 +204,7 @@ function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyS
return storeAnswers(state, state.tab, list) 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) const total = questionTotal(request, state)
if (total === 0) { if (total === 0) {
return state 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) const info = questionInfo(request, state)
if (!info) { if (!info) {
return { state } return { state }
@@ -255,7 +255,7 @@ export function questionSelect(state: QuestionBodyState, request: QuestionReques
return questionPick(state, request, option.label) 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) const info = questionInfo(request, state)
if (!info) { if (!info) {
return { state } return { state }
@@ -305,20 +305,20 @@ export function questionSave(state: QuestionBodyState, request: QuestionRequest)
return questionPick(state, request, value, true) return questionPick(state, request, value, true)
} }
export function questionSubmit(request: QuestionRequest, state: QuestionBodyState): QuestionReply { export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
return { return {
requestID: request.id, requestID: request.id,
answers: questionAnswers(state, request.questions.length), answers: questionAnswers(state, request.questions.length),
} }
} }
export function questionReject(request: QuestionRequest): QuestionReject { export function questionReject(request: QuestionV2Request): QuestionReject {
return { return {
requestID: request.id, requestID: request.id,
} }
} }
export function questionHint(request: QuestionRequest, state: QuestionBodyState): string { export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
if (state.submitting) { if (state.submitting) {
return "Waiting for question event..." return "Waiting for question event..."
} }
+3 -3
View File
@@ -2,13 +2,13 @@ import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Server } from "../services/server" import { Server } from "../services/server"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive" import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool" import { toolInlineInfo } from "./tool"
import type { MiniToolPart } from "./types"
import { UI } from "./ui" import { UI } from "./ui"
export type RunCommandInput = { 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 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) const info = toolInlineInfo(part)
if (info.mode === "block") { if (info.mode === "block") {
UI.empty() 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) const info = toolInlineInfo(part)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`) UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
} }
-1
View File
@@ -547,7 +547,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer, footer,
sessionID: state.sessionID, sessionID: state.sessionID,
thinking: input.thinking, thinking: input.thinking,
limits: () => state.limits,
}) })
} }
+1 -5
View File
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([]) return style ?? SyntaxStyle.fromTheme([])
} }
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle { export function entrySyntax(theme: RunTheme): SyntaxStyle {
if (commit.kind === "reasoning") {
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
}
return syntax(theme.block.syntax) return syntax(theme.block.syntax)
} }
+3 -3
View File
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
} }
active.renderable.fg = entryColor(active.commit, theme) active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(active.commit, theme) active.renderable.syntaxStyle = entrySyntax(theme)
} }
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry { private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
? new CodeRenderable(surface.renderContext, { ? new CodeRenderable(surface.renderContext, {
content: "", content: "",
filetype: body.filetype, filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme), syntaxStyle: entrySyntax(this.theme),
width: "100%", width: "100%",
wrapMode: "word", wrapMode: "word",
drawUnstyledText: false, drawUnstyledText: false,
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
}) })
: new MarkdownRenderable(surface.renderContext, { : new MarkdownRenderable(surface.renderContext, {
content: "", content: "",
syntaxStyle: entrySyntax(commit, this.theme), syntaxStyle: entrySyntax(this.theme),
width: "100%", width: "100%",
streaming: true, streaming: true,
internalBlockMode: "top-level", internalBlockMode: "top-level",
+1 -1
View File
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK) const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit)) const body = createMemo(() => props.body ?? entryBody(props.commit))
const style = createMemo(() => entryLook(props.commit, theme().entry)) 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 color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true) const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color) const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
File diff suppressed because it is too large Load Diff
+43 -194
View File
@@ -1,15 +1,10 @@
// Session message extraction and prompt history. import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
//
// 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 { promptCopy, promptSame } from "./prompt.shared" import { promptCopy, promptSame } from "./prompt.shared"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import type { RunInput, RunPrompt } from "./types" import type { RunInput, RunPrompt } from "./types"
const LIMIT = 200 const LIMIT = 200
export type SessionMessages = Array<{ info: Message; parts: Part[] }> export type SessionMessages = SessionMessageInfo[]
type Turn = { type Turn = {
prompt: RunPrompt prompt: RunPrompt
@@ -25,153 +20,8 @@ export type RunSession = {
variant?: string variant?: string
} }
function fileName(url: string, filename?: string) { function messagePrompt(message: SessionMessageUser): RunPrompt {
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 { return {
...structuredClone(part.source),
text,
}
}
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,
}
}
export function createSession(messages: SessionMessages): RunSession {
return {
first: messages.length === 0,
turns: messages.flatMap((msg) => {
const item = turn(msg)
return item ? [item] : []
}),
}
}
export async function resolveCurrentSession(
sdk: RunInput["sdk"],
sessionID: string,
limit = LIMIT,
): Promise<RunSession> {
const [response, session] = await Promise.all([
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const messages = response.data.toReversed()
return {
first: messages.length === 0,
turns: messages.flatMap((message) => {
if (message.type !== "user") return []
return [
{
prompt: {
text: message.text, text: message.text,
parts: [ parts: [
...(message.files ?? []).map((file) => ({ ...(message.files ?? []).map((file) => ({
@@ -181,7 +31,7 @@ export async function resolveCurrentSession(
filename: file.name, filename: file.name,
source: file.mention source: file.mention
? { ? {
type: "file" as const, type: "file",
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"), 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 }, text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
} }
@@ -195,58 +45,57 @@ export async function resolveCurrentSession(
: undefined, : undefined,
})), })),
], ],
}, }
}
export function createSession(messages: SessionMessages): RunSession {
return {
first: messages.length === 0,
turns: messages.flatMap((message) =>
message.type === "user"
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
: [],
),
}
}
export async function resolveCurrentSession(
sdk: RunInput["sdk"],
sessionID: string,
limit = LIMIT,
): Promise<RunSession> {
const [response, session] = await Promise.all([
sdk.message.list({ sessionID, limit, order: "desc" }),
sdk.session.get({ sessionID }),
])
const current = createSession(response.data.toReversed())
return {
...current,
turns: current.turns.map((turn) => ({
...turn,
provider: session.model?.providerID, provider: session.model?.providerID,
model: session.model?.id, model: session.model?.id,
variant: session.model?.variant, variant: session.model?.variant,
}, })),
]
}),
...(session.model && { ...(session.model && {
model: { model: { providerID: session.model.providerID, modelID: session.model.id },
providerID: session.model.providerID,
modelID: session.model.id,
},
variant: session.model.variant, variant: session.model.variant,
}), }),
} }
} }
export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
const out: RunPrompt[] = [] return session.turns
.map((turn) => turn.prompt)
for (const turn of session.turns) { .filter((prompt) => prompt.text.trim())
if (!turn.prompt.text.trim()) { .filter((prompt, index, prompts) => index === 0 || !promptSame(prompts[index - 1], prompt))
continue .map(promptCopy)
} .slice(-limit)
if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) {
continue
}
out.push(promptCopy(turn.prompt))
}
return out.slice(-limit)
} }
export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined { export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined {
if (!model) { if (!model) return
return undefined 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.turns.findLast((turn) => turn.provider === model.providerID && turn.model === model.modelID)?.variant
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
} }
+11 -7
View File
@@ -15,10 +15,14 @@
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
// backgrounding is intentionally absent: subagent jobs block the parent // backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists. // session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type {
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Locale } from "@opencode-ai/tui/util/locale" 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_MESSAGE_LIMIT = 80
const CHILD_FRAME_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") return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
} }
export function legacyTool(input: { export function miniTool(input: {
sessionID: string sessionID: string
messageID: string messageID: string
tool: SessionMessageAssistantTool tool: SessionMessageAssistantTool
}): ToolPart { }): MiniToolPart {
const tool = input.tool const tool = input.tool
const providerCall = const providerCall =
tool.executed === undefined && tool.providerState === undefined 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 status = part.state.status
const text = const text =
status === "running" status === "running"
@@ -310,7 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
const part = legacyTool({ const part = miniTool({
sessionID: child.sessionID, sessionID: child.sessionID,
messageID, messageID,
tool: item, tool: item,
+15 -38
View File
@@ -1,14 +1,15 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { import type {
PermissionRequest, EventSubscribeOutput,
QuestionRequest, OpenCodeClient,
SessionMessageInfo, PermissionV2Request,
QuestionV2Request,
SessionMessageAssistantTool, SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2" SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { blockerStatus, pickBlockerView } from "./session-data" import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream" import { writeSessionOutput } from "./stream"
import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent" import { createSubagentTracker, miniTool, toolCommit } from "./stream-v2.subagent"
import type { import type {
FooterApi, FooterApi,
FooterView, FooterView,
@@ -87,8 +88,6 @@ type ShellWait = {
} }
type RunV2Event = EventSubscribeOutput 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 PromptFilePart = Extract<RunPromptPart, { type: "file" }>
type ToolState = { type ToolState = {
@@ -101,8 +100,8 @@ type ToolState = {
} }
type State = { type State = {
permissions: PermissionRequest[] permissions: PermissionV2Request[]
questions: QuestionRequest[] questions: QuestionV2Request[]
view: FooterView view: FooterView
messageIDs: Set<string> messageIDs: Set<string>
text: Map<string, string> text: Map<string, string>
@@ -138,27 +137,6 @@ export function formatUnknownError(error: unknown): string {
return "unknown error" 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) { function sessionID(event: RunV2Event) {
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined 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}` return `${messageID}\u0000${partID}`
} }
// Matches the commit shapes the legacy session-data reducer produced for direct // Direct shell calls use one "start" commit rendering `$ command` and one "progress"
// shell calls: one "start" commit rendering `$ command` and one "progress"
// commit rendering the merged output (see toolEntryBody in tool.ts). // commit rendering the merged output (see toolEntryBody in tool.ts).
function shellCommit( function shellCommit(
callID: string, callID: string,
@@ -384,7 +361,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
const renderTool = (messageID: string, item: SessionMessageAssistantTool) => { const renderTool = (messageID: string, item: SessionMessageAssistantTool) => {
const part = legacyTool({ const part = miniTool({
sessionID: input.sessionID, sessionID: input.sessionID,
messageID, messageID,
tool: item, tool: item,
@@ -536,8 +513,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
]) ])
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[] const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission) state.permissions = permissions
state.questions = questions.map(question) state.questions = questions
syncBlockers() syncBlockers()
await subagents.hydrate({ messages: [...projected], active }) await subagents.hydrate({ messages: [...projected], active })
const running = input.sessionID in active const running = input.sessionID in active
@@ -770,7 +747,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (event.type === "permission.v2.asked") { 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() syncBlockers()
return return
} }
@@ -780,7 +757,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (event.type === "question.v2.asked") { 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() syncBlockers()
return return
} }
+7 -7
View File
@@ -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 // view + subagent state). This module forwards them to footer.append() and
// footer.event() respectively, adding trace writes along the way. It also // footer.event() respectively, adding trace writes along the way. It also
// defaults status updates to phase "running" if the caller didn't set a // 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" import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
type Trace = { type Trace = {
@@ -103,9 +103,9 @@ export function traceSubagentState(state: FooterSubagentState) {
permissions: state.permissions.map((item) => ({ permissions: state.permissions.map((item) => ({
id: item.id, id: item.id,
sessionID: item.sessionID, sessionID: item.sessionID,
permission: item.permission, action: item.action,
patterns: item.patterns, resources: item.resources,
tool: item.tool, source: item.source,
metadata: item.metadata metadata: item.metadata
? { ? {
keys: Object.keys(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 { export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
for (const commit of out.commits) { for (const commit of out.commits) {
input.trace?.write("ui.commit", commit) input.trace?.write("ui.commit", commit)
+1 -44
View File
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
text: ColorInput text: ColorInput
muted: ColorInput muted: ColorInput
syntax?: SyntaxStyle syntax?: SyntaxStyle
subtleSyntax?: SyntaxStyle
diffAdded: ColorInput diffAdded: ColorInput
diffRemoved: ColorInput diffRemoved: ColorInput
diffAddedBg: 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) { function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) 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[] { function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => { return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index] const value = colors.palette[index]
@@ -502,10 +469,7 @@ function map(
scrollbackTheme: TuiThemeCurrent, scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme, splash: RunSplashTheme,
syntax?: SyntaxStyle, syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme { ): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
subtleSyntax?.destroy()
const footerBackground = alpha(footerTheme.background, 1) const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground) const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72) const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
@@ -566,7 +530,6 @@ function map(
text: scrollbackTheme.text, text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted, muted: scrollbackTheme.textMuted,
syntax, syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: scrollbackTheme.diffAdded, diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved, diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent, diffAddedBg: transparent,
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
_hasSelectedListItemText: true, _hasSelectedListItemText: true,
} }
const syntax = shared.generateSyntax(syntaxTheme) const syntax = shared.generateSyntax(syntaxTheme)
return map( return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
} catch { } catch {
return RUN_THEME_FALLBACK return RUN_THEME_FALLBACK
} }
+3 -4
View File
@@ -15,10 +15,9 @@
import os from "os" import os from "os"
import path from "path" import path from "path"
import stripAnsi from "strip-ansi" import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype" import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
import { Locale } from "@opencode-ai/tui/util/locale" 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 = { export type ToolView = {
output: boolean output: boolean
@@ -1177,7 +1176,7 @@ function rule(name?: string): AnyToolRule | undefined {
return TOOL_RULES[name] return TOOL_RULES[name]
} }
function frame(part: ToolPart): ToolFrame { function frame(part: MiniToolPart): ToolFrame {
const state = dict(part.state) const state = dict(part.state)
return { return {
raw: "", 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 ctx = frame(part)
const draw = rule(ctx.name)?.run const draw = rule(ctx.name)?.run
try { try {
+56 -13
View File
@@ -7,12 +7,16 @@
// //
// Data flow through the system: // Data flow through the system:
// //
// SDK events → session-data reducer → StreamCommit[] + FooterOutput // V2 events / demo actions → StreamCommit[] + FooterOutput
// → stream.ts bridges to footer API // → stream.ts bridges to footer API
// → footer.ts queues commits and patches the footer view // → footer.ts queues commits and patches the footer view
// → OpenTUI split-footer renderer writes to terminal // → OpenTUI split-footer renderer writes to terminal
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise" import type {
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" OpenCodeClient,
PermissionV2Request,
QuestionV2Request,
ReferenceListOutput,
} from "@opencode-ai/client/promise"
import type { TuiConfig } from "@opencode-ai/tui/config/v1" import type { TuiConfig } from "@opencode-ai/tui/config/v1"
export type RunFilePart = { export type RunFilePart = {
@@ -30,7 +34,11 @@ export type RunPromptPart =
url: string url: string
filename?: string filename?: string
mime?: 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 } } | { 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 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 EntryLayout = "inline" | "block"
export type RunEntryBody = export type RunEntryBody =
@@ -220,13 +263,13 @@ export type RunEntryBody =
| { type: "structured"; snapshot: ToolSnapshot } | { type: "structured"; snapshot: ToolSnapshot }
// Which interactive surface the footer is showing. Only one view is active at // 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 // switches to "permission", and when the permission resolves it falls back to
// "prompt". // "prompt".
export type FooterView = export type FooterView =
| { type: "prompt" } | { type: "prompt" }
| { type: "permission"; request: PermissionRequest } | { type: "permission"; request: PermissionV2Request }
| { type: "question"; request: QuestionRequest } | { type: "question"; request: QuestionV2Request }
export type FooterPromptRoute = export type FooterPromptRoute =
| { type: "composer" } | { type: "composer" }
@@ -259,11 +302,11 @@ export type FooterSubagentDetail = {
export type FooterSubagentState = { export type FooterSubagentState = {
tabs: FooterSubagentTab[] tabs: FooterSubagentTab[]
details: Record<string, FooterSubagentDetail> details: Record<string, FooterSubagentDetail>
permissions: PermissionRequest[] permissions: PermissionV2Request[]
questions: QuestionRequest[] 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 = { export type FooterOutput = {
patch?: FooterPatch patch?: FooterPatch
view?: FooterView view?: FooterView
@@ -357,8 +400,8 @@ export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
export type StreamToolState = "running" | "completed" | "error" export type StreamToolState = "running" | "completed" | "error"
// A single append-only commit to scrollback. The session-data reducer produces // A single append-only commit to scrollback. The transport produces these from
// these from SDK events, and RunFooter.append() queues them for the next // V2 events, and RunFooter.append() queues them for the next
// microtask flush. Once flushed, they become immutable terminal scrollback // microtask flush. Once flushed, they become immutable terminal scrollback
// rows -- they cannot be rewritten. // rows -- they cannot be rewritten.
export type StreamCommit = { export type StreamCommit = {
@@ -370,7 +413,7 @@ export type StreamCommit = {
messageID?: string messageID?: string
partID?: string partID?: string
tool?: string tool?: string
part?: ToolPart part?: MiniToolPart
interrupted?: boolean interrupted?: boolean
toolState?: StreamToolState toolState?: StreamToolState
toolError?: string toolError?: string
+23 -4
View File
@@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "kv.json"), path.join(directory, "kv.json"),
JSON.stringify({ JSON.stringify({
theme_mode_lock: "light", 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, paste_summary_enabled: false,
sidebar: "hide",
scrollbar_visible: true,
thinking_mode: "show",
exploration_grouping: false, exploration_grouping: false,
tips_hidden: true, 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"], plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 }, leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true }, scroll: { speed: 2, acceleration: true },
diffs: { view: "unified" }, attention: { sound_pack: "custom.pack" },
prompt: { paste: "full" }, diffs: { wrap: "none", tree: false, single: true, view: "split" },
session: { grouping: "none" }, terminal: { title: false },
hints: { tips: false }, prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { tips: false, onboarding: false },
animations: false,
mouse: 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")).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, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true) expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+2 -2
View File
@@ -16,8 +16,8 @@
## Future Design Notes ## 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. - 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 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. - 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. - 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. - 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. - 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.
+4 -4
View File
@@ -20,7 +20,7 @@ ultimate source of truth.
- [x] Top-level `await` and `return` through the program's implicit async-function scope. - [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] 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] 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] 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 - [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. shadowable by program declarations like other globals.
@@ -94,7 +94,7 @@ ultimate source of truth.
- [x] Optional property access and optional calls. - [x] Optional property access and optional calls.
- [x] Function/tool calls and spread arguments. - [x] Function/tool calls and spread arguments.
- [x] Sequence expressions (the comma operator). - [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. continuation one reaction turn.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
@@ -109,7 +109,7 @@ ultimate source of truth.
## Promises and tools ## 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] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program.
- [x] `Promise.resolve` and `Promise.reject`. - [x] `Promise.resolve` and `Promise.reject`.
- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing - [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] Computed property names and object spread.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`. - [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys` over arrays and tool references. - [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`. - [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first. - [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
- [ ] `Object.groupBy`. - [ ] `Object.groupBy`.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode", "name": "@opencode-ai/codemode",
"version": "1.17.18", "version": "1.17.20",
"description": "Effect-native confined code execution over schema-described tools", "description": "Effect-native confined code execution over schema-described tools",
"private": true, "private": true,
"type": "module", "type": "module",
+25 -25
View File
@@ -13,13 +13,13 @@ import {
import { rejectCircularInsertion } from "./references.js" import { rejectCircularInsertion } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js" import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { import {
SandboxDate, CodeModeDate,
SandboxMap, CodeModeMap,
SandboxPromise, CodeModePromise,
SandboxRegExp, CodeModeRegExp,
SandboxSet, CodeModeSet,
SandboxURL, CodeModeURL,
SandboxURLSearchParams, CodeModeURLSearchParams,
} from "../values.js" } from "../values.js"
import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js" import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeJsonMethod } from "../stdlib/json.js" import { invokeJsonMethod } from "../stdlib/json.js"
@@ -33,7 +33,7 @@ import { boundedData, coerceToNumber, coerceToString, invokeCoercion } from "../
export type CallbackRunner<R> = { export type CallbackRunner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, 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>( export const invokeIntrinsic = <R>(
@@ -57,22 +57,22 @@ export const invokeIntrinsic = <R>(
if (Array.isArray(ref.receiver)) { if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node) 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)) 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)) 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) 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) 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)) 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) return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
} }
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, 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] result = [value]
break break
} }
if (args[0] instanceof SandboxRegExp) { if (args[0] instanceof CodeModeRegExp) {
result = value.split(args[0].regex, optNum(1)) result = value.split(args[0].regex, optNum(1))
break break
} }
@@ -181,7 +181,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break break
case "replace": case "replace":
case "replaceAll": { case "replaceAll": {
if (args[0] instanceof SandboxRegExp) { if (args[0] instanceof CodeModeRegExp) {
const pattern = args[0].regex const pattern = args[0].regex
const replacement = str(1) const replacement = str(1)
if (name === "replaceAll" && !pattern.global) { if (name === "replaceAll" && !pattern.global) {
@@ -278,13 +278,13 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
[supportedSyntaxMessage], [supportedSyntaxMessage],
) )
} }
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item]) if (args[0] instanceof CodeModeMap) 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 CodeModeSet) return Array.from(args[0].set.values())
if (args[0] instanceof SandboxURLSearchParams) { if (args[0] instanceof CodeModeURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
} }
const source = args[0] const source = args[0]
if (source instanceof SandboxPromise) { if (source instanceof CodeModePromise) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.", "Array.from received an un-awaited Promise; await it before creating the array.",
node, node,
@@ -341,7 +341,7 @@ const invokeStringReplacer = <R>(
} }
const pattern = args[0] const pattern = args[0]
if (pattern instanceof SandboxRegExp) { if (pattern instanceof CodeModeRegExp) {
if (name === "replaceAll" && !pattern.regex.global) { if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError( 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.`, `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) { for (const match of matches) {
const replacement = yield* apply(match.args) const replacement = yield* apply(match.args)
const resolved = 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) ? yield* runner.settlePromise(replacement)
: replacement : replacement
output.push( output.push(
@@ -404,7 +404,7 @@ export const applyCollectionCallback = <R>(
const invokeMapMethod = <R>( const invokeMapMethod = <R>(
runner: CallbackRunner<R>, runner: CallbackRunner<R>,
target: SandboxMap, target: CodeModeMap,
name: string, name: string,
args: Array<unknown>, args: Array<unknown>,
node: AstNode, node: AstNode,
@@ -446,7 +446,7 @@ const invokeMapMethod = <R>(
const invokeSetMethod = <R>( const invokeSetMethod = <R>(
runner: CallbackRunner<R>, runner: CallbackRunner<R>,
target: SandboxSet, target: CodeModeSet,
name: string, name: string,
args: Array<unknown>, args: Array<unknown>,
node: AstNode, node: AstNode,
@@ -485,7 +485,7 @@ const invokeSetMethod = <R>(
const invokeURLSearchParamsMethod = <R>( const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>, runner: CallbackRunner<R>,
target: SandboxURLSearchParams, target: CodeModeURLSearchParams,
name: string, name: string,
args: Array<unknown>, args: Array<unknown>,
node: AstNode, node: AstNode,
+3 -3
View File
@@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js" import type { SafeObject } from "../tool-runtime.js"
import type { SandboxPromise, SandboxURL } from "../values.js" import type { CodeModePromise, CodeModeURL } from "../values.js"
export type SourcePosition = { export type SourcePosition = {
line: number line: number
@@ -35,7 +35,7 @@ export type StatementResult =
| { kind: "continue" } | { kind: "continue" }
export type MemberReference = { export type MemberReference = {
target: SafeObject | Array<unknown> | SandboxURL target: SafeObject | Array<unknown> | CodeModeURL
key: string | number key: string | number
} }
@@ -71,7 +71,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference { export class PromiseInstanceMethodReference {
constructor( constructor(
readonly promise: SandboxPromise, readonly promise: CodeModePromise,
readonly name: PromiseInstanceMethodName, readonly name: PromiseInstanceMethodName,
) {} ) {}
} }
+26 -26
View File
@@ -17,24 +17,24 @@ import { applyCollectionCallback, type CallbackRunner } from "./methods.js"
import { typeofValue } from "./references.js" import { typeofValue } from "./references.js"
import { spreadItems } from "../stdlib/collections.js" import { spreadItems } from "../stdlib/collections.js"
import { createAggregateErrorValue } from "../stdlib/value.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. // Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> { export class PromiseRuntime<R> {
private readonly active = new Set<SandboxPromise>() private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<SandboxPromise, number>() private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<SandboxPromise>() private readonly observed = new WeakSet<CodeModePromise>()
private readonly failures = new Map<number, Diagnostic>() private readonly failures = new Map<number, Diagnostic>()
private nextID = 0 private nextID = 0
constructor(private readonly scope: Scope.Scope) {} 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(() => { return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order. // Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++ const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => { 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.active.add(promise)
this.ids.set(promise, id) this.ids.set(promise, id)
fiber.addObserver((exit) => { fiber.addObserver((exit) => {
@@ -55,14 +55,14 @@ export class PromiseRuntime<R> {
} }
// Observation must be recorded when responsibility transfers, before the consumer fiber runs. // Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: SandboxPromise): void { markObserved(promise: CodeModePromise): void {
this.observed.add(promise) this.observed.add(promise)
const id = this.ids.get(promise) const id = this.ids.get(promise)
this.ids.delete(promise) this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id) 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) return Fiber.await(promise.fiber)
} }
@@ -94,7 +94,7 @@ export const invokePromiseMethod = <R>(
): Effect.Effect<unknown, unknown, R> => { ): Effect.Effect<unknown, unknown, R> => {
if (ref.name === "resolve") { if (ref.name === "resolve") {
const value = args[0] 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") { if (ref.name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0]))) return promises.create(Effect.fail(new ProgramThrow(args[0])))
@@ -114,19 +114,19 @@ export const invokePromiseMethod = <R>(
const items = Array.from(spread) const items = Array.from(spread)
for (const item of items) { for (const item of items) {
if (item instanceof SandboxPromise) promises.markObserved(item) if (item instanceof CodeModePromise) promises.markObserved(item)
} }
switch (ref.name) { switch (ref.name) {
case "all": { case "all": {
const observations = items.map((item) => 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" }))) return promises.create(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
} }
case "allSettled": { case "allSettled": {
const observations = items.map((item) => 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( return promises.create(
settleAfterTurn( settleAfterTurn(
@@ -168,13 +168,13 @@ export const invokePromiseMethod = <R>(
) )
} }
const observations = items.map((item) => 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)))) return promises.create(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
} }
case "any": { case "any": {
const flipped = items.map((item) => const flipped = items.map((item) =>
item instanceof SandboxPromise item instanceof CodeModePromise
? Effect.flatMap(promises.await(item), (exit) => { ? Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
@@ -201,7 +201,7 @@ export const invokePromiseInstanceMethod = <R>(
ref: PromiseInstanceMethodReference, ref: PromiseInstanceMethodReference,
args: Array<unknown>, args: Array<unknown>,
node: AstNode, node: AstNode,
): Effect.Effect<SandboxPromise, never, R> => { ): Effect.Effect<CodeModePromise, never, R> => {
const method = `Promise.prototype.${ref.name}` const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise) promises.markObserved(ref.promise)
if (ref.name === "finally") { if (ref.name === "finally") {
@@ -217,7 +217,7 @@ export const constructPromise = <R>(
promises: PromiseRuntime<R>, promises: PromiseRuntime<R>,
executor: unknown, executor: unknown,
node: AstNode, node: AstNode,
): Effect.Effect<SandboxPromise, unknown, R> => { ): Effect.Effect<CodeModePromise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) { if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).", "new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
@@ -226,10 +226,10 @@ export const constructPromise = <R>(
} }
return Effect.gen(function* () { return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>() const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { own?: SandboxPromise } = {} const box: { own?: CodeModePromise } = {}
const promise = yield* promises.create( const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => { 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)) if (value === box.own) return Effect.fail(selfResolutionError(node))
return runner.settlePromise(value) 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. // Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>( const reactionExit = <R>(
promises: PromiseRuntime<R>, promises: PromiseRuntime<R>,
source: SandboxPromise, source: CodeModePromise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> => ): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* promises.await(source) const exit = yield* promises.await(source)
@@ -293,13 +293,13 @@ const reactionExit = <R>(
const chainReaction = <R>( const chainReaction = <R>(
runner: CallbackRunner<R>, runner: CallbackRunner<R>,
promises: PromiseRuntime<R>, promises: PromiseRuntime<R>,
source: SandboxPromise, source: CodeModePromise,
onFulfilled: ReactionHandler | undefined, onFulfilled: ReactionHandler | undefined,
onRejected: ReactionHandler | undefined, onRejected: ReactionHandler | undefined,
method: string, method: string,
node: AstNode, node: AstNode,
): Effect.Effect<SandboxPromise, never, R> => { ): Effect.Effect<CodeModePromise, never, R> => {
const box: { derived?: SandboxPromise } = {} const box: { derived?: CodeModePromise } = {}
const body = Effect.gen(function* () { const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source) const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected 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 input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input]) const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node)) 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 result
}) })
return Effect.map(promises.create(body), (derived) => { return Effect.map(promises.create(body), (derived) => {
@@ -319,17 +319,17 @@ const chainReaction = <R>(
const chainFinally = <R>( const chainFinally = <R>(
runner: CallbackRunner<R>, runner: CallbackRunner<R>,
promises: PromiseRuntime<R>, promises: PromiseRuntime<R>,
source: SandboxPromise, source: CodeModePromise,
cleanup: ReactionHandler | undefined, cleanup: ReactionHandler | undefined,
method: string, method: string,
node: AstNode, node: AstNode,
): Effect.Effect<SandboxPromise, never, R> => ): Effect.Effect<CodeModePromise, never, R> =>
promises.create( promises.create(
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* reactionExit(promises, source) const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) { if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([]) 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 return yield* exit
}), }),
@@ -15,7 +15,7 @@ import {
UriFunction, UriFunction,
} from "./model.js" } from "./model.js"
import { ToolReference } from "../tool-runtime.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 => export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction || value instanceof CodeModeFunction ||
@@ -26,13 +26,13 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseNamespace || value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference || value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference || value instanceof PromiseInstanceMethodReference ||
value instanceof SandboxPromise || value instanceof CodeModePromise ||
value instanceof CoercionFunction || value instanceof CoercionFunction ||
value instanceof UriFunction || value instanceof UriFunction ||
value instanceof SearchFunction || value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction || value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference || value instanceof ErrorConstructorReference ||
isSandboxValue(value) isCodeModeValue(value)
export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => { export const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
if (isRuntimeReference(value)) return true if (isRuntimeReference(value)) return true
@@ -46,9 +46,9 @@ export const containsRuntimeReference = (value: unknown, seen = new Set<object>(
return contains 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 => { 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 (isRuntimeReference(value)) return true
if (value === null || typeof value !== "object") return false if (value === null || typeof value !== "object") return false
if (seen.has(value)) return false if (seen.has(value)) return false
+64 -64
View File
@@ -73,14 +73,14 @@ import {
valueConstructors, valueConstructors,
} from "../stdlib/value.js" } from "../stdlib/value.js"
import { import {
isSandboxValue, isCodeModeValue,
SandboxDate, CodeModeDate,
SandboxMap, CodeModeMap,
SandboxPromise, CodeModePromise,
SandboxRegExp, CodeModeRegExp,
SandboxSet, CodeModeSet,
SandboxURL, CodeModeURL,
SandboxURLSearchParams, CodeModeURLSearchParams,
} from "../values.js" } from "../values.js"
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { 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) { if (rhs instanceof GlobalNamespace) {
switch (rhs.name) { switch (rhs.name) {
case "Date": case "Date":
return lhs instanceof SandboxDate return lhs instanceof CodeModeDate
case "RegExp": case "RegExp":
return lhs instanceof SandboxRegExp return lhs instanceof CodeModeRegExp
case "Map": case "Map":
return lhs instanceof SandboxMap return lhs instanceof CodeModeMap
case "Set": case "Set":
return lhs instanceof SandboxSet return lhs instanceof CodeModeSet
case "URL": case "URL":
return lhs instanceof SandboxURL return lhs instanceof CodeModeURL
case "URLSearchParams": case "URLSearchParams":
return lhs instanceof SandboxURLSearchParams return lhs instanceof CodeModeURLSearchParams
case "Array": case "Array":
return Array.isArray(lhs) return Array.isArray(lhs)
case "Object": case "Object":
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") 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")) { if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
return false return false
} }
@@ -226,7 +226,7 @@ export class Interpreter<R> {
} }
// The implicit async body adopts returned promises before copy-out. // 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 return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
} }
@@ -235,16 +235,16 @@ export class Interpreter<R> {
private createToolCallPromise( private createToolCallPromise(
path: ReadonlyArray<string>, path: ReadonlyArray<string>,
args: Array<unknown>, args: Array<unknown>,
): Effect.Effect<SandboxPromise, never, R> { ): Effect.Effect<CodeModePromise, never, R> {
return this.createPromise(Effect.suspend(() => this.invokeTool(path, args))) 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) return this.promises.create(effect)
} }
// Fiber exits make settlement idempotent; yielding prevents inline continuation. // 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 const promises = this.promises
return Effect.suspend(() => { return Effect.suspend(() => {
promises.markObserved(promise) promises.markObserved(promise)
@@ -971,7 +971,7 @@ export class Interpreter<R> {
// Await always suspends, including for plain values. // Await always suspends, including for plain values.
const self = this const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => 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": case "NewExpression":
@@ -1019,23 +1019,23 @@ export class Interpreter<R> {
throw unsupportedSyntax("NewExpression", node) throw unsupportedSyntax("NewExpression", node)
} }
private constructDate(args: Array<unknown>): SandboxDate { private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new SandboxDate(Date.now()) if (args.length === 0) return new CodeModeDate(Date.now())
if (args.length === 1) { if (args.length === 1) {
const arg = args[0] const arg = args[0]
if (arg instanceof SandboxDate) return new SandboxDate(arg.time) if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time)
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime())
if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) if (typeof arg === "string") return new CodeModeDate(Date.parse(arg))
return new SandboxDate(Number.NaN) return new CodeModeDate(Number.NaN)
} }
const parts = args.map((arg) => coerceToNumber(arg)) 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 first = args[0]
const pattern = 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] const flagsArg = args[1]
if (flagsArg !== undefined && typeof flagsArg !== "string") { if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
@@ -1043,9 +1043,9 @@ export class Interpreter<R> {
node, node,
) )
} }
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
try { try {
return new SandboxRegExp(pattern, flags) return new CodeModeRegExp(pattern, flags)
} catch (error) { } catch (error) {
const reason = regexFailureReason(error) const reason = regexFailureReason(error)
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
@@ -1057,12 +1057,12 @@ export class Interpreter<R> {
} }
} }
private constructMap(init: unknown, node: AstNode): SandboxMap { private constructMap(init: unknown, node: AstNode): CodeModeMap {
const target = new SandboxMap() const target = new CodeModeMap()
if (init === undefined || init === null) return target if (init === undefined || init === null) return target
const entries = Array.isArray(init) const entries = Array.isArray(init)
? init ? init
: init instanceof SandboxMap : init instanceof CodeModeMap
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item]) ? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
: undefined : undefined
if (entries === undefined) { if (entries === undefined) {
@@ -1080,12 +1080,12 @@ export class Interpreter<R> {
return target return target
} }
private constructSet(init: unknown, node: AstNode): SandboxSet { private constructSet(init: unknown, node: AstNode): CodeModeSet {
const target = new SandboxSet() const target = new CodeModeSet()
if (init === undefined || init === null) return target if (init === undefined || init === null) return target
const items = Array.isArray(init) const items = Array.isArray(init)
? init ? init
: init instanceof SandboxSet : init instanceof CodeModeSet
? Array.from(init.set.values()) ? Array.from(init.set.values())
: typeof init === "string" : typeof init === "string"
? Array.from(init) ? Array.from(init)
@@ -1097,7 +1097,7 @@ export class Interpreter<R> {
return target return target
} }
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL { private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
if (args.length === 0) { if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as( throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError", "TypeError",
@@ -1106,7 +1106,7 @@ export class Interpreter<R> {
const input = urlArgument(args[0], "new URL input") const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base") const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try { try {
return new SandboxURL(new URL(input, base)) return new CodeModeURL(new URL(input, base))
} catch { } catch {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`, `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 { private constructURLSearchParams(init: unknown, node: AstNode): CodeModeURLSearchParams {
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams()) if (init === undefined) return new CodeModeURLSearchParams(new URLSearchParams())
if (init instanceof SandboxURLSearchParams) { if (init instanceof CodeModeURLSearchParams) {
return new SandboxURLSearchParams(new URLSearchParams(init.params)) 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") { 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( return this.constructURLSearchParams(
Array.from(init.map.entries(), ([key, value]) => [key, value]), Array.from(init.map.entries(), ([key, value]) => [key, value]),
node, node,
@@ -1143,9 +1143,9 @@ export class Interpreter<R> {
string, 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") const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") { if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
@@ -1153,7 +1153,7 @@ export class Interpreter<R> {
node, node,
).as("TypeError") ).as("TypeError")
} }
return new SandboxURLSearchParams( return new CodeModeURLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), 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. // Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use string coercion for `+` and epoch time elsewhere. // Dates use string coercion for `+` and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => { 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 return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
} }
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" 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") throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
} }
const operand = const operand =
value instanceof SandboxDate value instanceof CodeModeDate
? value.time ? value.time
: value !== null && typeof value === "object" : value !== null && typeof value === "object"
? coerceToString(value) ? coerceToString(value)
@@ -1520,11 +1520,11 @@ export class Interpreter<R> {
}) })
if (!fn.async) return run if (!fn.async) return run
// The initial yield assigns `box.own` before the body can self-resolve. // The initial yield assigns `box.own` before the body can self-resolve.
const box: { own?: SandboxPromise } = {} const box: { own?: CodeModePromise } = {}
return Effect.map( return Effect.map(
this.createPromise( this.createPromise(
Effect.flatMap(run, (value) => { 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()) if (value === box.own) return Effect.fail(selfResolutionError())
return invocation.settlePromise(value) return invocation.settlePromise(value)
}), }),
@@ -1546,7 +1546,7 @@ export class Interpreter<R> {
if (property.type === "SpreadElement") { if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(property, "argument")) 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)) { if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
"Object spread requires a data object in CodeMode.", "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.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) if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof SandboxRegExp) { if (objectValue instanceof CodeModeRegExp) {
if (typeof key === "string" && regexpProperties.has(key)) { if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[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) if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof SandboxMap) { if (objectValue instanceof CodeModeMap) {
if (key === "size") return new ComputedValue(objectValue.map.size) if (key === "size") return new ComputedValue(objectValue.map.size)
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof SandboxSet) { if (objectValue instanceof CodeModeSet) {
if (key === "size") return new ComputedValue(objectValue.set.size) if (key === "size") return new ComputedValue(objectValue.set.size)
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof SandboxURL) { if (objectValue instanceof CodeModeURL) {
if (key === "searchParams") { if (key === "searchParams") {
return new ComputedValue(objectValue.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 } if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined) return new ComputedValue(undefined)
} }
if (objectValue instanceof SandboxURLSearchParams) { if (objectValue instanceof CodeModeURLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size) if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) { if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key) return new IntrinsicReference(objectValue, key)
@@ -1786,7 +1786,7 @@ export class Interpreter<R> {
} }
// Reject unknown promise properties so a missing await cannot hide. // 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") { if (key === "then" || key === "catch" || key === "finally") {
return new PromiseInstanceMethodReference(objectValue, key) 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)] 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.url as unknown as Record<string, unknown>)[String(reference.key)]
} }
return reference.target[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 key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
const current = const current =
reference.target instanceof SandboxURL reference.target instanceof CodeModeURL
? (reference.target.url as unknown as Record<string, unknown>)[key] ? (reference.target.url as unknown as Record<string, unknown>)[key]
: (reference.target as Record<PropertyKey, unknown>)[key] : (reference.target as Record<PropertyKey, unknown>)[key]
const { write, next, result } = yield* compute(current) const { write, next, result } = yield* compute(current)
@@ -1915,7 +1915,7 @@ export class Interpreter<R> {
target[index] = next target[index] = next
return return
} }
if (reference.target instanceof SandboxURL) { if (reference.target instanceof CodeModeURL) {
const property = key as string const property = key as string
if (!urlWritableProperties.has(property)) { if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError") throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
+4 -4
View File
@@ -43,9 +43,9 @@ export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "
export const spreadItems = (value: unknown): Array<unknown> | undefined => { export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(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 CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof SandboxSet) return Array.from(value.set.values()) if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item]) if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined return undefined
} }
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
+17 -17
View File
@@ -1,14 +1,14 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js" import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js" import { copyIn, copyOut } from "../tool-runtime.js"
import { import {
isSandboxValue, isCodeModeValue,
SandboxDate, CodeModeDate,
SandboxMap, CodeModeMap,
SandboxPromise, CodeModePromise,
SandboxRegExp, CodeModeRegExp,
SandboxSet, CodeModeSet,
SandboxURL, CodeModeURL,
SandboxURLSearchParams, CodeModeURLSearchParams,
} from "../values.js" } from "../values.js"
import { boundedData, coerceToString } from "./value.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 === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value) if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") 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 CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof SandboxDate) return coerceToString(value) if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof SandboxRegExp) return coerceToString(value) if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof SandboxURL) return coerceToString(value) if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof SandboxURLSearchParams) return coerceToString(value) if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..." if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]" if (seen.has(value)) return "[Circular]"
if (value instanceof SandboxMap) { if (value instanceof CodeModeMap) {
seen.add(value) seen.add(value)
try { try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item]) 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) seen.delete(value)
} }
} }
if (value instanceof SandboxSet) { if (value instanceof CodeModeSet) {
seen.add(value) seen.add(value)
try { try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
@@ -100,14 +100,14 @@ const consoleTableRows = (
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) 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 Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
} }
return [{ index: "0", values: { Value: data } }] return [{ index: "0", values: { Value: data } }]
} }
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => { 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> const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source)) return Object.fromEntries(Object.entries(source))
+2 -2
View File
@@ -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) const hosted = new Date(value.time)
switch (name) { switch (name) {
case "getTime": case "getTime":
@@ -88,5 +88,5 @@ export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode
} }
} }
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { SandboxDate } from "../values.js" import { CodeModeDate } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js" import { coerceToNumber, coerceToString } from "./value.js"
+9 -9
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember } from "../tool-runtime.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" import { boundedData, coerceToString } from "./value.js"
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"]) 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 requireObject = (): Record<string, unknown> => {
const input = args[0] const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown> if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (isSandboxValue(input)) return {} if (isCodeModeValue(input)) return {}
if (input instanceof SandboxPromise) { if (input instanceof CodeModePromise) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`, `Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node, node,
@@ -46,12 +46,12 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.hasOwn(requireObject(), String(args[1])) return Object.hasOwn(requireObject(), String(args[1]))
case "assign": { case "assign": {
const target = args[0] 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) throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
} }
const out = target as Record<string, unknown> const out = target as Record<string, unknown>
for (const source of args.slice(1)) { 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)) { if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node) 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 return out
} }
case "fromEntries": { case "fromEntries": {
if (args[0] instanceof SandboxMap) { if (args[0] instanceof CodeModeMap) {
const out: Record<string, unknown> = Object.create(null) const out: Record<string, unknown> = Object.create(null)
for (const [key, item] of args[0].map.entries()) addEntry(out, key, item) for (const [key, item] of args[0].map.entries()) addEntry(out, key, item)
return out return out
} }
if (args[0] instanceof SandboxURLSearchParams) { if (args[0] instanceof CodeModeURLSearchParams) {
const out: Record<string, unknown> = Object.create(null) const out: Record<string, unknown> = Object.create(null)
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value) for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
return out 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)) { if (!Array.isArray(pairs)) {
boundedData(args[0], "Object.fromEntries input") boundedData(args[0], "Object.fromEntries input")
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) 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) const out: Record<string, unknown> = Object.create(null)
for (const pair of pairs) { for (const pair of pairs) {
const validated = boundedData(pair, "Object.fromEntries entry") 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) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
const entry = pair as Record<string, unknown> const entry = pair as Record<string, unknown>
addEntry(out, entry[0], entry[1]) addEntry(out, entry[0], entry[1])
+3 -3
View File
@@ -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.' '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 => { 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") { if (typeof arg === "string") {
try { try {
return new RegExp(arg, extraFlags) return new RegExp(arg, extraFlags)
@@ -50,7 +50,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
} }
export const invokeRegExpMethod = ( export const invokeRegExpMethod = (
value: SandboxRegExp, value: CodeModeRegExp,
name: string, name: string,
args: Array<unknown>, args: Array<unknown>,
node: AstNode, node: AstNode,
@@ -70,5 +70,5 @@ export const invokeRegExpMethod = (
} }
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js" import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { SandboxRegExp } from "../values.js" import { CodeModeRegExp } from "../values.js"
import { coerceToString } from "./value.js" import { coerceToString } from "./value.js"
+4 -4
View File
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
} }
export const urlArgument = (value: unknown, label: string): string => 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 => { 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) 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`) const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try { try {
const url = new URL(input, base) const url = new URL(input, base)
return name === "canParse" ? true : new SandboxURL(url) return name === "canParse" ? true : new CodeModeURL(url)
} catch { } catch {
return name === "canParse" ? false : null 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 if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node) throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
} }
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js" 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" import { boundedData, coerceToString } from "./value.js"
+16 -16
View File
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
export const coerceToString = (value: unknown): string => { export const coerceToString = (value: unknown): string => {
if (value === null) return "null" if (value === null) return "null"
if (value === undefined) return "undefined" 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" 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 CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof SandboxMap) return "[object Map]" if (value instanceof CodeModeMap) return "[object Map]"
if (value instanceof SandboxSet) return "[object Set]" if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof SandboxURL) return value.url.href if (value instanceof CodeModeURL) return value.url.href
if (value instanceof SandboxURLSearchParams) return value.params.toString() if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (typeof value === "object") { if (typeof value === "object") {
return Array.isArray(value) return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") ? 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 => { export const coerceToNumber = (value: unknown): number => {
if (value instanceof SandboxDate) return value.time if (value instanceof CodeModeDate) return value.time
if (isSandboxValue(value)) return Number.NaN if (isCodeModeValue(value)) return Number.NaN
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
} }
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => { export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
const raw = args[0] const raw = args[0]
if (isSandboxValue(raw)) { if (isCodeModeValue(raw)) {
if (ref.name === "Boolean") return true if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw) if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(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 { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js" import { copyIn, type SafeObject } from "../tool-runtime.js"
import { import {
isSandboxValue, isCodeModeValue,
SandboxDate, CodeModeDate,
SandboxMap, CodeModeMap,
SandboxRegExp, CodeModeRegExp,
SandboxSet, CodeModeSet,
SandboxURL, CodeModeURL,
SandboxURLSearchParams, CodeModeURLSearchParams,
} from "../values.js" } from "../values.js"
+34 -34
View File
@@ -10,13 +10,13 @@ import {
} from "./tool-schema.js" } from "./tool-schema.js"
import { isDefinition as isToolDefinition, type Definition } from "./tool.js" import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
import { import {
SandboxDate, CodeModeDate,
SandboxMap, CodeModeMap,
SandboxPromise, CodeModePromise,
SandboxRegExp, CodeModeRegExp,
SandboxSet, CodeModeSet,
SandboxURL, CodeModeURL,
SandboxURLSearchParams, CodeModeURLSearchParams,
} from "./values.js" } from "./values.js"
const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) 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) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
// Checkpoint mode preserves sandbox values; boundary mode JSON-normalizes them. // Checkpoint mode preserves CodeMode values; boundary mode JSON-normalizes them.
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => export const copyIn = (value: unknown, label: string, preserveCodeModeValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues) copyBounded(value, label, 0, new Set(), preserveCodeModeValues)
const copyBounded = ( const copyBounded = (
value: unknown, value: unknown,
label: string, label: string,
depth: number, depth: number,
seen: Set<object>, seen: Set<object>,
preserveSandboxValues: boolean, preserveCodeModeValues: boolean,
): unknown => { ): unknown => {
if (depth > MAX_VALUE_DEPTH) { if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${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.`) throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
} }
if (value instanceof SandboxPromise) { if (value instanceof CodeModePromise) {
throw new ToolRuntimeError( throw new ToolRuntimeError(
"InvalidDataValue", "InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, `${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 ( if (
value instanceof SandboxDate || value instanceof CodeModeDate ||
value instanceof SandboxRegExp || value instanceof CodeModeRegExp ||
value instanceof SandboxMap || value instanceof CodeModeMap ||
value instanceof SandboxSet || value instanceof CodeModeSet ||
value instanceof SandboxURL || value instanceof CodeModeURL ||
value instanceof SandboxURLSearchParams value instanceof CodeModeURLSearchParams
) { ) {
return value return value
} }
if (value instanceof Date) return new SandboxDate(value.getTime()) if (value instanceof Date) return new CodeModeDate(value.getTime())
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
if (value instanceof Map) { if (value instanceof Map) {
const wrapped = new SandboxMap() const wrapped = new CodeModeMap()
for (const [key, item] of value.entries()) { for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
} }
return wrapped return wrapped
} }
if (value instanceof Set) { 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)) for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped return wrapped
} }
if (value instanceof URL) return new SandboxURL(new URL(value.href)) if (value instanceof URL) return new CodeModeURL(new URL(value.href))
if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) 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 return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
} }
if (value instanceof Date) { if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null 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 URL) return value.href
if ( if (
value instanceof SandboxRegExp || value instanceof CodeModeRegExp ||
value instanceof SandboxMap || value instanceof CodeModeMap ||
value instanceof SandboxSet || value instanceof CodeModeSet ||
value instanceof SandboxURLSearchParams || value instanceof CodeModeURLSearchParams ||
value instanceof RegExp || value instanceof RegExp ||
value instanceof Map || value instanceof Map ||
value instanceof Set || value instanceof Set ||
@@ -233,8 +233,8 @@ const copyBounded = (
seen.add(value) seen.add(value)
if (Array.isArray(value)) { if (Array.isArray(value)) {
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveCodeModeValues))
if (preserveSandboxValues) { if (preserveCodeModeValues) {
// Checkpoint copies retain array metadata that boundary copies omit. // Checkpoint copies retain array metadata that boundary copies omit.
for (const [key, item] of Object.entries(value)) { for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue if (Object.hasOwn(copied, key)) continue
@@ -258,7 +258,7 @@ const copyBounded = (
if (isBlockedMember(key)) { if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${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) seen.delete(value)
return copied return copied
+17 -17
View File
@@ -1,45 +1,45 @@
import type { Fiber } from "effect" import type { Fiber } from "effect"
export class SandboxPromise { export class CodeModePromise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {} constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
} }
export class SandboxDate { export class CodeModeDate {
constructor(readonly time: number) {} constructor(readonly time: number) {}
} }
export class SandboxRegExp { export class CodeModeRegExp {
readonly regex: RegExp readonly regex: RegExp
constructor(pattern: string, flags: string) { constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags) this.regex = new RegExp(pattern, flags)
} }
} }
export class SandboxMap { export class CodeModeMap {
readonly map = new Map<unknown, unknown>() readonly map = new Map<unknown, unknown>()
} }
export class SandboxSet { export class CodeModeSet {
readonly set = new Set<unknown>() readonly set = new Set<unknown>()
} }
export class SandboxURLSearchParams { export class CodeModeURLSearchParams {
constructor(readonly params: URLSearchParams) {} constructor(readonly params: URLSearchParams) {}
} }
export class SandboxURL { export class CodeModeURL {
readonly searchParams: SandboxURLSearchParams readonly searchParams: CodeModeURLSearchParams
constructor(readonly url: URL) { 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: unknown,
): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams => ): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
value instanceof SandboxDate || value instanceof CodeModeDate ||
value instanceof SandboxRegExp || value instanceof CodeModeRegExp ||
value instanceof SandboxMap || value instanceof CodeModeMap ||
value instanceof SandboxSet || value instanceof CodeModeSet ||
value instanceof SandboxURL || value instanceof CodeModeURL ||
value instanceof SandboxURLSearchParams value instanceof CodeModeURLSearchParams
+2 -2
View File
@@ -276,7 +276,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) 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( const result = await Effect.runPromise(
CodeMode.execute({ CodeMode.execute({
code: ` code: `
@@ -311,7 +311,7 @@ describe("CodeMode console capture", () => {
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}']) 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( const result = await Effect.runPromise(
CodeMode.execute({ CodeMode.execute({
code: ` code: `
+6 -6
View File
@@ -8,7 +8,7 @@ import { ToolRuntime } from "../src/tool-runtime.js"
// a strict interpreter would throw but idiomatic JS yields undefined / succeeds. // 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 // 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. // `undefined` read check `=== undefined` inside the program and `null` at the boundary.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => { 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) 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 5/0`)).toBeNull()
expect(await value(`return 0/0`)).toBeNull() expect(await value(`return 0/0`)).toBeNull()
expect(await value(`return Math.max()`)).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] }) 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 Number.isNaN(NaN)`)).toBe(true)
expect(await value(`return Infinity > 1e9`)).toBe(true) expect(await value(`return Infinity > 1e9`)).toBe(true)
expect(await value(`return Number.isFinite(1/0)`)).toBe(false) 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) 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}') 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 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`) 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") 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( expect(
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`), await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
).toBe(600) ).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: // Sources:
// test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js // test/built-ins/Promise/resolve/S25.4.4.5_A2.1_T1.js
// test/built-ins/Promise/resolve/resolve-non-obj.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/all/reject-immed.js
// test/built-ins/Promise/allSettled/reject-immed.js // test/built-ins/Promise/allSettled/reject-immed.js
// test/built-ins/Promise/race/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) // and were even observed, before the combinator call)
expect( expect(
await value(` 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_A2.1_T1.js
// test/built-ins/Promise/race/S25.4.4.3_A5.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 // (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) // divergence rather than the spec never-settles behavior)
expect( expect(
await value(` await value(`
@@ -375,7 +375,7 @@ describe("Test262 Promise statics", () => {
).toEqual([true, true]) ).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 // 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 // (adapted: no executor construction, and identity is observed with Array includes
// because promises are not comparable data values in CodeMode) // 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 () => { test("coerces a non-string message to a string", async () => {
// Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the // 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( expect(
await value(` await value(`
return [ return [
@@ -1408,7 +1408,7 @@ describe("Test262 Promise constructor", () => {
test.failing("calling Promise without new throws TypeError", async () => { test.failing("calling Promise without new throws TypeError", async () => {
// Source: test/built-ins/Promise/undefined-newtarget.js // 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( expect(
await value(` await value(`
try { try {
+144 -69
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import { CodeMode, Tool, toolError } from "../src/index.js"
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on // 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 }) 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({ Tool.make({
description: "Echo an id after a delay", description: "Echo an id immediately",
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), input: Schema.Struct({ id: Schema.Number }),
output: 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* () { Effect.gen(function* () {
trace.starts.push(id) trace.starts.push(id)
trace.active += 1 trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active) trace.maxActive = Math.max(trace.maxActive, trace.active)
yield* Effect.sleep(ms ?? 20) yield* Deferred.await(gate(id))
trace.active -= 1 trace.active -= 1
trace.completed += 1 trace.completed += 1
return id 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({ const failingTool = Tool.make({
description: "Always refuse", description: "Always refuse",
input: Schema.Struct({}), input: Schema.Struct({}),
@@ -58,7 +110,7 @@ const interruptedTool = Tool.make({
const completedTool = (trace: Trace) => const completedTool = (trace: Trace) =>
Tool.make({ Tool.make({
description: "Return the number of completed sleepy calls", description: "Return the number of completed calls",
input: Schema.Struct({}), input: Schema.Struct({}),
output: Schema.Number, output: Schema.Number,
run: () => Effect.succeed(trace.completed), run: () => Effect.succeed(trace.completed),
@@ -88,11 +140,22 @@ const run = (
options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {},
): Promise<CodeMode.Result> => { ): Promise<CodeMode.Result> => {
const trace = options.trace ?? makeTrace() 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( return Effect.runPromise(
CodeMode.execute({ CodeMode.execute({
tools: { tools: {
host: { host: {
sleepy: sleepyTool(trace), echo: echoTool(trace),
gated: gatedTool(trace, gate),
open: openTool(gate),
pending: pendingTool(trace),
fail: failingTool, fail: failingTool,
interrupt: interruptedTool, interrupt: interruptedTool,
completed: completedTool(trace), completed: completedTool(trace),
@@ -122,7 +185,7 @@ describe("first-class promise values", () => {
expect( expect(
await value(` await value(`
const load = async (id) => { const load = async (id) => {
const result = await tools.host.sleepy({ id, ms: 20 }) const result = await tools.host.echo({ id })
return [id, result] return [id, result]
} }
const first = load(1) const first = load(1)
@@ -158,8 +221,10 @@ describe("first-class promise values", () => {
const trace = makeTrace() const trace = makeTrace()
const result = await value( const result = await value(
` `
const a = tools.host.sleepy({ id: 1, ms: 40 }) const a = tools.host.gated({ id: 1 })
const b = tools.host.sleepy({ id: 2, ms: 40 }) const b = tools.host.gated({ id: 2 })
await tools.host.open({ id: 1 })
await tools.host.open({ id: 2 })
const rb = await b const rb = await b
const ra = await a const ra = await a
return [ra, rb] 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 () => { test("awaiting the same promise twice settles once and never re-runs the call", async () => {
const result = await run(` const result = await run(`
const p = tools.host.sleepy({ id: 7 }) const p = tools.host.echo({ id: 7 })
const x = await p const x = await p
const y = await p const y = await p
return [x, y] return [x, y]
@@ -182,7 +247,7 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
expect(result.value).toEqual([7, 7]) 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 () => { 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 () => { 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 () => { 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 trace = makeTrace()
const result = await run( const result = await run(
` `
tools.host.sleepy({ id: 1, ms: 30 }) tools.host.pending({ id: 1 })
return "done" return "done"
`, `,
{ trace }, { trace },
@@ -307,7 +372,7 @@ describe("first-class promise values", () => {
const result = await run( const result = await run(
` `
const run = async () => { const run = async () => {
await tools.host.sleepy({ id: 1, ms: 60000 }) await tools.host.pending({ id: 1 })
tools.host.fail({}) tools.host.fail({})
} }
run() run()
@@ -373,7 +438,7 @@ describe("first-class promise values", () => {
const trace = makeTrace() const trace = makeTrace()
const result = await run( const result = await run(
` `
tools.host.sleepy({ id: 1, ms: 1_000 }) tools.host.pending({ id: 1 })
throw new Error("boom") throw new Error("boom")
`, `,
{ trace }, { trace },
@@ -392,8 +457,8 @@ describe("first-class promise values", () => {
await value( await value(
` `
const launch = async () => { const launch = async () => {
tools.host.sleepy({ id: 1, ms: 60000 }) tools.host.pending({ id: 1 })
Promise.all([tools.host.sleepy({ id: 2, ms: 60000 })]) Promise.all([tools.host.pending({ id: 2 })])
return "returned" return "returned"
} }
return await launch() return await launch()
@@ -411,7 +476,7 @@ describe("first-class promise values", () => {
describe("promises at data boundaries", () => { describe("promises at data boundaries", () => {
test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => { 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.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise") expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await tools.ns.tool(...)") expect(diagnostic.message).toContain("await tools.ns.tool(...)")
@@ -427,7 +492,7 @@ describe("promises at data boundaries", () => {
const trace = makeTrace() const trace = makeTrace()
const result = await run( const result = await run(
` `
const pending = tools.host.sleepy({ id: 1, ms: 60_000 }) const pending = tools.host.pending({ id: 1 })
return { pending } return { pending }
`, `,
{ trace, limits: { timeoutMs: 100 } }, { 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 () => { 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.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise") expect(diagnostic.message).toContain("un-awaited Promise")
}) })
@@ -475,8 +540,10 @@ describe("Promise.all over arbitrary arrays", () => {
expect( expect(
await value( await value(
` `
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })]) const first = Promise.all([tools.host.gated({ id: 1 })])
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })]) 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] return [await first, await second]
`, `,
{ trace }, { trace },
@@ -502,19 +569,19 @@ describe("Promise.all over arbitrary arrays", () => {
test("awaiting an aggregate repeatedly does not rerun its members", async () => { test("awaiting an aggregate repeatedly does not rerun its members", async () => {
const result = await run(` 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] return [await aggregate, await aggregate]
`) `)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
expect(result.value).toEqual([[7], [7]]) 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 () => { test("mixes promises and plain values, preserving order", async () => {
expect( expect(
await value(` 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]) ).toEqual([1, "plain", 2, 42])
}) })
@@ -523,9 +590,9 @@ describe("Promise.all over arbitrary arrays", () => {
expect( expect(
await value(` await value(`
const calls = [] const calls = []
calls.push(tools.host.sleepy({ id: 1 })) calls.push(tools.host.echo({ id: 1 }))
calls.push(7) calls.push(7)
const more = [tools.host.sleepy({ id: 2 })] const more = [tools.host.echo({ id: 2 })]
const batch = [...calls, ...more, "x"] const batch = [...calls, ...more, "x"]
return await Promise.all(batch) return await Promise.all(batch)
`), `),
@@ -537,7 +604,9 @@ describe("Promise.all over arbitrary arrays", () => {
const result = await value( const result = await value(
` `
const ids = [1, 2, 3, 4] 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 }, { trace },
) )
@@ -552,7 +621,9 @@ describe("Promise.all over arbitrary arrays", () => {
const result = await value( const result = await value(
` `
const ids = [1, 2, 3, 4] 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 }, { trace },
) )
@@ -566,7 +637,9 @@ describe("Promise.all over arbitrary arrays", () => {
` `
const ids = [] const ids = []
for (let i = 0; i < 20; i += 1) ids.push(i) 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 return results.length
`, `,
{ trace }, { trace },
@@ -582,7 +655,7 @@ describe("Promise.all over arbitrary arrays", () => {
test("rejects with the first failure, catchable in-program", async () => { test("rejects with the first failure, catchable in-program", async () => {
const result = await run(` const result = await run(`
try { 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" return "no"
} catch (e) { } catch (e) {
return e.message return e.message
@@ -601,7 +674,7 @@ describe("Promise.all over arbitrary arrays", () => {
` `
try { try {
await Promise.all([ await Promise.all([
tools.host.sleepy({ id: 1, ms: 100 }), tools.host.pending({ id: 1 }),
tools.host.fail({}), tools.host.fail({}),
]) ])
return -1 return -1
@@ -623,11 +696,12 @@ describe("Promise.all over arbitrary arrays", () => {
expect( expect(
await value( await value(
` `
const slow = tools.host.sleepy({ id: 1, ms: 40 }) const slow = tools.host.gated({ id: 1 })
try { try {
await Promise.all([slow, tools.host.fail({})]) await Promise.all([slow, tools.host.fail({})])
return "no" return "no"
} catch {} } catch {}
await tools.host.open({ id: 1 })
return await slow return await slow
`, `,
{ trace }, { trace },
@@ -643,7 +717,7 @@ describe("Promise.all over arbitrary arrays", () => {
await value( await value(
` `
const failLater = async () => { const failLater = async () => {
await tools.host.sleepy({ id: 1, ms: 40 }) await tools.host.pending({ id: 1 })
throw new Error("later") throw new Error("later")
} }
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()]) 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 () => { test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => {
const diagnostic = await error( 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 } }, { limits: { maxToolCalls: 2 } },
) )
expect(diagnostic.kind).toBe("ToolCallLimitExceeded") expect(diagnostic.kind).toBe("ToolCallLimitExceeded")
@@ -680,7 +754,7 @@ describe("Promise.allSettled", () => {
expect( expect(
await value(` await value(`
return await Promise.allSettled([ return await Promise.allSettled([
tools.host.sleepy({ id: 5 }), tools.host.echo({ id: 5 }),
tools.host.fail({}), tools.host.fail({}),
"plain", "plain",
Promise.reject(new Error("boom")), Promise.reject(new Error("boom")),
@@ -711,8 +785,8 @@ describe("Promise.race", () => {
const trace = makeTrace() const trace = makeTrace()
const result = await value( const result = await value(
` `
const fast = tools.host.sleepy({ id: 1, ms: 10 }) const fast = tools.host.echo({ id: 1 })
const slow = tools.host.sleepy({ id: 2, ms: 40 }) const slow = tools.host.pending({ id: 2 })
return await Promise.race([fast, slow]) return await Promise.race([fast, slow])
`, `,
{ trace }, { trace },
@@ -726,9 +800,10 @@ describe("Promise.race", () => {
test("a direct loser remains awaitable after the race settles", async () => { test("a direct loser remains awaitable after the race settles", async () => {
expect( expect(
await value(` await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 }) const fast = tools.host.echo({ id: 1 })
const slow = tools.host.sleepy({ id: 2, ms: 40 }) const slow = tools.host.gated({ id: 2 })
const winner = await Promise.race([fast, slow]) const winner = await Promise.race([fast, slow])
await tools.host.open({ id: 2 })
return { winner, loser: await slow } return { winner, loser: await slow }
`), `),
).toEqual({ winner: 1, loser: 2 }) ).toEqual({ winner: 1, loser: 2 })
@@ -740,8 +815,8 @@ describe("Promise.race", () => {
await value( await value(
` `
const nested = Promise.all([ const nested = Promise.all([
tools.host.sleepy({ id: 1, ms: 40 }), tools.host.pending({ id: 1 }),
tools.host.sleepy({ id: 2, ms: 40 }), tools.host.pending({ id: 2 }),
]) ])
return await Promise.race(["immediate", nested]) return await Promise.race(["immediate", nested])
`, `,
@@ -757,7 +832,7 @@ describe("Promise.race", () => {
expect( expect(
await value(` await value(`
try { 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" return "no"
} catch (e) { } catch (e) {
return e.message return e.message
@@ -768,9 +843,9 @@ describe("Promise.race", () => {
test("a plain value wins over pending promises", async () => { test("a plain value wins over pending promises", async () => {
const trace = makeTrace() const trace = makeTrace()
expect( expect(await value(`return await Promise.race([tools.host.pending({ id: 1 }), "immediate"])`, { trace })).toBe(
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }), "immediate",
).toBe("immediate") )
expect(trace.completed).toBe(0) expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1) expect(trace.interrupted).toBe(1)
}) })
@@ -793,7 +868,7 @@ describe("Promise.resolve / Promise.reject", () => {
test("resolve wraps plain values and passes promises through", async () => { 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(42)`)).toBe(42)
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested") 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( expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
true, true,
) )
@@ -816,7 +891,7 @@ describe("Promise.resolve / Promise.reject", () => {
expect( expect(
await value(` await value(`
const rejected = Promise.reject(new Error("handled")) const rejected = Promise.reject(new Error("handled"))
await tools.host.sleepy({ id: 1 }) await tools.host.echo({ id: 1 })
try { try {
await rejected await rejected
return "no" return "no"
@@ -846,8 +921,8 @@ describe("timeout interruption of forked calls", () => {
const trace = makeTrace() const trace = makeTrace()
const result = await run( const result = await run(
` `
const a = tools.host.sleepy({ id: 1, ms: 60000 }) const a = tools.host.pending({ id: 1 })
const b = tools.host.sleepy({ id: 2, ms: 60000 }) const b = tools.host.pending({ id: 2 })
return await a return await a
`, `,
{ trace, limits: { timeoutMs: 100 } }, { trace, limits: { timeoutMs: 100 } },
@@ -864,7 +939,7 @@ describe("timeout interruption of forked calls", () => {
test("the timeout also interrupts calls inside Promise.all", async () => { test("the timeout also interrupts calls inside Promise.all", async () => {
const trace = makeTrace() const trace = makeTrace()
const result = await run( 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 } }, { trace, limits: { timeoutMs: 100 } },
) )
expect(result.ok).toBe(false) 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 () => { test("a non-settling race loser cannot hold the execution to the timeout", async () => {
const trace = makeTrace() 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, trace,
limits: { timeoutMs: 100 }, limits: { timeoutMs: 100 },
}) })
@@ -940,8 +1015,8 @@ describe("promise chaining", () => {
expect( expect(
await value(` await value(`
return await tools.host return await tools.host
.sleepy({ id: 2 }) .echo({ id: 2 })
.then((id) => tools.host.sleepy({ id: id + 1 })) .then((id) => tools.host.echo({ id: id + 1 }))
.then((id) => id * 10) .then((id) => id * 10)
`), `),
).toBe(30) ).toBe(30)
@@ -966,7 +1041,7 @@ describe("promise chaining", () => {
await value(` await value(`
return [ return [
await tools.host.fail({}).catch((error) => error.message), 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]) ).toEqual(["Lookup refused", 4])
@@ -976,7 +1051,7 @@ describe("promise chaining", () => {
expect( expect(
await value(` await value(`
const events = [] 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] return [result, events]
`), `),
).toEqual([5, ["cleanup"]]) ).toEqual([5, ["cleanup"]])
@@ -1009,12 +1084,12 @@ describe("promise chaining", () => {
}) })
test("non-plain-function handlers fail loudly instead of being ignored", async () => { 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") expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
}) })
test("chaining methods are opaque references until called", async () => { 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. // cannot beat it into rejection.
expect( expect(
await value(` 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)]) const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
try { try {
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")]) 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", () => { describe("unsupported promise surface", () => {
test("other property reads on a promise hint at the missing await", async () => { 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.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("un-awaited Promise") expect(diagnostic.message).toContain("un-awaited Promise")
expect(diagnostic.message).toContain("await it first") expect(diagnostic.message).toContain("await it first")
@@ -1074,8 +1149,8 @@ describe("Promise.any", () => {
` `
const winner = await Promise.any([ const winner = await Promise.any([
tools.host.fail({}), tools.host.fail({}),
tools.host.sleepy({ id: 1, ms: 5 }), tools.host.echo({ id: 1 }),
tools.host.sleepy({ id: 2, ms: 60000 }), tools.host.pending({ id: 2 }),
]) ])
return winner return winner
`, `,
@@ -1141,7 +1216,7 @@ describe("promise construction", () => {
const id = await gate const id = await gate
return id * 2 return id * 2
})() })()
openGate(await tools.host.sleepy({ id: 21, ms: 5 })) openGate(await tools.host.echo({ id: 21 }))
return await worker return await worker
`), `),
).toBe(42) ).toBe(42)
@@ -1151,7 +1226,7 @@ describe("promise construction", () => {
expect( expect(
await value(` await value(`
const bridged = new Promise((resolve, reject) => { 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 return await bridged
`), `),
@@ -1163,7 +1238,7 @@ describe("promise construction", () => {
await value(` await value(`
let settle let settle
const manual = new Promise((resolve) => { settle = resolve }) 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 all = Promise.all([manual, "plain"])
const any = Promise.any([manual, new Promise(() => {})]) const any = Promise.any([manual, new Promise(() => {})])
settle("manual") settle("manual")
@@ -1193,7 +1268,7 @@ describe("promise construction", () => {
expect( expect(
await value(` await value(`
const result = new Promise(async (resolve) => { 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) resolve(id * 2)
}) })
return await result return await result

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