mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13ef9924fa | |||
| 746f941b40 | |||
| 4df54601df | |||
| e2614f2be4 | |||
| 016545513f | |||
| b67bed061a | |||
| b2741d2f97 | |||
| 4a93972a78 | |||
| 5c5579e90c | |||
| cd9be63484 | |||
| 40fedf086e | |||
| a6b5cf94b0 | |||
| 634386fe0f | |||
| 26e27c7a7f | |||
| ea386fba1f | |||
| aa8fc4234d | |||
| 9b8282ad3d | |||
| 2a08cd3b96 | |||
| 5cf24bf185 | |||
| c15e3487b2 | |||
| 6963f2f6da | |||
| 547e0148c7 | |||
| 358d4746a9 | |||
| ce2e301e24 | |||
| fbe7f26e71 | |||
| ecb5754f4c | |||
| 36f8cb7054 | |||
| 4c45a5cf23 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 9db5073eab | |||
| 775fce0177 | |||
| 4e74f77e44 | |||
| b980f74ed9 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 | |||
| 339fd50cb9 | |||
| 828a4b3f15 | |||
| a43de3f86d | |||
| fe3b2e8f90 | |||
| 20af1f5e79 | |||
| 8daf912fbf | |||
| 3d545f960b | |||
| 58201a32c1 | |||
| 64ca9b8d77 | |||
| a4b91d33d9 | |||
| 397993e898 | |||
| c0ed0106b1 | |||
| f112a73c06 | |||
| 7e9b9cb0fd | |||
| 09a6cecf23 | |||
| 6e55ddd078 | |||
| f8f8cc9546 | |||
| 3c60470269 | |||
| b2bdab24e6 | |||
| 3568dd1b99 | |||
| 1e17202413 | |||
| 49cb73b348 | |||
| 430750e2f8 | |||
| 7913c4a490 | |||
| 5414697bd1 | |||
| e22e3b8f2c | |||
| 8e657c7db5 | |||
| 02e2277057 | |||
| ac1ddc3f83 | |||
| 8f904c8e9a | |||
| ec07ee56f4 | |||
| de86d73d19 | |||
| 0fa9e5039e | |||
| c073387723 | |||
| 1956497f42 | |||
| 2a7e32c416 | |||
| 56a7c06a80 | |||
| 75e8fd4da2 | |||
| b9f39dd751 | |||
| 6eeeb4bfcf | |||
| 1ccaca826e | |||
| 04f9b15178 | |||
| 66b9cc7931 | |||
| e3f7637eb2 | |||
| 5945a8d429 | |||
| c7ceccf869 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/client": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Reuse a same-version background service when a repeated health probe succeeds instead of replacing an endpoint another client may already be using.
|
||||||
@@ -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
@@ -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.
|
|
||||||
Binary file not shown.
+4
-4
@@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -12,6 +12,7 @@
|
|||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||||
|
"dev:www": "bun run --cwd packages/www dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||||
@@ -48,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",
|
||||||
@@ -75,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",
|
||||||
@@ -102,6 +103,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@ast-grep/cli": "0.44.0",
|
"@ast-grep/cli": "0.44.0",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -158,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,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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,60 +1,167 @@
|
|||||||
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 }}>
|
||||||
<For each={props.attachments}>
|
<div
|
||||||
{(attachment) => (
|
data-slot="prompt-attachments-scroll"
|
||||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
classList={{
|
||||||
<div class="relative group">
|
"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}>
|
||||||
|
{(attachment) => {
|
||||||
|
const image = attachment.mime.startsWith("image/")
|
||||||
|
const media = () => (
|
||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={image}
|
||||||
fallback={
|
fallback={
|
||||||
<div class={fallbackClass}>
|
<Show
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
when={props.newLayoutDesigns}
|
||||||
</div>
|
fallback={
|
||||||
|
<div class={fallbackClass}>
|
||||||
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
|
</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>
|
||||||
</div>
|
)
|
||||||
</Tooltip>
|
const remove = () => (
|
||||||
)}
|
<button
|
||||||
</For>
|
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>
|
||||||
|
</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>
|
||||||
|
</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>
|
</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>
|
||||||
<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]">
|
<TooltipV2
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
placement="top"
|
||||||
<span class="min-w-0 truncate">{branch()}</span>
|
value={branch()}
|
||||||
</div>
|
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]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{branch()}</span>
|
||||||
|
</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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export const dict = {
|
|||||||
"command.message.next.description": "Go to the next user message",
|
"command.message.next.description": "Go to the next user message",
|
||||||
"command.model.choose": "Choose model",
|
"command.model.choose": "Choose model",
|
||||||
"command.model.choose.description": "Select a different model",
|
"command.model.choose.description": "Select a different model",
|
||||||
"command.mcp.toggle": "Toggle MCPs",
|
"command.mcp.toggle": "Manage MCP servers",
|
||||||
"command.mcp.toggle.description": "Toggle MCPs",
|
"command.mcp.toggle.description": "Enable or disable MCP servers",
|
||||||
"command.agent.cycle": "Cycle agent",
|
"command.agent.cycle": "Cycle agent",
|
||||||
"command.agent.cycle.description": "Switch to the next agent",
|
"command.agent.cycle.description": "Switch to the next agent",
|
||||||
"command.agent.cycle.reverse": "Cycle agent backwards",
|
"command.agent.cycle.reverse": "Cycle agent backwards",
|
||||||
@@ -307,9 +307,9 @@ export const dict = {
|
|||||||
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
"prompt.toast.promptSendFailed.title": "Failed to send prompt",
|
||||||
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
"prompt.toast.promptSendFailed.description": "Unable to retrieve session",
|
||||||
|
|
||||||
"dialog.mcp.title": "MCPs",
|
"dialog.mcp.title": "MCP servers",
|
||||||
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
"dialog.mcp.description": "{{enabled}} of {{total}} enabled",
|
||||||
"dialog.mcp.empty": "No MCPs configured",
|
"dialog.mcp.empty": "No MCP servers configured",
|
||||||
|
|
||||||
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
"dialog.lsp.empty": "LSPs auto-detected from file types",
|
||||||
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
"dialog.plugins.empty": "Plugins configured in opencode.json",
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,72 +319,283 @@ export function SessionSidePanel(props: {
|
|||||||
"bg-background-base": !settings.general.newLayoutDesigns(),
|
"bg-background-base": !settings.general.newLayoutDesigns(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DragDropProvider
|
<Show
|
||||||
onDragStart={handleDragStart}
|
when={props.fileBrowserState}
|
||||||
onDragEnd={handleDragEnd}
|
fallback={
|
||||||
onDragOver={handleDragOver}
|
<DragDropProvider
|
||||||
collisionDetector={closestCenter}
|
onDragStart={handleDragStart}
|
||||||
>
|
onDragEnd={handleDragEnd}
|
||||||
<DragDropSensors />
|
onDragOver={handleDragOver}
|
||||||
<ConstrainDragYAxis />
|
collisionDetector={closestCenter}
|
||||||
<Tabs value={activeTab()} onChange={activateTab}>
|
>
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<DragDropSensors />
|
||||||
<Tabs.List
|
<ConstrainDragYAxis />
|
||||||
ref={(el: HTMLDivElement) => {
|
<Tabs value={activeTab()} onChange={activateTab}>
|
||||||
const stop = createFileTabListSync({ el, contextOpen })
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
onCleanup(stop)
|
<Tabs.List
|
||||||
}}
|
ref={(el: HTMLDivElement) => {
|
||||||
>
|
const stop = createFileTabListSync({ el, contextOpen })
|
||||||
<Show when={reviewTab() && props.canReview()}>
|
onCleanup(stop)
|
||||||
<Tabs.Trigger
|
}}
|
||||||
value="review"
|
|
||||||
id={reviewTabID}
|
|
||||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<div>{language.t("session.tab.review")}</div>
|
<Tabs.Trigger
|
||||||
<Show when={props.hasReview()}>
|
value="review"
|
||||||
<div>{props.reviewCount()}</div>
|
id={reviewTabID}
|
||||||
</Show>
|
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||||
</div>
|
>
|
||||||
</Tabs.Trigger>
|
<div class="flex items-center gap-1.5">
|
||||||
</Show>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={contextOpen()}>
|
<Show when={props.hasReview()}>
|
||||||
<Tabs.Trigger
|
<div>{props.reviewCount()}</div>
|
||||||
value="context"
|
</Show>
|
||||||
closeButton={
|
</div>
|
||||||
|
</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>
|
||||||
|
<SortableProvider ids={openedTabs()}>
|
||||||
|
<For each={panelTabs()}>
|
||||||
|
{(tab) => (
|
||||||
|
<Show
|
||||||
|
when={tab === SESSION_OPEN_FILE_TAB}
|
||||||
|
fallback={
|
||||||
|
<SortableTab
|
||||||
|
tab={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>
|
||||||
|
</SortableProvider>
|
||||||
|
<div
|
||||||
|
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("common.closeTab")}
|
title={language.t("command.file.open")}
|
||||||
keybind={command.keybind("tab.close")}
|
keybind={command.keybind("file.open")}
|
||||||
placement="bottom"
|
class="flex items-center"
|
||||||
gutter={10}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-5 w-5"
|
iconSize="large"
|
||||||
onClick={() => tabs().close("context")}
|
class="!rounded-md"
|
||||||
aria-label={language.t("common.closeTab")}
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-file").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
aria-label={language.t("command.file.open")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
|
||||||
hideCloseButton
|
|
||||||
onMiddleClick={() => tabs().close("context")}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<SessionContextUsage variant="indicator" />
|
|
||||||
<div>{language.t("session.tab.context")}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.List>
|
||||||
|
</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>
|
||||||
<SortableProvider ids={openedTabs()}>
|
|
||||||
|
<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={activeFileTab()} keyed>
|
||||||
|
{(tab) => <FileTabContent tab={tab} />}
|
||||||
|
</Show>
|
||||||
|
</Tabs>
|
||||||
|
<DragOverlay>
|
||||||
|
<Show when={store.activeDraggable} keyed>
|
||||||
|
{(tab) => {
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
return (
|
||||||
|
<div data-component="tabs-drag-preview">
|
||||||
|
<Show when={path}>
|
||||||
|
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</DragOverlay>
|
||||||
|
</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()}>
|
<For each={panelTabs()}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<Show
|
<Show
|
||||||
when={tab === SESSION_OPEN_FILE_TAB}
|
when={tab === SESSION_OPEN_FILE_TAB}
|
||||||
fallback={
|
fallback={
|
||||||
<SortableTab
|
<SortableTabV2
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
index={() => tabs().all().indexOf(tab)}
|
||||||
temporary={temporaryTab() === tab}
|
temporary={temporaryTab() === tab}
|
||||||
onTabClose={tabs().close}
|
onTabClose={tabs().close}
|
||||||
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||||
@@ -386,106 +631,104 @@ export function SessionSidePanel(props: {
|
|||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
<div
|
||||||
<div
|
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center"
|
||||||
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
|
classList={{
|
||||||
classList={{
|
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
}}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
title={language.t("command.file.open")}
|
|
||||||
keybind={command.keybind("file.open")}
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<TooltipV2
|
||||||
icon="plus-small"
|
value={
|
||||||
variant="ghost"
|
<>
|
||||||
iconSize="large"
|
{language.t("command.file.open")}
|
||||||
class="!rounded-md"
|
<Show when={openFileKeybind().length > 0}>
|
||||||
onClick={() => {
|
<KeybindV2 keys={openFileKeybind()} variant="neutral" />
|
||||||
if (props.fileBrowserState) {
|
</Show>
|
||||||
openFileBrowser()
|
</>
|
||||||
return
|
}
|
||||||
}
|
placement="bottom"
|
||||||
void import("@/components/dialog-select-file").then((x) => {
|
class="flex items-center"
|
||||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
>
|
||||||
})
|
<IconButtonV2
|
||||||
}}
|
icon={<Icon name="plus-small" />}
|
||||||
aria-label={language.t("command.file.open")}
|
variant="ghost-muted"
|
||||||
/>
|
size="large"
|
||||||
</TooltipKeybind>
|
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>
|
||||||
</Tabs.List>
|
|
||||||
</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>
|
</div>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={activeTab() === "empty"}>
|
<Show when={reviewTab() && props.canReview() && activeTab() === "review"}>
|
||||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
<div
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
id={reviewTabPanelID}
|
||||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
role="tabpanel"
|
||||||
<Mark class="w-14 opacity-10" />
|
aria-labelledby={reviewTabID}
|
||||||
<div class="text-14-regular text-text-weak max-w-56">
|
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||||
{language.t("session.files.selectToOpen")}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs.Content>
|
||||||
</Tabs.Content>
|
</Show>
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={activeTab() === "context"}>
|
<Show when={activeTab() === "context"}>
|
||||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
<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">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab />
|
<SessionContextTab />
|
||||||
</div>
|
|
||||||
</Tabs.Content>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={browserTab()}>
|
|
||||||
<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} />}
|
|
||||||
</Show>
|
|
||||||
</Tabs>
|
|
||||||
<DragOverlay>
|
|
||||||
<Show when={store.activeDraggable} keyed>
|
|
||||||
{(tab) => {
|
|
||||||
const path = file.pathFromTab(tab)
|
|
||||||
return (
|
|
||||||
<div data-component="tabs-drag-preview">
|
|
||||||
<Show when={path}>
|
|
||||||
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</Tabs.Content>
|
||||||
}}
|
</Show>
|
||||||
</Show>
|
|
||||||
</DragOverlay>
|
<Show when={fileBrowserMounted()}>
|
||||||
</DragDropProvider>
|
<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" }}>
|
||||||
{props.reviewCount()}{" "}
|
<Show
|
||||||
{language.t(
|
when={settings.general.newLayoutDesigns()}
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
fallback={
|
||||||
)}
|
<>
|
||||||
|
{props.reviewCount()}{" "}
|
||||||
|
{language.t(
|
||||||
|
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,102 +88,92 @@ 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={false}
|
||||||
toolbar
|
sidebar={
|
||||||
toolbarStart={
|
<SessionReviewV2Sidebar
|
||||||
<>
|
open={sidebarOpened()}
|
||||||
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
title={<span class="truncate">{title()}</span>}
|
||||||
<Show when={!sidebarOpened()}>
|
filter={filter()}
|
||||||
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
|
onFilterChange={setFilter}
|
||||||
</Show>
|
onFilterKeyDown={onFilterKeyDown}
|
||||||
</>
|
filterAutofocus={props.placeholder}
|
||||||
}
|
filterRef={props.filterRef}
|
||||||
sidebar={
|
filterControls={resultsID}
|
||||||
<SessionReviewV2Sidebar
|
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
||||||
open={sidebarOpened()}
|
filterExpanded={query().length > 0 && files().length > 0}
|
||||||
title={<span class="truncate">{title()}</span>}
|
width={props.state.sidebarWidth()}
|
||||||
filter={filter()}
|
onWidthChange={props.state.resizeSidebar}
|
||||||
onFilterChange={setFilter}
|
>
|
||||||
onFilterKeyDown={onFilterKeyDown}
|
<Show
|
||||||
filterAutofocus={props.placeholder}
|
when={query()}
|
||||||
filterRef={props.filterRef}
|
fallback={
|
||||||
filterControls={resultsID}
|
<FileTreeV2
|
||||||
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
active={props.active}
|
||||||
filterExpanded={query().length > 0 && files().length > 0}
|
kinds={props.kinds}
|
||||||
width={props.state.sidebarWidth()}
|
onFileClick={(node) => props.onSelect(node.path)}
|
||||||
onWidthChange={props.state.resizeSidebar}
|
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={query()}
|
when={!loading()}
|
||||||
fallback={
|
fallback={
|
||||||
<FileTree
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
path=""
|
{language.t("common.loading")}
|
||||||
class="pt-1"
|
{language.t("common.loading.ellipsis")}
|
||||||
active={props.active}
|
</div>
|
||||||
kinds={props.kinds}
|
|
||||||
onFileClick={(node) => props.onSelect(node.path)}
|
|
||||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={!loading()}
|
when={files().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{language.t("common.loading")}
|
{language.t("palette.empty")}
|
||||||
{language.t("common.loading.ellipsis")}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<SessionFileListV2
|
||||||
when={files().length > 0}
|
id={resultsID}
|
||||||
fallback={
|
role="listbox"
|
||||||
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
optionID={optionID}
|
||||||
{language.t("palette.empty")}
|
files={files()}
|
||||||
</div>
|
kinds={props.kinds}
|
||||||
}
|
active={props.active}
|
||||||
>
|
highlighted={highlighted()}
|
||||||
<SessionFileListV2
|
onFileClick={(path) => {
|
||||||
id={resultsID}
|
setExplicitHighlight(path)
|
||||||
role="listbox"
|
props.onSelect(path)
|
||||||
optionID={optionID}
|
}}
|
||||||
files={files()}
|
onFileDoubleClick={props.onSelectPermanent}
|
||||||
kinds={props.kinds}
|
/>
|
||||||
active={props.active}
|
|
||||||
highlighted={highlighted()}
|
|
||||||
onFileClick={(path) => {
|
|
||||||
setExplicitHighlight(path)
|
|
||||||
props.onSelect(path)
|
|
||||||
}}
|
|
||||||
onFileDoubleClick={props.onSelectPermanent}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</SessionReviewV2Sidebar>
|
</Show>
|
||||||
|
</SessionReviewV2Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!props.placeholder}
|
||||||
|
fallback={
|
||||||
|
<SessionFilePanelV2Empty>
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||||
|
<Icon name="file-tree" size="large" />
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||||
|
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||||
|
</div>
|
||||||
|
</SessionFilePanelV2Empty>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<div class="min-h-0 flex-1">
|
||||||
when={!props.placeholder}
|
<Show when={props.tab} keyed>
|
||||||
fallback={
|
{(tab) => <SessionFileView tab={tab} />}
|
||||||
<SessionFilePanelV2Empty>
|
</Show>
|
||||||
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
</div>
|
||||||
<Icon name="file-tree" size="large" />
|
</Show>
|
||||||
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
</SessionFilePanelV2>
|
||||||
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
|
||||||
</div>
|
|
||||||
</SessionFilePanelV2Empty>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<Show when={props.tab} keyed>
|
|
||||||
{(tab) => <SessionFileView tab={tab} />}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</SessionFilePanelV2>
|
|
||||||
</Tabs.Content>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
estimateSize: () => 60,
|
||||||
getScrollElement: () => null,
|
initialOffset: Number.MAX_SAFE_INTEGER,
|
||||||
estimateSize: () => 60,
|
initialRect: { width: 800, height: 600 },
|
||||||
initialOffset: 100,
|
scrollMargin: 64,
|
||||||
initialRect: { width: 800, height: 60 },
|
paddingEnd,
|
||||||
})
|
overscan: 1,
|
||||||
|
getScrollElement: () => null,
|
||||||
virtualizer.getTotalSize()
|
scrollToFn: () => {},
|
||||||
virtualizer.resizeItem(0, 100)
|
observeElementRect: () => {},
|
||||||
|
observeElementOffset: () => {},
|
||||||
expect(virtualizer.scrollOffset).toBe(100)
|
|
||||||
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
|
|
||||||
dispose()
|
|
||||||
})
|
})
|
||||||
|
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(64))
|
||||||
|
|
||||||
|
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
|
||||||
|
|
||||||
|
virtualizer.setOptions(options(600))
|
||||||
|
expect(virtualizer.getVirtualItems().map((item) => item.index)).toEqual([18, 19])
|
||||||
})
|
})
|
||||||
|
|
||||||
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", () => {
|
||||||
|
|||||||
@@ -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:",
|
||||||
@@ -45,6 +44,7 @@
|
|||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "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,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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 { TuiConfig } from "@opencode-ai/tui/config"
|
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
|
import { Config } from "../../config"
|
||||||
import { Effect, Option } from "effect"
|
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 { Npm } from "@opencode-ai/core/npm"
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -15,23 +16,43 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
|
const preflight = UpdatePreflight.make()
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* Server.resolve({
|
const server = yield* Server.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
onStart: (reason) =>
|
onStart: (reason, existing) => {
|
||||||
|
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
reason === "version-mismatch"
|
reason === "version-mismatch"
|
||||||
? "Restarting background server (version mismatch)...\n"
|
? "Restarting background server (version mismatch)...\n"
|
||||||
: "Starting background server...\n",
|
: "Starting background server...\n",
|
||||||
),
|
)
|
||||||
})
|
},
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
}).pipe(
|
||||||
let disposeSlots: (() => void) | undefined
|
Effect.tapError(() =>
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
preflight.loading()
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const npm = yield* Npm.Service
|
||||||
|
const context = yield* Effect.context()
|
||||||
|
const runFork = Effect.runForkWith(context)
|
||||||
|
const runPromise = Effect.runPromiseWith(context)
|
||||||
yield* run({
|
yield* run({
|
||||||
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()),
|
||||||
|
update: (update) => runPromise(config.update(update)),
|
||||||
|
},
|
||||||
|
packages: {
|
||||||
|
resolve: (spec) =>
|
||||||
|
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
||||||
|
},
|
||||||
|
terminalHandoff: () => preflight.finish(),
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
const effect =
|
const effect =
|
||||||
level === "debug"
|
level === "debug"
|
||||||
@@ -43,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)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export * as Config from "./config"
|
||||||
|
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||||
|
import { produce, type Draft } from "immer"
|
||||||
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||||
|
import path from "path"
|
||||||
|
import { ConfigMigration } from "./migrate"
|
||||||
|
import { Info } from "./schema"
|
||||||
|
|
||||||
|
export * from "./schema"
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly path: string
|
||||||
|
readonly get: () => Effect.Effect<Info>
|
||||||
|
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
|
||||||
|
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||||
|
const empty: Info = {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const file = path.join(global.config, "cli.json")
|
||||||
|
const lock = yield* Semaphore.make(1)
|
||||||
|
|
||||||
|
const readJson = Effect.fnUntraced(function* () {
|
||||||
|
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||||
|
if (errors.length) return undefined
|
||||||
|
return Option.getOrUndefined(decodeRecord(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = Effect.fnUntraced(function* (text: string) {
|
||||||
|
const temp = file + ".tmp"
|
||||||
|
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||||
|
yield* fs.rename(temp, file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||||
|
Effect.provideService(FileSystem.FileSystem, fs),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("cli.config.get")(function* () {
|
||||||
|
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||||
|
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||||
|
lock
|
||||||
|
.withPermits(1)(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* migrate
|
||||||
|
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||||
|
const next = produce(current, update)
|
||||||
|
const edits = changes(current, next)
|
||||||
|
if (!edits.length) return current
|
||||||
|
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||||
|
const updated = edits.reduce(
|
||||||
|
(text, edit) =>
|
||||||
|
applyEdits(
|
||||||
|
text,
|
||||||
|
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||||
|
),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||||
|
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||||
|
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||||
|
return config
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ path: file, get, update })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Edit = { readonly path: (string | number)[]; readonly value: any }
|
||||||
|
|
||||||
|
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
|
||||||
|
if (Object.is(before, after)) return []
|
||||||
|
if (
|
||||||
|
before !== null &&
|
||||||
|
after !== null &&
|
||||||
|
typeof before === "object" &&
|
||||||
|
typeof after === "object" &&
|
||||||
|
!Array.isArray(before) &&
|
||||||
|
!Array.isArray(after)
|
||||||
|
) {
|
||||||
|
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||||
|
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
|
||||||
|
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
|
||||||
|
return changes(before[key], after[key], [...path, key])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [{ path, value: after }]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * as Config from "./config"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
export * as ConfigMigration from "./migrate"
|
||||||
|
|
||||||
|
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||||
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
|
import path from "path"
|
||||||
|
import type { Info } from "./schema"
|
||||||
|
|
||||||
|
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||||
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||||
|
|
||||||
|
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||||
|
readonly file: string
|
||||||
|
readonly config: string
|
||||||
|
readonly state: string
|
||||||
|
}) {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||||
|
|
||||||
|
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||||
|
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||||
|
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
||||||
|
const migrated = migrateV1(legacy, kv ?? {})
|
||||||
|
if (!Object.keys(migrated).length) return
|
||||||
|
|
||||||
|
const temp = input.file + ".tmp"
|
||||||
|
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||||
|
yield* fs.rename(temp, input.file)
|
||||||
|
yield* Effect.logInfo("migrated cli config", {
|
||||||
|
from: [
|
||||||
|
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||||
|
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||||
|
].filter(Boolean),
|
||||||
|
to: input.file,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||||
|
const plugins = [
|
||||||
|
...(legacy?.plugin?.map((plugin) =>
|
||||||
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
|
) ?? []),
|
||||||
|
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
||||||
|
]
|
||||||
|
const themeName = legacy?.theme ?? kv.theme
|
||||||
|
const themeMode = kv.theme_mode_lock
|
||||||
|
const attentionSoundPack = kv.attention_sound_pack
|
||||||
|
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||||
|
const thinking =
|
||||||
|
kv.thinking_mode ??
|
||||||
|
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(themeName !== undefined || themeMode !== undefined
|
||||||
|
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
|
||||||
|
: {}),
|
||||||
|
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||||
|
...(plugins.length ? { plugins } : {}),
|
||||||
|
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||||
|
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
scroll: {
|
||||||
|
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
||||||
|
...(legacy.scroll_acceleration?.enabled === undefined
|
||||||
|
? {}
|
||||||
|
: { acceleration: legacy.scroll_acceleration.enabled }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
attention: {
|
||||||
|
...legacy?.attention,
|
||||||
|
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(legacy?.diff_style === undefined &&
|
||||||
|
kv.diff_wrap_mode === undefined &&
|
||||||
|
kv.diff_viewer_show_file_tree === undefined &&
|
||||||
|
kv.diff_viewer_single_patch === undefined &&
|
||||||
|
diffView === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
diffs: {
|
||||||
|
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
||||||
|
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
||||||
|
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
||||||
|
...(diffView === undefined ? {} : { view: diffView }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
||||||
|
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
prompt: {
|
||||||
|
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
||||||
|
...(kv.paste_summary_enabled === undefined
|
||||||
|
? {}
|
||||||
|
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.sidebar === undefined &&
|
||||||
|
kv.scrollbar_visible === undefined &&
|
||||||
|
thinking === undefined &&
|
||||||
|
kv.exploration_grouping === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
session: {
|
||||||
|
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
||||||
|
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
||||||
|
...(thinking === undefined ? {} : { thinking }),
|
||||||
|
...(kv.exploration_grouping === undefined
|
||||||
|
? {}
|
||||||
|
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hints: {
|
||||||
|
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
|
||||||
|
...(kv.dismissed_getting_started === undefined
|
||||||
|
? {}
|
||||||
|
: { onboarding: !kv.dismissed_getting_started }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||||
|
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readJson = Effect.fnUntraced(function* (target: string) {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
const errors: ParseError[] = []
|
||||||
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||||
|
if (errors.length) return undefined
|
||||||
|
return Option.getOrUndefined(decodeRecord(value))
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Config } from "@opencode-ai/tui/config"
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({ ...Config.Info.fields })
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
@@ -3,6 +3,8 @@ import { Command } from "effect/unstable/cli"
|
|||||||
import { Spec } from "./spec"
|
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 { 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>
|
||||||
@@ -13,18 +15,26 @@ export type Input<Value> =
|
|||||||
|
|
||||||
type RuntimeHandler = (
|
type RuntimeHandler = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
) => Effect.Effect<
|
||||||
|
void,
|
||||||
|
unknown,
|
||||||
|
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: (
|
||||||
input: Input<Node>,
|
input: Input<Node>,
|
||||||
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
) => Effect.Effect<
|
||||||
|
void,
|
||||||
|
any,
|
||||||
|
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
|
||||||
|
>
|
||||||
}>
|
}>
|
||||||
type ProvidedCommand = Command.Command<
|
type ProvidedCommand = Command.Command<
|
||||||
string,
|
string,
|
||||||
unknown,
|
unknown,
|
||||||
unknown,
|
unknown,
|
||||||
unknown,
|
unknown,
|
||||||
FileSystem.FileSystem | Global.Service | Updater.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
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
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 { Npm } from "@opencode-ai/core/npm"
|
||||||
|
|
||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
@@ -51,8 +53,9 @@ Effect.logInfo("cli starting", {
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
|
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,
|
||||||
|
|||||||
+121
-298
@@ -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",
|
||||||
messageID: msg,
|
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
|
||||||
partID: part,
|
phase: "progress",
|
||||||
field: "text",
|
messageID: msg,
|
||||||
delta: item,
|
partID: part,
|
||||||
},
|
},
|
||||||
} 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: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
|
||||||
state: {
|
|
||||||
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",
|
id,
|
||||||
properties: {
|
sessionID: state.id,
|
||||||
id,
|
action: item.permission,
|
||||||
sessionID: state.id,
|
resources: item.patterns,
|
||||||
permission: item.permission,
|
metadata: item.metadata ?? {},
|
||||||
patterns: item.patterns,
|
save: item.always,
|
||||||
metadata: item.metadata ?? {},
|
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||||
always: item.always,
|
})
|
||||||
tool: {
|
|
||||||
messageID: item.ref.msg,
|
|
||||||
callID: item.ref.call,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as Event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function doneTool(
|
function doneTool(
|
||||||
@@ -508,77 +386,53 @@ 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: {
|
id: ref.part,
|
||||||
sessionID: state.id,
|
sessionID: state.id,
|
||||||
time: Date.now(),
|
messageID: ref.msg,
|
||||||
part: {
|
callID: ref.call,
|
||||||
id: ref.part,
|
tool: ref.tool,
|
||||||
sessionID: state.id,
|
state: {
|
||||||
messageID: ref.msg,
|
status: "completed",
|
||||||
type: "tool",
|
input: ref.input,
|
||||||
callID: ref.call,
|
output: output.output,
|
||||||
tool: ref.tool,
|
title: output.title,
|
||||||
state: {
|
metadata: output.metadata ?? {},
|
||||||
status: "completed",
|
time: { start: ref.start, end: Date.now() },
|
||||||
input: ref.input,
|
|
||||||
output: output.output,
|
|
||||||
title: output.title,
|
|
||||||
metadata: output.metadata ?? {},
|
|
||||||
time: {
|
|
||||||
start: ref.start,
|
|
||||||
end: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as Event)
|
}
|
||||||
|
present(state, [toolCommit(part, output.output ? "progress" : "final")])
|
||||||
}
|
}
|
||||||
|
|
||||||
function failTool(state: State, ref: Ref, error: string): void {
|
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: {
|
status: "error",
|
||||||
status: "error",
|
input: ref.input,
|
||||||
input: ref.input,
|
error,
|
||||||
error,
|
metadata: {},
|
||||||
metadata: {},
|
time: { start: ref.start, end: Date.now() },
|
||||||
time: {
|
|
||||||
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",
|
id,
|
||||||
properties: {
|
sessionID: state.id,
|
||||||
id,
|
questions,
|
||||||
sessionID: state.id,
|
tool: { messageID: ref.msg, callID: ref.call },
|
||||||
questions,
|
})
|
||||||
tool: {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// history ring. All are async because they read config or hit the SDK, but
|
// history ring. All are async because they read config or hit the SDK, but
|
||||||
// none block each other.
|
// none block each other.
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { resolve } from "@opencode-ai/tui/config"
|
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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,133 +20,42 @@ 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 {
|
|
||||||
...structuredClone(part.source),
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "file" as const,
|
text: message.text,
|
||||||
path: part.filename ?? part.url,
|
parts: [
|
||||||
text,
|
...(message.files ?? []).map((file) => ({
|
||||||
}
|
type: "file" as const,
|
||||||
}
|
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||||
|
mime: file.mime,
|
||||||
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
|
filename: file.name,
|
||||||
const parts: RunPrompt["parts"] = []
|
source: file.mention
|
||||||
let text = msg.parts
|
? {
|
||||||
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
|
type: "file",
|
||||||
return part.type === "text" && !part.synthetic
|
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 },
|
||||||
.map((part) => part.text)
|
}
|
||||||
.join("")
|
: undefined,
|
||||||
let cursor = Bun.stringWidth(text)
|
})),
|
||||||
const used: Array<{ start: number; end: number }> = []
|
...(message.agents ?? []).map((agent) => ({
|
||||||
|
type: "agent" as const,
|
||||||
const take = (value: string): { start: number; end: number; value: string } | undefined => {
|
name: agent.name,
|
||||||
let from = 0
|
source: agent.mention
|
||||||
while (true) {
|
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
||||||
const idx = text.indexOf(value, from)
|
: undefined,
|
||||||
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 {
|
export function createSession(messages: SessionMessages): RunSession {
|
||||||
return {
|
return {
|
||||||
first: messages.length === 0,
|
first: messages.length === 0,
|
||||||
turns: messages.flatMap((msg) => {
|
turns: messages.flatMap((message) =>
|
||||||
const item = turn(msg)
|
message.type === "user"
|
||||||
return item ? [item] : []
|
? [{ prompt: messagePrompt(message), provider: undefined, model: undefined, variant: undefined }]
|
||||||
}),
|
: [],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,89 +68,34 @@ export async function resolveCurrentSession(
|
|||||||
sdk.message.list({ sessionID, limit, order: "desc" }),
|
sdk.message.list({ sessionID, limit, order: "desc" }),
|
||||||
sdk.session.get({ sessionID }),
|
sdk.session.get({ sessionID }),
|
||||||
])
|
])
|
||||||
const messages = response.data.toReversed()
|
const current = createSession(response.data.toReversed())
|
||||||
return {
|
return {
|
||||||
first: messages.length === 0,
|
...current,
|
||||||
turns: messages.flatMap((message) => {
|
turns: current.turns.map((turn) => ({
|
||||||
if (message.type !== "user") return []
|
...turn,
|
||||||
return [
|
provider: session.model?.providerID,
|
||||||
{
|
model: session.model?.id,
|
||||||
prompt: {
|
variant: session.model?.variant,
|
||||||
text: message.text,
|
})),
|
||||||
parts: [
|
|
||||||
...(message.files ?? []).map((file) => ({
|
|
||||||
type: "file" as const,
|
|
||||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
|
||||||
mime: file.mime,
|
|
||||||
filename: file.name,
|
|
||||||
source: file.mention
|
|
||||||
? {
|
|
||||||
type: "file" as const,
|
|
||||||
path: file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment"),
|
|
||||||
text: { start: file.mention.start, end: file.mention.end, value: file.mention.text },
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
...(message.agents ?? []).map((agent) => ({
|
|
||||||
type: "agent" as const,
|
|
||||||
name: agent.name,
|
|
||||||
source: agent.mention
|
|
||||||
? { start: agent.mention.start, end: agent.mention.end, value: agent.mention.text }
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
provider: session.model?.providerID,
|
|
||||||
model: session.model?.id,
|
|
||||||
variant: session.model?.variant,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
...(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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
//
|
//
|
||||||
// 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,
|
||||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
PermissionV2Request,
|
||||||
|
QuestionV2Request,
|
||||||
|
ReferenceListOutput,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
|
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||||
|
|
||||||
export type RunFilePart = {
|
export type RunFilePart = {
|
||||||
type: "file"
|
type: "file"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
// Split-footer status shown while a freshly launched CLI replaces a
|
||||||
|
// version-mismatched background service before the TUI attaches.
|
||||||
|
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||||
|
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||||
|
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||||
|
import { go } from "@opencode-ai/tui/logo"
|
||||||
|
import {
|
||||||
|
batch,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
Index,
|
||||||
|
on,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
Show,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js"
|
||||||
|
|
||||||
|
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const
|
||||||
|
const stageFloor = 480
|
||||||
|
const transitionDuration = 420
|
||||||
|
const completionHold = 650
|
||||||
|
|
||||||
|
export type Handle = {
|
||||||
|
readonly begin: (from?: string) => boolean
|
||||||
|
readonly loading: () => void
|
||||||
|
readonly finish: () => Promise<Handoff | undefined>
|
||||||
|
readonly fail: (message: string) => Promise<void>
|
||||||
|
readonly close: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Handoff = {
|
||||||
|
readonly renderer: CliRenderer
|
||||||
|
readonly mode: ThemeMode | null
|
||||||
|
readonly complete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = (): Handle => {
|
||||||
|
let session: Promise<Session | undefined> | undefined
|
||||||
|
return {
|
||||||
|
begin: (from) => {
|
||||||
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return false
|
||||||
|
session ??= open(from).catch(() => {
|
||||||
|
process.stderr.write("Restarting background server (version mismatch)...\n")
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
loading: () => {
|
||||||
|
void session?.then((active) => active?.loading())
|
||||||
|
},
|
||||||
|
finish: async () => {
|
||||||
|
const active = await session
|
||||||
|
return active?.finish()
|
||||||
|
},
|
||||||
|
fail: async (message) => {
|
||||||
|
const active = await session
|
||||||
|
await active?.fail(message)
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
const active = await session
|
||||||
|
await active?.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
readonly loading: () => Promise<void>
|
||||||
|
readonly finish: () => Promise<Handoff>
|
||||||
|
readonly fail: (message: string) => Promise<void>
|
||||||
|
readonly close: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(from?: string): Promise<Session> {
|
||||||
|
registerOpencodeSpinner()
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
|
||||||
|
const [failure, setFailure] = createSignal("")
|
||||||
|
const [animating, setAnimating] = createSignal(true)
|
||||||
|
const [visible, setVisible] = createSignal(true)
|
||||||
|
let resolveOutcome: (() => void) | undefined
|
||||||
|
const renderer = await createCliRenderer({
|
||||||
|
stdin: process.stdin,
|
||||||
|
useMouse: false,
|
||||||
|
autoFocus: false,
|
||||||
|
openConsoleOnError: false,
|
||||||
|
exitOnCtrlC: false,
|
||||||
|
screenMode: "split-footer",
|
||||||
|
footerHeight: 4,
|
||||||
|
targetFps: 60,
|
||||||
|
useKittyKeyboard: {},
|
||||||
|
consoleOptions: {
|
||||||
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||||
|
},
|
||||||
|
externalOutputMode: "capture-stdout",
|
||||||
|
consoleMode: "disabled",
|
||||||
|
})
|
||||||
|
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||||
|
await render(
|
||||||
|
() => (
|
||||||
|
<Show when={visible()}>
|
||||||
|
<UpdateFooter
|
||||||
|
from={from}
|
||||||
|
active={active}
|
||||||
|
outcome={outcome}
|
||||||
|
failure={failure}
|
||||||
|
animating={animating}
|
||||||
|
renderer={renderer}
|
||||||
|
onOutcomeSettled={() => resolveOutcome?.()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
),
|
||||||
|
renderer,
|
||||||
|
).catch((error) => {
|
||||||
|
if (!renderer.isDestroyed) renderer.destroy()
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
let shownAt = performance.now()
|
||||||
|
const waitForStage = async () => {
|
||||||
|
const remaining = stageFloor - (performance.now() - shownAt)
|
||||||
|
if (remaining > 0) await Bun.sleep(remaining)
|
||||||
|
}
|
||||||
|
const advance = async (stage: number) => {
|
||||||
|
await waitForStage()
|
||||||
|
if (outcome() !== "running") return
|
||||||
|
setActive(stage)
|
||||||
|
shownAt = performance.now()
|
||||||
|
}
|
||||||
|
// Service.start currently exposes only its start boundary, so this first
|
||||||
|
// transition is time-based. Finer lifecycle callbacks remain follow-up work.
|
||||||
|
const auto = advance(1)
|
||||||
|
const transitionTo = async (next: "success" | "failure", hold: number) => {
|
||||||
|
const settled = Promise.withResolvers<void>()
|
||||||
|
resolveOutcome = settled.resolve
|
||||||
|
setOutcome(next)
|
||||||
|
const completed = await Promise.race([
|
||||||
|
settled.promise.then(() => true),
|
||||||
|
Bun.sleep(transitionDuration + 500).then(() => false),
|
||||||
|
])
|
||||||
|
resolveOutcome = undefined
|
||||||
|
setAnimating(false)
|
||||||
|
if (completed) await Bun.sleep(hold)
|
||||||
|
}
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
let transferred = false
|
||||||
|
const close = () =>
|
||||||
|
(closing ??= (async () => {
|
||||||
|
if (transferred) return
|
||||||
|
setAnimating(false)
|
||||||
|
if (renderer.isDestroyed) return
|
||||||
|
renderer.pause()
|
||||||
|
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||||
|
renderer.destroy()
|
||||||
|
})())
|
||||||
|
let loading: Promise<void> | undefined
|
||||||
|
const load = () =>
|
||||||
|
(loading ??= (async () => {
|
||||||
|
await auto
|
||||||
|
await advance(2)
|
||||||
|
})())
|
||||||
|
let settled: Promise<void> | undefined
|
||||||
|
const settle = (task: () => Promise<void>) => (settled ??= task())
|
||||||
|
return {
|
||||||
|
loading: load,
|
||||||
|
finish: async () => {
|
||||||
|
await settle(async () => {
|
||||||
|
await load()
|
||||||
|
await waitForStage()
|
||||||
|
await transitionTo("success", completionHold)
|
||||||
|
})
|
||||||
|
const mode = await terminalMode
|
||||||
|
renderer.externalOutputMode = "passthrough"
|
||||||
|
renderer.screenMode = "alternate-screen"
|
||||||
|
renderer.consoleMode = "console-overlay"
|
||||||
|
renderer.requestRender()
|
||||||
|
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||||
|
transferred = true
|
||||||
|
return {
|
||||||
|
renderer,
|
||||||
|
mode,
|
||||||
|
complete: () => setVisible(false),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (message) =>
|
||||||
|
settle(async () => {
|
||||||
|
setFailure(message)
|
||||||
|
await transitionTo("failure", 250)
|
||||||
|
await close()
|
||||||
|
}),
|
||||||
|
close,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
accent: RGBA.fromHex("#a6b8ff"),
|
||||||
|
accentBright: RGBA.fromHex("#eef1ff"),
|
||||||
|
accentDim: RGBA.fromHex("#596998"),
|
||||||
|
error: RGBA.fromHex("#ff8192"),
|
||||||
|
muted: RGBA.fromHex("#808080"),
|
||||||
|
success: RGBA.fromHex("#8bd5a5"),
|
||||||
|
text: RGBA.fromHex("#eeeeee"),
|
||||||
|
}
|
||||||
|
|
||||||
|
const monogram = go.right.slice(1)
|
||||||
|
const sweepBlend = 8
|
||||||
|
const textDim = RGBA.fromHex("#4c4c4c")
|
||||||
|
const rampSteps = 32
|
||||||
|
|
||||||
|
const blend = (from: RGBA, to: RGBA, amount: number) =>
|
||||||
|
RGBA.fromValues(
|
||||||
|
from.r + (to.r - from.r) * amount,
|
||||||
|
from.g + (to.g - from.g) * amount,
|
||||||
|
from.b + (to.b - from.b) * amount,
|
||||||
|
)
|
||||||
|
const ramp = (from: RGBA, to: RGBA) =>
|
||||||
|
Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps))
|
||||||
|
const railRamp = ramp(colors.accentDim, colors.accentBright)
|
||||||
|
const monogramRamp = ramp(colors.muted, colors.accent)
|
||||||
|
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
|
||||||
|
const rampFor = (color: RGBA) => {
|
||||||
|
const cached = rampCache.get(color)
|
||||||
|
if (cached) return cached
|
||||||
|
const result = ramp(textDim, color)
|
||||||
|
rampCache.set(color, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
|
||||||
|
palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
|
||||||
|
|
||||||
|
type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean }
|
||||||
|
const styled = (text: string, color: RGBA, bold?: boolean): Cell[] =>
|
||||||
|
Array.from(text).map((char) => ({ char, color, bold }))
|
||||||
|
const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>): Cell[] =>
|
||||||
|
segments.flatMap((segment, index) => [
|
||||||
|
...(index > 0 ? styled(" ", colors.muted) : []),
|
||||||
|
...styled(segment[0], segment[1], segment[2]),
|
||||||
|
])
|
||||||
|
|
||||||
|
function Monogram(props: { ink: () => RGBA }) {
|
||||||
|
const shadow = createMemo(() => {
|
||||||
|
const ink = props.ink()
|
||||||
|
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={monogram}>
|
||||||
|
{(line) => (
|
||||||
|
<box flexDirection="row">
|
||||||
|
<For each={Array.from(line)}>
|
||||||
|
{(char) =>
|
||||||
|
char === "_" ? (
|
||||||
|
<text bg={shadow()} selectable={false}>
|
||||||
|
{" "}
|
||||||
|
</text>
|
||||||
|
) : (
|
||||||
|
<text fg={props.ink()} selectable={false}>
|
||||||
|
{char}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void }
|
||||||
|
|
||||||
|
function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) {
|
||||||
|
const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>()
|
||||||
|
const [progress, setProgress] = createSignal(0)
|
||||||
|
let elapsed = 0
|
||||||
|
const cells = createMemo(() => {
|
||||||
|
const transition = state()
|
||||||
|
if (!transition) return undefined
|
||||||
|
return render(transition, progress())
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
start(from: Cell[], to: Cell[], done?: () => void) {
|
||||||
|
elapsed = 0
|
||||||
|
setProgress(0)
|
||||||
|
setState({ from, to, done })
|
||||||
|
},
|
||||||
|
tick(deltaTime: number) {
|
||||||
|
const transition = state()
|
||||||
|
if (!transition) return
|
||||||
|
elapsed = Math.min(transitionDuration, elapsed + deltaTime)
|
||||||
|
setProgress(elapsed / transitionDuration)
|
||||||
|
if (elapsed < transitionDuration) return
|
||||||
|
setState(undefined)
|
||||||
|
transition.done?.()
|
||||||
|
},
|
||||||
|
cells,
|
||||||
|
progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSweep = () =>
|
||||||
|
createTransition((transition, progress) => {
|
||||||
|
const length = Math.max(transition.from.length, transition.to.length)
|
||||||
|
const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend
|
||||||
|
return Array.from({ length }, (_, index) => {
|
||||||
|
const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend))
|
||||||
|
const brightness = smoothstep(Math.abs(passed * 2 - 1))
|
||||||
|
const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? {
|
||||||
|
char: " ",
|
||||||
|
color: colors.text,
|
||||||
|
}
|
||||||
|
return { ...cell, color: shade(rampFor(cell.color), brightness) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFade = () =>
|
||||||
|
createTransition((transition, progress) => {
|
||||||
|
const entering = progress >= 0.5
|
||||||
|
const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2)
|
||||||
|
return (entering ? transition.to : transition.from).map((cell) => ({
|
||||||
|
...cell,
|
||||||
|
color: shade(rampFor(cell.color), brightness),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
||||||
|
const frameDone = Promise.resolve()
|
||||||
|
|
||||||
|
function UpdateFooter(props: {
|
||||||
|
from?: string
|
||||||
|
active: () => number
|
||||||
|
outcome: () => "running" | "success" | "failure"
|
||||||
|
failure: () => string
|
||||||
|
animating: () => boolean
|
||||||
|
renderer: CliRenderer
|
||||||
|
onOutcomeSettled: () => void
|
||||||
|
}) {
|
||||||
|
const term = useTerminalDimensions()
|
||||||
|
const [position, setPosition] = createSignal(0)
|
||||||
|
const [pulse, setPulse] = createSignal(0)
|
||||||
|
const headerFade = createFade()
|
||||||
|
const statusSweep = createSweep()
|
||||||
|
const runningHeader = () =>
|
||||||
|
phrase(
|
||||||
|
["OpenCode", colors.muted, true],
|
||||||
|
["is updating", colors.muted],
|
||||||
|
...(props.from
|
||||||
|
? ([
|
||||||
|
["from", colors.muted],
|
||||||
|
[props.from, colors.accentDim],
|
||||||
|
] as const)
|
||||||
|
: []),
|
||||||
|
["to", colors.muted],
|
||||||
|
[InstallationVersion, colors.accent],
|
||||||
|
)
|
||||||
|
const completedHeader = phrase(
|
||||||
|
["OpenCode", colors.muted, true],
|
||||||
|
["updated to", colors.muted],
|
||||||
|
[InstallationVersion, colors.accent],
|
||||||
|
)
|
||||||
|
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
||||||
|
const outcomeStatus = () =>
|
||||||
|
props.outcome() === "success"
|
||||||
|
? [...styled("✓", colors.success), ...styled(" Ready", colors.text)]
|
||||||
|
: [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)]
|
||||||
|
let previousStage: string = stages[0]
|
||||||
|
createEffect(
|
||||||
|
on(props.active, (index) => {
|
||||||
|
if (props.outcome() !== "running") return
|
||||||
|
const next = stages[index]
|
||||||
|
if (next === previousStage) return
|
||||||
|
statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
|
||||||
|
previousStage = next
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
props.outcome,
|
||||||
|
(outcome) => {
|
||||||
|
if (outcome === "running") return
|
||||||
|
const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
|
||||||
|
headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
|
||||||
|
statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const header = createMemo(
|
||||||
|
() =>
|
||||||
|
headerFade.cells() ??
|
||||||
|
(props.outcome() === "success"
|
||||||
|
? completedHeader
|
||||||
|
: props.outcome() === "failure"
|
||||||
|
? pausedHeader
|
||||||
|
: runningHeader()),
|
||||||
|
)
|
||||||
|
const monogramInk = createMemo(() =>
|
||||||
|
props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted,
|
||||||
|
)
|
||||||
|
const rail = createMemo(() => {
|
||||||
|
const width = Math.max(0, Math.min(30, term().width - 39))
|
||||||
|
if (width === 0) return []
|
||||||
|
const filled = Math.round(position() * width)
|
||||||
|
const glowRadius = 6
|
||||||
|
const span = Math.max(1, filled + glowRadius * 2)
|
||||||
|
const center = pulse() * span - glowRadius
|
||||||
|
const success = props.outcome() === "success"
|
||||||
|
const completion = smoothstep(headerFade.progress())
|
||||||
|
return Array.from({ length: width }, (_, index) => {
|
||||||
|
const color =
|
||||||
|
index >= filled
|
||||||
|
? colors.muted
|
||||||
|
: shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
|
||||||
|
return {
|
||||||
|
char: success || index < filled ? "━" : "·",
|
||||||
|
color: success ? blend(color, colors.accent, completion) : color,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let value = 0
|
||||||
|
let velocity = 0
|
||||||
|
let phase = 0
|
||||||
|
const frame = (deltaTime: number) => {
|
||||||
|
if (!props.animating()) return frameDone
|
||||||
|
const elapsed = Math.min(0.032, deltaTime / 1_000)
|
||||||
|
const stiffness = 110
|
||||||
|
const damping = 2 * Math.sqrt(stiffness)
|
||||||
|
const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
|
||||||
|
velocity += (stiffness * (target - value) - damping * velocity) * elapsed
|
||||||
|
value += velocity * elapsed
|
||||||
|
phase = (phase + deltaTime / 900) % 1
|
||||||
|
batch(() => {
|
||||||
|
setPosition(Math.max(0, Math.min(1, value)))
|
||||||
|
setPulse(phase)
|
||||||
|
})
|
||||||
|
headerFade.tick(deltaTime)
|
||||||
|
statusSweep.tick(deltaTime)
|
||||||
|
return frameDone
|
||||||
|
}
|
||||||
|
props.renderer.setFrameCallback(frame)
|
||||||
|
onCleanup(() => props.renderer.removeFrameCallback(frame))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
||||||
|
<Monogram ink={monogramInk} />
|
||||||
|
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||||
|
<CellLine cells={header()} />
|
||||||
|
<Show
|
||||||
|
when={props.outcome() === "running"}
|
||||||
|
fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
|
||||||
|
<CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<CellLine cells={rail()} />
|
||||||
|
<text fg={colors.muted}>
|
||||||
|
{props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CellLine(props: { cells: ReadonlyArray<Cell> }) {
|
||||||
|
return (
|
||||||
|
<text truncate>
|
||||||
|
<Index each={props.cells}>
|
||||||
|
{(cell) => (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fg: cell().color,
|
||||||
|
attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell().char}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Index>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as UpdatePreflight from "./update-preflight"
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Config } from "../src/config"
|
||||||
|
|
||||||
|
function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service>) {
|
||||||
|
return Effect.runPromise(
|
||||||
|
effect.pipe(
|
||||||
|
Effect.provide(Config.layer),
|
||||||
|
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("migrates tui and kv config into cli.json", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(
|
||||||
|
path.join(directory, "tui.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
theme: "legacy",
|
||||||
|
keybinds: { leader: "ctrl+o" },
|
||||||
|
plugin: [["example", { mode: "safe" }]],
|
||||||
|
plugin_enabled: { disabled: false },
|
||||||
|
leader_timeout: 500,
|
||||||
|
scroll_speed: 2,
|
||||||
|
scroll_acceleration: { enabled: true },
|
||||||
|
diff_style: "stacked",
|
||||||
|
mouse: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(directory, "kv.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
theme_mode_lock: "light",
|
||||||
|
attention_sound_pack: "custom.pack",
|
||||||
|
diff_wrap_mode: "none",
|
||||||
|
diff_viewer_show_file_tree: false,
|
||||||
|
diff_viewer_single_patch: true,
|
||||||
|
diff_viewer_view: "split",
|
||||||
|
terminal_title_enabled: false,
|
||||||
|
file_context_enabled: false,
|
||||||
|
paste_summary_enabled: false,
|
||||||
|
sidebar: "hide",
|
||||||
|
scrollbar_visible: true,
|
||||||
|
thinking_mode: "show",
|
||||||
|
exploration_grouping: false,
|
||||||
|
tips_hidden: true,
|
||||||
|
dismissed_getting_started: true,
|
||||||
|
animations_enabled: false,
|
||||||
|
skipped_version: "9.9.9",
|
||||||
|
which_key_layout: "overlay",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
return yield* service.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
theme: { name: "legacy", mode: "light" },
|
||||||
|
keybinds: { leader: "ctrl+o" },
|
||||||
|
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||||
|
leader: { timeout: 500 },
|
||||||
|
scroll: { speed: 2, acceleration: true },
|
||||||
|
attention: { sound_pack: "custom.pack" },
|
||||||
|
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||||
|
terminal: { title: false },
|
||||||
|
prompt: { editor: false, paste: "full" },
|
||||||
|
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||||
|
hints: { tips: false, onboarding: false },
|
||||||
|
animations: false,
|
||||||
|
mouse: false,
|
||||||
|
})
|
||||||
|
expect(config).not.toHaveProperty("skipped_version")
|
||||||
|
expect(config).not.toHaveProperty("which_key")
|
||||||
|
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||||
|
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||||
|
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migrates before the first update and does not remigrate afterward", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "legacy" }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
yield* service.update((draft) => {
|
||||||
|
draft.animations = false
|
||||||
|
draft.mouse = false
|
||||||
|
})
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
|
||||||
|
)
|
||||||
|
return yield* service.get()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
|
||||||
|
theme: { name: "legacy" },
|
||||||
|
animations: false,
|
||||||
|
mouse: false,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates a config draft while preserving JSONC comments", async () => {
|
||||||
|
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||||
|
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await run(
|
||||||
|
directory,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Config.Service
|
||||||
|
return yield* service.update((draft) => {
|
||||||
|
draft.prompt = { paste: "compact" }
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||||
|
} finally {
|
||||||
|
await Bun.$`rm -rf ${directory}`
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||||
import { resolve } from "@opencode-ai/tui/config"
|
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createComponent, createSignal } from "solid-js"
|
import { createComponent, createSignal } from "solid-js"
|
||||||
import { RunFooterView } from "../src/mini/footer.view"
|
import { RunFooterView } from "../src/mini/footer.view"
|
||||||
|
|||||||
@@ -476,18 +476,16 @@ export interface IntegrationApi<E = never> {
|
|||||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||||
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
||||||
export type ServerMcpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||||
|
|
||||||
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||||
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
||||||
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
||||||
export type ServerMcpResourceCatalogOperation<E = never> = (
|
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
|
||||||
input?: Endpoint11_1Input,
|
|
||||||
) => Effect.Effect<Endpoint11_1Output, E>
|
|
||||||
|
|
||||||
export interface ServerMcpApi<E = never> {
|
export interface McpApi<E = never> {
|
||||||
readonly list: ServerMcpListOperation<E>
|
readonly list: McpListOperation<E>
|
||||||
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
|
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
@@ -955,7 +953,7 @@ export interface AppApi<E = never> {
|
|||||||
readonly generate: GenerateApi<E>
|
readonly generate: GenerateApi<E>
|
||||||
readonly provider: ProviderApi<E>
|
readonly provider: ProviderApi<E>
|
||||||
readonly integration: IntegrationApi<E>
|
readonly integration: IntegrationApi<E>
|
||||||
readonly "server.mcp": ServerMcpApi<E>
|
readonly mcp: McpApi<E>
|
||||||
readonly credential: CredentialApi<E>
|
readonly credential: CredentialApi<E>
|
||||||
readonly project: ProjectApi<E>
|
readonly project: ProjectApi<E>
|
||||||
readonly form: FormApi<E>
|
readonly form: FormApi<E>
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
generate: adaptGroup8(raw["server.generate"]),
|
generate: adaptGroup8(raw["server.generate"]),
|
||||||
provider: adaptGroup9(raw["server.provider"]),
|
provider: adaptGroup9(raw["server.provider"]),
|
||||||
integration: adaptGroup10(raw["server.integration"]),
|
integration: adaptGroup10(raw["server.integration"]),
|
||||||
"server.mcp": adaptGroup11(raw["server.mcp"]),
|
mcp: adaptGroup11(raw["server.mcp"]),
|
||||||
credential: adaptGroup12(raw["server.credential"]),
|
credential: adaptGroup12(raw["server.credential"]),
|
||||||
project: adaptGroup13(raw["server.project"]),
|
project: adaptGroup13(raw["server.project"]),
|
||||||
form: adaptGroup14(raw["server.form"]),
|
form: adaptGroup14(raw["server.form"]),
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ export type Options = {
|
|||||||
export type StartReason = "missing" | "version-mismatch"
|
export type StartReason = "missing" | "version-mismatch"
|
||||||
|
|
||||||
export type StartOptions = Options & {
|
export type StartOptions = Options & {
|
||||||
readonly onStart?: (reason: StartReason) => void
|
// Called once when start() decides it must spawn: either no service was
|
||||||
|
// found, or a healthy service with a different version is being replaced.
|
||||||
|
// `existing` carries the registration of the service being replaced.
|
||||||
|
readonly onStart?: (reason: StartReason, existing?: Info) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only lookup: registration file plus health check and version gate.
|
// Read-only lookup: registration file plus health check and version gate.
|
||||||
@@ -56,9 +59,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
|||||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||||
const compatible = yield* discover(options)
|
const compatible = yield* discover(options)
|
||||||
if (compatible !== undefined) return compatible
|
if (compatible !== undefined) return compatible
|
||||||
const mismatched = yield* find(options)
|
const existing = yield* find(options)
|
||||||
yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch"))
|
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
|
||||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
return existing.endpoint
|
||||||
|
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info))
|
||||||
|
if (existing !== undefined) yield* kill(existing.info, options).pipe(Effect.ignore)
|
||||||
|
|
||||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||||
@@ -133,6 +138,7 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
|||||||
type LocalService = {
|
type LocalService = {
|
||||||
readonly info: Info
|
readonly info: Info
|
||||||
readonly endpoint: Endpoint
|
readonly endpoint: Endpoint
|
||||||
|
readonly version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||||
@@ -156,7 +162,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
|||||||
if (health.value.pid !== info.pid) return undefined
|
if (health.value.pid !== info.pid) return undefined
|
||||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||||
if (version !== undefined && health.value.version !== version) return undefined
|
if (version !== undefined && health.value.version !== version) return undefined
|
||||||
return { info, endpoint } satisfies LocalService
|
return { info, endpoint, version: health.value.version } satisfies LocalService
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!allowLegacy ||
|
!allowLegacy ||
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
|
export type ClientErrorReason =
|
||||||
|
| "Transport"
|
||||||
|
| "UnexpectedStatus"
|
||||||
|
| "UnsupportedContentType"
|
||||||
|
| "MalformedResponse"
|
||||||
|
| "SseEventTooLarge"
|
||||||
|
|
||||||
export class ClientError extends Error {
|
export class ClientError extends Error {
|
||||||
override readonly name = "ClientError"
|
override readonly name = "ClientError"
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ import type {
|
|||||||
IntegrationAttemptCompleteOutput,
|
IntegrationAttemptCompleteOutput,
|
||||||
IntegrationAttemptCancelInput,
|
IntegrationAttemptCancelInput,
|
||||||
IntegrationAttemptCancelOutput,
|
IntegrationAttemptCancelOutput,
|
||||||
ServerMcpListInput,
|
McpListInput,
|
||||||
ServerMcpListOutput,
|
McpListOutput,
|
||||||
ServerMcpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
ServerMcpResourceCatalogOutput,
|
McpResourceCatalogOutput,
|
||||||
CredentialUpdateInput,
|
CredentialUpdateInput,
|
||||||
CredentialUpdateOutput,
|
CredentialUpdateOutput,
|
||||||
CredentialRemoveInput,
|
CredentialRemoveInput,
|
||||||
@@ -213,6 +213,8 @@ interface RequestDescriptor {
|
|||||||
readonly binary?: true
|
readonly binary?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxSseEventBytes = 16 * 1024 * 1024
|
||||||
|
|
||||||
export function make(options: ClientOptions) {
|
export function make(options: ClientOptions) {
|
||||||
const fetch = options.fetch ?? globalThis.fetch
|
const fetch = options.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ export function make(options: ClientOptions) {
|
|||||||
throw new ClientError("Transport", { cause })
|
throw new ClientError("Transport", { cause })
|
||||||
}
|
}
|
||||||
buffer += decoder.decode(next.value, { stream: !next.done })
|
buffer += decoder.decode(next.value, { stream: !next.done })
|
||||||
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
|
||||||
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
||||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||||
@@ -939,9 +941,9 @@ export function make(options: ClientOptions) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"server.mcp": {
|
mcp: {
|
||||||
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
|
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
||||||
request<ServerMcpListOutput>(
|
request<McpListOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/mcp`,
|
path: `/api/mcp`,
|
||||||
@@ -953,8 +955,8 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
resource: {
|
resource: {
|
||||||
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||||
request<ServerMcpResourceCatalogOutput>(
|
request<McpResourceCatalogOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/mcp/resource`,
|
path: `/api/mcp/resource`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
import { rename, writeFile } from "node:fs/promises"
|
||||||
|
|
||||||
|
const [registration, mode] = process.argv.slice(2)
|
||||||
|
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||||
|
|
||||||
|
let requests = 0
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(request) {
|
||||||
|
if (new URL(request.url).pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||||
|
requests += 1
|
||||||
|
if (mode === "modern" && requests === 1) {
|
||||||
|
await writeFile(registration + ".first-request", "")
|
||||||
|
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||||
|
return new Response(null, { status: 503 })
|
||||||
|
}
|
||||||
|
if (mode === "legacy") return Response.json({ healthy: true })
|
||||||
|
return Response.json({ healthy: true, version: "test", pid: process.pid })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
registration + ".tmp",
|
||||||
|
JSON.stringify({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
version: mode === "legacy" ? undefined : "test",
|
||||||
|
url: server.url.toString(),
|
||||||
|
pid: process.pid,
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
)
|
||||||
|
await rename(registration + ".tmp", registration)
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
server.stop(true)
|
||||||
|
process.exit()
|
||||||
|
}
|
||||||
|
process.on("SIGTERM", shutdown)
|
||||||
|
process.on("SIGINT", shutdown)
|
||||||
@@ -284,6 +284,43 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
|
||||||
|
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
|
||||||
|
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
for (let offset = 0; offset < encoded.length; offset += 64 * 1024) {
|
||||||
|
controller.enqueue(encoded.slice(offset, offset + 64 * 1024))
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("event.subscribe rejects an SSE event above the size limit", async () => {
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(`data: ${JSON.stringify({ output: "x".repeat(16 * 1024 * 1024) })}`, {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||||
|
name: "ClientError",
|
||||||
|
reason: "SseEventTooLarge",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { Service } from "../src/effect/index"
|
||||||
|
|
||||||
|
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||||
|
const processes: Bun.Subprocess[] = []
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
processes.forEach((process) => process.kill("SIGTERM"))
|
||||||
|
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||||
|
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
spawn(registration, "modern")
|
||||||
|
await waitForFile(registration)
|
||||||
|
const original = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
const starts: Service.StartReason[] = []
|
||||||
|
const first = run(
|
||||||
|
Service.start({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [],
|
||||||
|
onStart: (reason) => starts.push(reason),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await waitForFile(registration + ".first-request")
|
||||||
|
|
||||||
|
const resolved = await run(Service.start({ file: registration, version: "test" }))
|
||||||
|
expect(resolved.url).toBe(original.url)
|
||||||
|
|
||||||
|
await writeFile(registration + ".release", "")
|
||||||
|
await first
|
||||||
|
|
||||||
|
expect(starts).toEqual([])
|
||||||
|
expect(await Bun.file(registration).json()).toEqual(original)
|
||||||
|
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a legacy health response is still replaced", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const existing = spawn(registration, "legacy")
|
||||||
|
await waitForFile(registration)
|
||||||
|
|
||||||
|
const starts: Service.StartReason[] = []
|
||||||
|
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow("Missing service command")
|
||||||
|
expect(starts).toEqual(["version-mismatch"])
|
||||||
|
await existing.exited
|
||||||
|
})
|
||||||
|
|
||||||
|
function run<A, E>(effect: Effect.Effect<A, E, never>) {
|
||||||
|
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(registration: string, mode: string, ...args: string[]) {
|
||||||
|
const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "inherit",
|
||||||
|
})
|
||||||
|
processes.push(subprocess)
|
||||||
|
return subprocess
|
||||||
|
}
|
||||||
|
|
||||||
|
async function temp() {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
|
||||||
|
directories.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(file: string) {
|
||||||
|
for (let attempt = 0; attempt < 600; attempt++) {
|
||||||
|
if (await Bun.file(file).exists()) return
|
||||||
|
await Bun.sleep(5)
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function health(url: string) {
|
||||||
|
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool.
|
||||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR. Update `codemode.md` when the package design, integration status, or rationale changes.
|
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||||
|
|
||||||
## OpenAPI
|
## OpenAPI
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
+92
-261
@@ -1,37 +1,40 @@
|
|||||||
# @opencode-ai/codemode
|
# @opencode-ai/codemode
|
||||||
|
|
||||||
Effect-native confined code execution over explicit, schema-described tools.
|
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
|
||||||
|
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
|
||||||
|
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
|
||||||
|
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
|
||||||
|
exactly what is supported.
|
||||||
|
|
||||||
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
|
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
|
||||||
|
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
|
||||||
|
runs, no sandbox required.
|
||||||
|
|
||||||
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
|
## How it differs from JavaScript
|
||||||
|
|
||||||
```ts
|
The deliberate differences:
|
||||||
// One execution
|
|
||||||
yield * CodeMode.execute({ tools, code })
|
|
||||||
|
|
||||||
// A reusable runtime
|
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
|
||||||
const runtime = CodeMode.make({ tools, limits })
|
library and the `tools` tree.
|
||||||
yield * runtime.execute(code)
|
- **No dynamic code.** No `eval`, `Function`, or module loading.
|
||||||
```
|
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
|
||||||
|
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
|
||||||
|
- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still
|
||||||
|
running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must
|
||||||
|
await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead
|
||||||
|
of crashing the run.
|
||||||
|
- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`.
|
||||||
|
|
||||||
## Install
|
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
|
||||||
|
`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes,
|
||||||
Within this workspace:
|
generators, and full sparse-array parity) are tracked as unchecked items in the
|
||||||
|
[interpreter support checklist](./interpreter-support.md).
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@opencode-ai/codemode": "workspace:*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
|
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
|
||||||
|
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
|
||||||
|
to programs as `tools`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
||||||
@@ -60,69 +63,53 @@ const result =
|
|||||||
`)
|
`)
|
||||||
```
|
```
|
||||||
|
|
||||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
|
||||||
|
rather than failing the Effect; host interruption remains interruption.
|
||||||
Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `Tool.make`
|
### `Tool.make`
|
||||||
|
|
||||||
```ts
|
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
|
||||||
const tool = Tool.make({
|
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
|
||||||
description,
|
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
|
||||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
Descriptions and schemas are model-visible contract; keep authorization in `run`.
|
||||||
output, // optional; same choice
|
|
||||||
run,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
|
### `CodeMode.execute` and `CodeMode.make`
|
||||||
|
|
||||||
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
|
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
|
||||||
|
runtime from `make` reuses the tool set and policy:
|
||||||
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
|
|
||||||
|
|
||||||
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
|
|
||||||
|
|
||||||
### `CodeMode.execute`
|
|
||||||
|
|
||||||
Use `CodeMode.execute` for a single execution:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const result =
|
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
|
||||||
yield *
|
|
||||||
CodeMode.execute({
|
|
||||||
tools: { orders: { lookup: lookupOrder } },
|
|
||||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
|
||||||
limits: { maxToolCalls: 10 },
|
|
||||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
|
||||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
|
|
||||||
|
|
||||||
### `CodeMode.make`
|
|
||||||
|
|
||||||
Use `CodeMode.make` when the tool set and execution policy are reused:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const runtime = CodeMode.make({
|
|
||||||
tools: { orders: { lookup: lookupOrder } },
|
|
||||||
limits: { timeoutMs: 30_000 },
|
|
||||||
})
|
|
||||||
|
|
||||||
runtime.catalog() // structured tool descriptions
|
runtime.catalog() // structured tool descriptions
|
||||||
runtime.instructions() // model-facing syntax and tool guide
|
runtime.instructions() // model-facing syntax and tool guide
|
||||||
runtime.execute(source) // CodeMode.Result
|
runtime.execute(source) // CodeMode.Result
|
||||||
```
|
```
|
||||||
|
|
||||||
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
|
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
|
||||||
|
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
|
||||||
|
Effect-returning and must not fail.
|
||||||
|
|
||||||
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
|
### OpenAPI tools
|
||||||
|
|
||||||
### Results
|
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted
|
||||||
|
`operationId`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
|
||||||
|
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||||
|
```
|
||||||
|
|
||||||
|
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
||||||
|
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
||||||
|
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
|
||||||
|
`src/openapi/types.ts` for full semantics.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
Every execution returns a `CodeMode.Result`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type Result = Success | Failure
|
type Result = Success | Failure
|
||||||
@@ -145,152 +132,11 @@ interface Failure {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
|
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
|
||||||
|
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
|
||||||
|
`toolCalls` lists admitted calls in order - retained on failure for auditing.
|
||||||
|
|
||||||
### Tool-call hooks
|
Failure `error` and success `warnings` share one diagnostic vocabulary:
|
||||||
|
|
||||||
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
|
|
||||||
|
|
||||||
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
|
|
||||||
|
|
||||||
### OpenAPI tools
|
|
||||||
|
|
||||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
|
||||||
|
|
||||||
const api = OpenAPI.fromSpec({
|
|
||||||
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
|
|
||||||
auth: {
|
|
||||||
resolve: ({ name, scopes, operation }) =>
|
|
||||||
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
|
||||||
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
|
|
||||||
```
|
|
||||||
|
|
||||||
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
|
|
||||||
|
|
||||||
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
|
|
||||||
|
|
||||||
## Discovery
|
|
||||||
|
|
||||||
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
|
||||||
|
|
||||||
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const runtime = CodeMode.make({
|
|
||||||
tools,
|
|
||||||
discovery: { catalogBudget: 6_000 },
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
The budget must be a non-negative safe integer.
|
|
||||||
|
|
||||||
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const matches = await tools.$codemode.search({
|
|
||||||
query: "order status",
|
|
||||||
namespace: "orders", // optional: scope to one top-level namespace
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const request = { query: "order status", namespace: "orders", limit: 10 }
|
|
||||||
const page = await tools.$codemode.search(request)
|
|
||||||
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
|
|
||||||
```
|
|
||||||
|
|
||||||
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
tools.github.list_issues(input: {
|
|
||||||
/** Repository owner */
|
|
||||||
owner: string,
|
|
||||||
/** Cursor from the previous response's pageInfo */
|
|
||||||
after?: string,
|
|
||||||
/**
|
|
||||||
* Results per page
|
|
||||||
* @default 30
|
|
||||||
*/
|
|
||||||
perPage?: number,
|
|
||||||
}): Promise<unknown>
|
|
||||||
```
|
|
||||||
|
|
||||||
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
|
|
||||||
|
|
||||||
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
|
||||||
|
|
||||||
A host cannot define its own `$codemode` top-level namespace.
|
|
||||||
|
|
||||||
## Supported Programs
|
|
||||||
|
|
||||||
CodeMode executes a deliberately bounded JavaScript subset. See the
|
|
||||||
[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library
|
|
||||||
matrix, known semantic gaps, and intentional exclusions.
|
|
||||||
|
|
||||||
At a high level, it supports:
|
|
||||||
|
|
||||||
- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining,
|
|
||||||
and structured error handling.
|
|
||||||
- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs.
|
|
||||||
- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work.
|
|
||||||
- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries.
|
|
||||||
- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures.
|
|
||||||
|
|
||||||
It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an
|
|
||||||
`UnsupportedSyntax` diagnostic with a source location when available.
|
|
||||||
|
|
||||||
CodeMode is an orchestration language, not a general JavaScript runtime.
|
|
||||||
|
|
||||||
## Execution Limits
|
|
||||||
|
|
||||||
The limits are exactly three knobs:
|
|
||||||
|
|
||||||
| Limit | Default | Bounds |
|
|
||||||
| ---------------- | -------------------: | ---------------------------------------------------- |
|
|
||||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
|
||||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
|
||||||
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
|
||||||
|
|
||||||
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
|
||||||
|
|
||||||
Pass only the overrides you need:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const runtime = CodeMode.make({
|
|
||||||
tools,
|
|
||||||
limits: {
|
|
||||||
maxToolCalls: 20,
|
|
||||||
timeoutMs: 60_000,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
|
|
||||||
|
|
||||||
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
|
|
||||||
|
|
||||||
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
|
|
||||||
|
|
||||||
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
|
|
||||||
|
|
||||||
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
|
|
||||||
|
|
||||||
## Diagnostics
|
|
||||||
|
|
||||||
Failures are data:
|
|
||||||
|
|
||||||
| Kind | Meaning |
|
| Kind | Meaning |
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -306,58 +152,45 @@ Failures are data:
|
|||||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||||
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
||||||
|
|
||||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
|
||||||
|
for a model-visible refusal; its optional cause never crosses the boundary.
|
||||||
|
|
||||||
```ts
|
## Discovery
|
||||||
import { toolError } from "@opencode-ai/codemode"
|
|
||||||
|
|
||||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
|
||||||
```
|
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
|
||||||
|
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
|
||||||
|
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
|
||||||
|
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
|
||||||
|
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
|
||||||
|
lookup. Search counts as an admitted tool call.
|
||||||
|
|
||||||
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
|
## Execution Limits
|
||||||
|
|
||||||
## Authority Boundary
|
| Limit | Default | Bounds |
|
||||||
|
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||||
|
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||||
|
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||||
|
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
||||||
|
|
||||||
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
|
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
|
||||||
|
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
||||||
|
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
||||||
|
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
||||||
|
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. CodeMode does not limit
|
||||||
|
tool-call concurrency. Data nesting at boundaries is limited to 32 levels.
|
||||||
|
|
||||||
The host owns:
|
## Boundaries and Non-Goals
|
||||||
|
|
||||||
- Authentication and authorization.
|
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
|
||||||
- Tool selection and immutable scope.
|
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
|
||||||
- Credentials and network clients.
|
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
|
||||||
- Persistence, idempotency, approval, and durable side effects.
|
to restrict it.
|
||||||
- Logging and redaction policy.
|
|
||||||
|
|
||||||
CodeMode owns:
|
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
|
||||||
|
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
|
||||||
- Parsing and interpreting the supported subset without `eval`.
|
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
|
||||||
- Schema boundaries around tool calls.
|
the currently authorized tools.
|
||||||
- Plain-data copying and blocked prototype members.
|
|
||||||
- Resource limits, call accounting, and normalized diagnostics.
|
|
||||||
- Model-facing tool discovery and instructions.
|
|
||||||
|
|
||||||
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
|
|
||||||
|
|
||||||
## Laws
|
|
||||||
|
|
||||||
The public contract is guided by these equivalences:
|
|
||||||
|
|
||||||
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
|
||||||
- A tool implementation is not invoked unless its input has decoded successfully.
|
|
||||||
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
|
|
||||||
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
|
|
||||||
- Host interruption remains interruption rather than a `CodeMode.Failure`.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Generic permission prompts or approval workflows.
|
|
||||||
- Durable pause/resume, replay, or storage adapters.
|
|
||||||
- Exactly-once external side effects.
|
|
||||||
- Application authorization or product policy.
|
|
||||||
- A filesystem or process sandbox for arbitrary JavaScript.
|
|
||||||
- Compatibility with the full JavaScript language or npm ecosystem.
|
|
||||||
|
|
||||||
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -367,5 +200,3 @@ From the package directory:
|
|||||||
bun test
|
bun test
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
|
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
# CodeMode Design and Status
|
|
||||||
|
|
||||||
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
|
|
||||||
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
|
|
||||||
|
|
||||||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
|
||||||
completed work instead of preserving checked-off chronology.
|
|
||||||
|
|
||||||
Detailed package API documentation lives in [README.md](./README.md), and the checkable language/runtime matrix lives
|
|
||||||
in [interpreter-support.md](./interpreter-support.md). OpenAPI-specific follow-ups live in
|
|
||||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
|
||||||
|
|
||||||
## How CodeMode Works
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
|
|
||||||
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
|
|
||||||
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
|
|
||||||
and filter or aggregate results before returning them to the agent loop.
|
|
||||||
|
|
||||||
The goals are:
|
|
||||||
|
|
||||||
- Reduce model context consumed by large tool catalogs.
|
|
||||||
- Avoid an agent round-trip between every dependent tool call.
|
|
||||||
- Keep large intermediate results inside the program instead of sending them through model context.
|
|
||||||
- Give generated code only the authority explicitly supplied by the host.
|
|
||||||
|
|
||||||
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
|
|
||||||
|
|
||||||
### Runtime
|
|
||||||
|
|
||||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
|
||||||
|
|
||||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
|
||||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
|
|
||||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
|
||||||
executes it without `eval`.
|
|
||||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
|
||||||
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
|
|
||||||
remains Effect interruption.
|
|
||||||
|
|
||||||
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
|
|
||||||
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
|
|
||||||
advertised as `Promise<unknown>`.
|
|
||||||
|
|
||||||
### Discovery and model workflow
|
|
||||||
|
|
||||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
|
||||||
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
|
|
||||||
and is advertised when the inline catalog is partial.
|
|
||||||
|
|
||||||
The intended workflow is:
|
|
||||||
|
|
||||||
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
|
|
||||||
in the next execution.
|
|
||||||
2. Call the exact returned path without guessing or normalizing segments.
|
|
||||||
3. Narrow `Promise<unknown>` results before reading fields.
|
|
||||||
4. Start independent calls together and await them with `Promise.all`.
|
|
||||||
5. Filter and aggregate inside the program, then return only the data needed by the model.
|
|
||||||
|
|
||||||
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
|
|
||||||
path lookup, namespace browsing, deterministic ranking, and pagination.
|
|
||||||
|
|
||||||
### Tool execution
|
|
||||||
|
|
||||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
|
||||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the
|
|
||||||
`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime
|
|
||||||
of work they started.
|
|
||||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
|
||||||
`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers
|
|
||||||
continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the
|
|
||||||
executor first-class resolve/reject callables that may escape and settle the promise later, exactly once.
|
|
||||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
|
||||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
|
||||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
|
||||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
|
||||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
|
||||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
|
||||||
Rejections that settled un-awaited before the return become `Success.warnings` diagnostics. A fatal program failure or
|
|
||||||
host interruption closes the execution promise scope and interrupts its active fibers instead. A timeout does the
|
|
||||||
same, except that a value the program already returned is preserved alongside a `TimeoutExceeded` warning rather than
|
|
||||||
discarded. At most eight tool calls execute concurrently.
|
|
||||||
|
|
||||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
|
||||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
|
||||||
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
|
||||||
warning diagnostics have an equal separate budget so a large value cannot starve them, and fixed truncation notices and
|
|
||||||
host-added framing are intentionally outside the budgets.
|
|
||||||
|
|
||||||
### Data, files, and failures
|
|
||||||
|
|
||||||
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
|
|
||||||
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
|
||||||
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
|
||||||
data, tool failures, limits, timeouts, execution failures, and warning truncation.
|
|
||||||
|
|
||||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
|
||||||
attach them to the outer result, but the program receives only the structured tool output.
|
|
||||||
|
|
||||||
### V2 OpenCode adapter
|
|
||||||
|
|
||||||
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
|
||||||
`packages/core/src/tool/execute.ts`:
|
|
||||||
|
|
||||||
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
|
|
||||||
`Tools.Service`.
|
|
||||||
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
|
|
||||||
normally.
|
|
||||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
|
||||||
CodeMode namespaces instead of flattened model-facing names.
|
|
||||||
- Nested calls execute the registered `Tool` values captured for the model request; later registrations affect later
|
|
||||||
requests.
|
|
||||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
|
||||||
authorization.
|
|
||||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
|
||||||
- Nested call statuses are returned as final `execute` metadata for the TUI.
|
|
||||||
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
|
|
||||||
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
|
|
||||||
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
|
|
||||||
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
|
|
||||||
supervised children; the outer settlement applies Core's normal output-retention policy.
|
|
||||||
|
|
||||||
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
|
|
||||||
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
|
||||||
inside CodeMode.
|
|
||||||
|
|
||||||
## Intentionally Unsupported
|
|
||||||
|
|
||||||
These are product boundaries rather than DSL backlog:
|
|
||||||
|
|
||||||
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
|
|
||||||
supplied tools.
|
|
||||||
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
|
|
||||||
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
|
|
||||||
side effects. Hosts and tools own those concerns.
|
|
||||||
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
|
|
||||||
|
|
||||||
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
|
|
||||||
represent accurately rather than guessing semantics.
|
|
||||||
|
|
||||||
## Decisions and Rationale
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
|
|
||||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
|
||||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
|
||||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
|
||||||
| Start promises eagerly and supervise them for the execution. | This preserves normal call-time parallelism and run-once settlement while allowing pending work to be interrupted when the program returns. |
|
|
||||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
|
||||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
|
||||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
|
||||||
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
|
|
||||||
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
|
||||||
|
|
||||||
## Remaining Work
|
|
||||||
|
|
||||||
The [interpreter support checklist](./interpreter-support.md) owns concrete DSL, standard-library, semantic-correctness,
|
|
||||||
diagnostic, and data-boundary work. OpenAPI adapter work remains in [src/openapi/TODO.md](./src/openapi/TODO.md).
|
|
||||||
@@ -20,9 +20,11 @@ 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] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
- [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.
|
||||||
|
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
|
||||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||||
|
|
||||||
## Values and literals
|
## Values and literals
|
||||||
@@ -92,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 `**`.
|
||||||
@@ -107,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
|
||||||
@@ -146,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`.
|
||||||
@@ -169,14 +171,13 @@ ultimate source of truth.
|
|||||||
- [ ] `Array.prototype.toSpliced`.
|
- [ ] `Array.prototype.toSpliced`.
|
||||||
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
|
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
|
||||||
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
|
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
|
||||||
- [ ] Correct `findLast` return behavior when its predicate mutates the examined element.
|
|
||||||
|
|
||||||
## Strings
|
## Strings
|
||||||
|
|
||||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||||
- [x] Trimming: `trim`, `trimStart`, `trimEnd`, `trimLeft`, and `trimRight`.
|
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`.
|
||||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||||
- [x] Slicing/access: `slice`, `substring`, `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||||
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
- [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`.
|
||||||
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
- [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`.
|
||||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||||
@@ -289,7 +290,6 @@ ultimate source of truth.
|
|||||||
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
These are actionable implementation items. Check them off only when behavior and direct tests land.
|
||||||
|
|
||||||
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
- [x] Return real promises from `Promise.all`, `Promise.allSettled`, and `Promise.race`.
|
||||||
- [ ] Bound pending tool-call admission/allocation in addition to execution concurrency.
|
|
||||||
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
- [ ] Guarantee every advertised tool path is executable, including dotted and blocked path segments.
|
||||||
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
|
||||||
`null` in render-only or OpenAPI tool calls.
|
`null` in render-only or OpenAPI tool calls.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user