Compare commits

...

31 Commits

Author SHA1 Message Date
James Long 4778e6e031 fix(core): stream shell progress tail 2026-07-16 20:25:00 +00:00
James Long ac1b802820 fix(tui): restore selected action styling, fix subagent list (#37371) 2026-07-16 16:20:16 -04:00
Dustin Deus 5ee4c1082a feat(tui): add message navigation shortcuts (#37362) 2026-07-16 16:18:18 -04:00
starptech 8a70d70006 revert(tui): remove message navigation shortcuts 2026-07-16 21:45:40 +02:00
James Long 91b4634363 fix(tui): remove debug theme toggle (#37359) 2026-07-16 15:31:42 -04:00
starptech 4f60bde502 feat(tui): add message navigation shortcuts 2026-07-16 21:27:13 +02:00
Dax Raad 0d3b6d430e fix(tui): restore expandable tool hover state 2026-07-16 15:00:49 -04:00
Dax Raad a7cf21e157 refactor(core): move wellknown refresh to config 2026-07-16 14:32:46 -04:00
Kit Langton e4a16830f1 feat(core): add transient session generation (#37330) 2026-07-16 14:28:54 -04:00
Dax Raad 103f764624 feat(core): add provider policy enforcement 2026-07-16 14:28:18 -04:00
James Long 7a7075d86f feat(tui): refine V2 theme colors (#37346) 2026-07-16 14:01:25 -04:00
Aiden Cline c764732aea feat(codemode): expand destructuring support (#37342) 2026-07-16 12:57:37 -05:00
Dax Raad 72af084dc1 refactor(core): make state transforms synchronous 2026-07-16 13:55:14 -04:00
Dax Raad 65a42fd549 fix(core): debounce state reloads 2026-07-16 13:34:46 -04:00
Aiden Cline ad8e6b1fb6 feat(codemode): support property deletion (#37335) 2026-07-16 12:21:28 -05:00
Kit Langton 041cda905d fix(tui): label only detached subagents as background (#37306) 2026-07-16 12:21:19 -04:00
Aiden Cline 2ad6c42143 feat(core): normalize tool and attachment images at settlement (#37141) 2026-07-16 11:16:19 -05:00
Aiden Cline 60c7f847c1 feat(codemode): support void and Object.is (#37332) 2026-07-16 11:15:29 -05:00
Dax Raad 7ab8a08efa sync 2026-07-16 12:08:12 -04:00
Aiden Cline 7388e69b5c chore(codemode): update interpreter support 2026-07-16 10:44:44 -05:00
Kit Langton 56c0658e6f refactor(core): extract session model request preparation (#37210) 2026-07-16 10:58:06 -04:00
opencode-agent[bot] 06a2592836 fix(tui): align subagent navigation copy (#37319)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-07-16 10:55:54 -04:00
Kit Langton 4829308f2d fix(core): preserve logical step across retries (#37316) 2026-07-16 10:54:51 -04:00
Kit Langton eec6cd5234 test(core): cover mcp instruction producer (#37303) 2026-07-16 10:40:38 -04:00
Kit Langton fc37ae4452 refactor(core): split instruction observation and commit (#37208) 2026-07-16 10:37:00 -04:00
James Long 5fcef6773c feat(tui): migrate core surfaces to V2 themes (#37145) 2026-07-16 10:22:29 -04:00
Kit Langton a5b28c2af2 refactor(core): rename guidance modules (#37207) 2026-07-16 10:05:09 -04:00
opencode-agent[bot] d01dfa57b7 fix(core): limit v2 subagent nesting depth (#37291) 2026-07-16 09:28:15 -04:00
Shoubhit Dash 198ca749fd feat(ai): add Vertex Responses entrypoint (#37286) 2026-07-16 18:09:44 +05:30
Shoubhit Dash 75f9fd5208 feat(ai): add Vertex Chat entrypoint (#37281) 2026-07-16 17:51:03 +05:30
Shoubhit Dash 282f3f7eb2 refactor(ai): separate Vertex API routes (#37275) 2026-07-16 17:17:15 +05:30
123 changed files with 5388 additions and 1691 deletions
+19 -5
View File
@@ -127,23 +127,37 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/openai/responses`
- `@opencode-ai/ai/providers/openai-compatible/responses`
- `@opencode-ai/ai/providers/anthropic-compatible`
- `@opencode-ai/ai/providers/google-vertex`
- `@opencode-ai/ai/providers/google-vertex/anthropic`
- `@opencode-ai/ai/providers/google-vertex/gemini`
- `@opencode-ai/ai/providers/google-vertex/chat`
- `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini and Vertex Anthropic are separate products with separate entrypoints. Both accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only.
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex"
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic"
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project: "my-project", location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
```
+23 -21
View File
@@ -1,15 +1,15 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-15
Last reviewed: 2026-07-16
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
## Existing Status Sources
| File | What it tracks | Limitation |
| ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
| File | What it tracks | Limitation |
| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------- |
| `packages/ai/DESIGN.md` | Future clean-break API proposal for `@opencode-ai/ai`. | Not a provider parity tracker. |
| `packages/ai/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. |
## Current Implementation Snapshot
@@ -23,8 +23,10 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
| Vertex Gemini | `src/protocols/google-vertex-gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Anthropic Messages | `src/protocols/google-vertex-anthropic.ts`, `src/providers/google-vertex-anthropic.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
| Vertex Chat | `src/protocols/openai-chat.ts`, `src/providers/google-vertex-chat.ts` | Usable for MaaS models through OpenAI-compatible Chat Completions with explicit OAuth tokens or ADC and project/location endpoint derivation. | Core runner/catalog mapping and recorded provider coverage are missing; MaaS family-specific request parity needs review. |
| Vertex Responses | `src/protocols/openai-responses.ts`, `src/providers/google-vertex-responses.ts` | Usable for Grok models through OpenAI-compatible Responses with explicit OAuth tokens or ADC, project/location endpoint derivation, and storage disabled by default. | Core runner/catalog mapping and recorded provider coverage are missing; stateful continuation is not supported by Vertex. |
| Vertex Messages | `src/protocols/anthropic-messages.ts`, `src/providers/google-vertex-messages.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
@@ -54,8 +56,8 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
| `@ai-sdk/google-vertex/maas` | Vertex MaaS OpenAI-compatible namespace/facade | Missing | Decide native Chat/Responses selection, endpoint derivation, auth, and catalog mapping. |
| `@ai-sdk/google-vertex/xai` | Vertex xAI OpenAI-compatible namespace/facade | Missing | Decide whether this composes the generic compatible bases or the xAI facade, then add endpoint/auth mapping and tests. |
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
@@ -65,19 +67,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
4. Vertex Gemini and Vertex Anthropic now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
4. Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native boundaries remain for Vertex MaaS, Vertex xAI, and Bedrock Mantle.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
## Native Namespace Shape
These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
@@ -85,12 +87,12 @@ These are implementation/API slices, not separate npm packages.
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex` | Vertex Gemini API. |
| Vertex Anthropic Messages | `@opencode-ai/ai/providers/google-vertex/anthropic` | Vertex-hosted Anthropic Messages API. |
| Vertex MaaS | Missing | Vertex OpenAI-compatible MaaS APIs. |
| Vertex xAI | Missing | Vertex-hosted xAI APIs. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex OpenAI-compatible Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
@@ -99,8 +101,8 @@ These are implementation/API slices, not separate npm packages.
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini and Vertex Anthropic entrypoints.
5. Add native Vertex MaaS and Vertex xAI entrypoints by composing the compatible bases and shared Vertex auth/endpoint setup.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
+15 -3
View File
@@ -360,17 +360,29 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
model("gpt-4o", { apiKey, transport: "websocket" })
```
Vertex keeps Gemini and Anthropic Messages as separate package-like entrypoints,
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
while sharing project/location resolution and ADC authentication internally:
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex"
import { model } from "@opencode-ai/ai/providers/google-vertex/gemini"
model("gemini-3.5-flash", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/anthropic"
import { model } from "@opencode-ai/ai/providers/google-vertex/chat"
model("deepseek-ai/deepseek-v3.2-maas", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/responses"
model("xai/grok-4.20-reasoning", { project, location: "global" })
```
```ts
import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project, location: "global" })
```
@@ -1,42 +0,0 @@
import { Effect, Schema, Struct } from "effect"
import { AnthropicMessages } from "./anthropic-messages"
import { Auth } from "../route/auth"
import { Route } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
const VERSION = "vertex-2023-10-16" as const
export const GoogleVertexAnthropicBody = Schema.Struct({
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
anthropic_version: Schema.Literal(VERSION),
})
export type GoogleVertexAnthropicBody = Schema.Schema.Type<typeof GoogleVertexAnthropicBody>
export const protocol = Protocol.make({
id: "google-vertex-anthropic",
body: {
schema: GoogleVertexAnthropicBody,
from: (request) =>
AnthropicMessages.protocol.body.from(request).pipe(
Effect.map((body) => ({
...Struct.omit(body, ["model"]),
anthropic_version: VERSION,
})),
),
},
stream: AnthropicMessages.protocol.stream,
})
export const route = Route.make({
id: "google-vertex-anthropic",
provider: "google-vertex-anthropic",
providerMetadataKey: "anthropic",
protocol,
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: Framing.sse,
})
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
@@ -1,20 +0,0 @@
import { Gemini } from "./gemini"
import { Auth } from "../route/auth"
import { Route } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
export const route = Route.make({
id: "google-vertex-gemini",
provider: "google-vertex",
providerMetadataKey: "google",
protocol: Gemini.protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
}),
auth: Auth.none,
framing: Framing.sse,
})
export * as GoogleVertexGemini from "./google-vertex-gemini"
-2
View File
@@ -1,6 +1,4 @@
export * as AnthropicMessages from "./anthropic-messages"
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
export * as GoogleVertexGemini from "./google-vertex-gemini"
export * as BedrockConverse from "./bedrock-converse"
export * as Gemini from "./gemini"
export * as OpenAIChat from "./openai-chat"
@@ -0,0 +1,81 @@
import type { ProviderPackage } from "../provider-package"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
export const id = ProviderID.make("google-vertex")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: ProviderOptions
}
const route = OpenAICompatibleChat.route.with({
id: "google-vertex-chat",
provider: id,
})
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
baseURL,
location: inputLocation,
project: inputProject,
...rest
} = input
const location = GoogleVertexShared.location(inputLocation, "global")
const project = GoogleVertexShared.project(inputProject)
return route.with({
...rest,
endpoint: {
baseURL:
baseURL ??
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
},
auth: GoogleVertexShared.oauth(input, project),
})
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -0,0 +1,111 @@
import { Effect, Schema, Struct } from "effect"
import type { ProviderPackage } from "../provider-package"
import { AnthropicMessages } from "../protocols/anthropic-messages"
import { Auth } from "../route/auth"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
const VERSION = "vertex-2023-10-16" as const
// models.dev uses this provider id even though the API contract is Anthropic Messages.
export const id = ProviderID.make("google-vertex-anthropic")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: ProviderOptions
}
const route = Route.make({
id: "google-vertex-messages",
provider: id,
providerMetadataKey: "anthropic",
protocol: Protocol.make({
id: AnthropicMessages.protocol.id,
body: {
schema: Schema.Struct({
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
anthropic_version: Schema.Literal(VERSION),
}),
from: (request) =>
AnthropicMessages.protocol.body.from(request).pipe(
Effect.map((body) => ({
...Struct.omit(body, ["model"]),
anthropic_version: VERSION,
})),
),
},
stream: AnthropicMessages.protocol.stream,
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
framing: Framing.sse,
})
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Messages does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
baseURL,
location: inputLocation,
project: inputProject,
...rest
} = input
const location = GoogleVertexShared.location(inputLocation, "global")
const project = GoogleVertexShared.project(inputProject)
return route.with({
...rest,
endpoint: {
baseURL:
baseURL ??
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
},
auth: GoogleVertexShared.oauth(input, project),
})
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
}).model(modelID)
}
@@ -1,10 +1,10 @@
import type { ProviderPackage } from "../provider-package"
import { GoogleVertexAnthropic } from "../protocols/google-vertex-anthropic"
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
export const id = ProviderID.make("google-vertex-anthropic")
export const id = ProviderID.make("google-vertex")
export type Config = RouteDefaultsInput &
GoogleVertexShared.OAuthOptions & {
@@ -22,11 +22,16 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: ProviderOptions
}
export const routes = [GoogleVertexAnthropic.route]
const route = OpenAICompatibleResponses.route.with({
id: "google-vertex-responses",
provider: id,
})
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Anthropic does not support API keys")
throw new Error("Google Vertex Responses does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -37,12 +42,12 @@ const configuredRoute = (input: Config) => {
} = input
const location = GoogleVertexShared.location(inputLocation, "global")
const project = GoogleVertexShared.project(inputProject)
return GoogleVertexAnthropic.route.with({
return route.with({
...rest,
endpoint: {
baseURL:
baseURL ??
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
`https://aiplatform.googleapis.com/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/endpoints/openapi`,
},
auth: GoogleVertexShared.oauth(input, project),
})
@@ -63,7 +68,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Anthropic does not support API keys")
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
+19 -4
View File
@@ -1,7 +1,9 @@
import type { ProviderPackage } from "../provider-package"
import { GoogleVertexGemini } from "../protocols/google-vertex-gemini"
import { Gemini } from "../protocols/gemini"
import { Auth } from "../route/auth"
import type { RouteDefaultsInput } from "../route/client"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { GoogleVertexShared } from "./google-vertex-shared"
@@ -25,7 +27,20 @@ export type Settings = ProviderPackage.Settings &
readonly providerOptions?: ProviderOptions
}
export const routes = [GoogleVertexGemini.route]
const route = Route.make({
id: "google-vertex-gemini",
provider: id,
providerMetadataKey: "google",
protocol: Gemini.protocol,
endpoint: Endpoint.path(({ request }) => {
const model = String(request.model.id)
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
}),
auth: Auth.none,
framing: Framing.sse,
})
export const routes = [route]
const configuredRoute = (input: Config, modelID: string | ModelID) => {
const {
@@ -48,7 +63,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
(apiKey
? "https://aiplatform.googleapis.com/v1/publishers/google"
: `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`)
return GoogleVertexGemini.route.with({
return route.with({
...rest,
endpoint: { baseURL: endpoint },
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
@@ -1,2 +0,0 @@
export { model } from "../google-vertex-anthropic"
export type { Settings } from "../google-vertex-anthropic"
@@ -0,0 +1,2 @@
export { model } from "../google-vertex-chat"
export type { Settings } from "../google-vertex-chat"
@@ -0,0 +1,2 @@
export { model } from "../google-vertex"
export type { Settings } from "../google-vertex"
@@ -0,0 +1,2 @@
export { model } from "../google-vertex-messages"
export type { Settings } from "../google-vertex-messages"
@@ -0,0 +1,2 @@
export { model } from "../google-vertex-responses"
export type { Settings } from "../google-vertex-responses"
+3 -1
View File
@@ -7,7 +7,9 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google"
export * as GoogleVertex from "./google-vertex"
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
export * as GoogleVertexChat from "./google-vertex-chat"
export * as GoogleVertexMessages from "./google-vertex-messages"
export * as GoogleVertexResponses from "./google-vertex-responses"
export * as OpenAI from "./openai"
export * as OpenAICompatible from "./openai-compatible"
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
+49 -11
View File
@@ -11,7 +11,9 @@ import * as Cloudflare from "../src/providers/cloudflare"
import * as GitHubCopilot from "../src/providers/github-copilot"
import * as Google from "../src/providers/google"
import * as GoogleVertex from "../src/providers/google-vertex"
import * as GoogleVertexAnthropic from "../src/providers/google-vertex-anthropic"
import * as GoogleVertexChat from "../src/providers/google-vertex-chat"
import * as GoogleVertexMessages from "../src/providers/google-vertex-messages"
import * as GoogleVertexResponses from "../src/providers/google-vertex-responses"
import * as OpenAI from "../src/providers/openai"
import * as OpenAICompatible from "../src/providers/openai-compatible"
import * as OpenRouter from "../src/providers/openrouter"
@@ -171,20 +173,56 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Anthropic package settings do not accept API keys.
GoogleVertexAnthropic.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexAnthropic.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
"claude-sonnet-4-6",
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
)
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
// @ts-expect-error Vertex Anthropic model selectors only accept model ids.
// @ts-expect-error Vertex Chat package settings do not accept API keys.
GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", { apiKey: "vertex-key", project: "project" })
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model(
"deepseek-ai/deepseek-v3.2-maas",
// @ts-expect-error Vertex Chat model selectors only accept model ids.
{},
)
GoogleVertexAnthropic.configure({
GoogleVertexChat.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Anthropic config accepts only one auth source.
// @ts-expect-error Vertex Chat config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
project: "project",
})
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
"xai/grok-4.20-reasoning",
)
// @ts-expect-error Vertex Responses package settings do not accept API keys.
GoogleVertexResponses.model("xai/grok-4.20-reasoning", { apiKey: "vertex-key", project: "project" })
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model(
"xai/grok-4.20-reasoning",
// @ts-expect-error Vertex Responses model selectors only accept model ids.
{},
)
GoogleVertexResponses.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Responses config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
project: "project",
})
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
// @ts-expect-error Vertex Messages package settings do not accept API keys.
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
"claude-sonnet-4-6",
)
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
"claude-sonnet-4-6",
// @ts-expect-error Vertex Messages model selectors only accept model ids.
{},
)
GoogleVertexMessages.configure({
accessToken: "vertex-token",
// @ts-expect-error Vertex Messages config accepts only one auth source.
auth: RuntimeAuth.bearer("vertex-token"),
project: "project",
})
+67 -10
View File
@@ -17,12 +17,16 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/azure/chat"),
import("@opencode-ai/ai/providers/google"),
import("@opencode-ai/ai/providers/google-vertex"),
import("@opencode-ai/ai/providers/google-vertex/anthropic"),
import("@opencode-ai/ai/providers/google-vertex/gemini"),
import("@opencode-ai/ai/providers/google-vertex/chat"),
import("@opencode-ai/ai/providers/google-vertex/responses"),
import("@opencode-ai/ai/providers/google-vertex/messages"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model)
})
test("maps package settings onto the executable model", () => {
@@ -179,20 +183,35 @@ describe("provider package entrypoints", () => {
test("selects Vertex entrypoints with the same model contract", async () => {
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic")
const GoogleVertexGemini = await import("@opencode-ai/ai/providers/google-vertex/gemini")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const gemini = GoogleVertex.model("gemini-3.5-flash", {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
})
const anthropic = GoogleVertexAnthropic.model("claude-sonnet-4-6", {
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const chat = GoogleVertexChat.model("deepseek-ai/deepseek-v3.2-maas", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
const responses = GoogleVertexResponses.model("xai/grok-4.20-reasoning", {
accessToken: "fixture",
location: "global",
project: "vertex-project",
})
expect(GoogleVertexGemini.model).toBe(GoogleVertex.model)
expect(gemini.route.id).toBe("google-vertex-gemini")
expect(gemini.route.protocol).toBe("gemini")
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
@@ -204,15 +223,31 @@ describe("provider package entrypoints", () => {
project: "vertex-project",
}).route.endpoint.baseURL,
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
expect(anthropic.route.id).toBe("google-vertex-anthropic")
expect(anthropic.route.endpoint.baseURL).toBe(
expect(messages.route.id).toBe("google-vertex-messages")
expect(messages.route.protocol).toBe("anthropic-messages")
expect(messages.route.endpoint.baseURL).toBe(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
)
expect(chat.route.id).toBe("google-vertex-chat")
expect(chat.route.protocol).toBe("openai-chat")
expect(chat.route.endpoint).toMatchObject({
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/chat/completions",
})
expect(responses.route.id).toBe("google-vertex-responses")
expect(responses.route.protocol).toBe("openai-responses")
expect(responses.route.endpoint).toMatchObject({
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses",
})
expect(responses.route.defaults.providerOptions).toEqual({ openai: { store: false } })
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
const GoogleVertex = await import("@opencode-ai/ai/providers/google-vertex")
const GoogleVertexAnthropic = await import("@opencode-ai/ai/providers/google-vertex/anthropic")
const GoogleVertexChat = await import("@opencode-ai/ai/providers/google-vertex/chat")
const GoogleVertexMessages = await import("@opencode-ai/ai/providers/google-vertex/messages")
const GoogleVertexResponses = await import("@opencode-ai/ai/providers/google-vertex/responses")
const Providers = await import("@opencode-ai/ai/providers")
expect(() =>
Reflect.apply(GoogleVertex.model, undefined, [
@@ -225,15 +260,37 @@ describe("provider package entrypoints", () => {
])
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexAnthropic.model, undefined, [
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Anthropic does not support API keys")
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexAnthropic.configure, undefined, [
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Anthropic does not support API keys")
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
})
})
@@ -2,10 +2,11 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { GoogleVertex, GoogleVertexAnthropic } from "../../src/providers"
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
import { LLMClient } from "../../src/route"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { deltaChunk, finishChunk } from "../lib/openai-chunks"
import { sseEvents } from "../lib/sse"
describe("Google Vertex providers", () => {
@@ -56,7 +57,7 @@ describe("Google Vertex providers", () => {
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexAnthropic.configure({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
location: "eu",
project: "vertex-project",
@@ -99,11 +100,91 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Anthropic API version from body overlays", () =>
it.effect("sends MaaS requests through Vertex Chat Completions", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexChat.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
}).model("deepseek-ai/deepseek-v3.2-maas"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/chat/completions",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(yield* Effect.promise(() => request.json())).toMatchObject({
model: "deepseek-ai/deepseek-v3.2-maas",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
stream_options: { include_usage: true },
})
return input.respond(sseEvents(deltaChunk({ content: "Hello." }), finishChunk("stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("sends Grok requests through Vertex Responses", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexResponses.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
}).model("xai/grok-4.20-reasoning"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/responses",
)
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
expect(yield* Effect.promise(() => request.json())).toMatchObject({
model: "xai/grok-4.20-reasoning",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
store: false,
stream: true,
})
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
{ type: "response.completed", response: { id: "resp_1" } },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* LLMClient.prepare(
LLM.request({
model: GoogleVertexAnthropic.configure({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
+73 -103
View File
@@ -4,10 +4,9 @@ This is the checkable support matrix for CodeMode's confined JavaScript interpre
standard-library surface that programs can use today, plus concrete gaps that may be implemented later.
- `[x]` means the feature is implemented at the scope described here.
- `[ ]` means the feature is unavailable, incomplete, or intentionally divergent as described.
- A checked item does not promise complete ECMAScript edge-case parity. Known differences are listed next to the
supported surface or under [Known semantic gaps](#known-semantic-gaps).
- [Intentional exclusions](#intentional-exclusions) are boundaries, not backlog.
- `[ ]` means a concrete compatibility gap remains.
- Checked items do not promise complete ECMAScript edge-case parity; known differences are stated explicitly.
- Intentional boundaries are not listed as compatibility work.
When behavior changes, update this file and the tests in the same change. The implementation and tests remain the
ultimate source of truth.
@@ -19,42 +18,45 @@ ultimate source of truth.
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments remain subject to their schema and the outbound-handling gap listed below.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
- [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.
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, and URLSearchParams.
- [x] Object literals with shorthand, computed string/number keys, and object spread.
- [x] Object literals with shorthand, computed string/number keys, and spread from plain data objects; `null` and
`undefined` are no-ops, while arrays are rejected.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and values.
- [ ] Symbols.
- [ ] Tagged template literals.
- [ ] Getters and setters in object literals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
## Bindings and destructuring
- [x] `const`, `let`, and accepted `var` declarations.
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
- [x] Nested patterns, defaults, elisions, and rest elements.
- [x] Assignment to identifiers, object fields, array indexes, and writable URL fields.
- [x] Function declarations are hoisted within their interpreted scope.
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
fields.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
- [ ] JavaScript-correct `var` function scope, hoisting, and redeclaration. Accepted `var` currently behaves like a
lexical declaration; prefer `let` or `const`.
- [ ] Complete `let`/`const` temporal-dead-zone and declaration-hoisting semantics.
- [ ] Computed object destructuring keys such as `const { [field]: value } = record`.
- [ ] Object destructuring from arrays, such as `const { length } = values`.
- [ ] Iterable array destructuring from Map, Set, string, or URLSearchParams values.
- [ ] Dynamic property deletion with `delete object[key]`.
- [ ] JavaScript-correct function scoping, hoisting, and redeclaration for accepted `var` declarations.
- [ ] Predeclare `let` and `const` bindings in every lexical scope, including program/block bodies, switch bodies, and
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone.
- [ ] Hoist function declarations accepted directly in switch cases.
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
- [x] Object destructuring from arrays, such as `const { length } = values`.
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
## Statements and control flow
@@ -69,7 +71,6 @@ ultimate source of truth.
- [x] `throw` with arbitrary values.
- [ ] Labeled statements, labeled `break`, and labeled `continue`.
- [ ] `for await...of` and async iteration.
- [ ] `with` and `debugger` statements.
## Functions and callbacks
@@ -90,15 +91,15 @@ ultimate source of truth.
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
- [x] Async string replacement callbacks; replacements are evaluated sequentially.
- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result
coercion.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this`, `super`, user-defined constructor functions, or function prototype methods such as `call`, `apply`,
and `bind`.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] Classes and private fields.
- [ ] Generator functions and `yield`.
- [ ] Async predicates, reducers, and comparators with automatic awaiting. Async mapping can be joined explicitly with
`Promise.all`, but a promise is not a meaningful predicate or sort result.
## Expressions and operators
@@ -108,16 +109,16 @@ ultimate source of truth.
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises; a plain value passes through unchanged, though every `await` still defers its
continuation one reaction turn.
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
- [x] Logical operators: `&&`, `||`, `??`, and `!`, with short-circuiting.
- [x] Unary `+`, unary `-`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Unary `+`, unary `-`, `void`, `typeof`, `instanceof`, and own-property-only `in`.
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [ ] Unary `void` and `delete`.
- [ ] Arbitrary constructors.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length.
## Promises and tools
@@ -152,8 +153,13 @@ ultimate source of truth.
promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are accepted, including
`.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot cross the data
boundary.
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
- [ ] Async iterables, host streams, and stream consumption.
- [ ] Thenable assimilation; objects with a callable `then` field remain plain data.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last definition supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run;
retain null normalization for program results and JSON serialization.
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
## Objects and properties
@@ -164,19 +170,17 @@ ultimate source of truth.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Blocked access to `__proto__`, `constructor`, and `prototype`.
- [ ] `Object.is`; runtime and tool-reference identity semantics need to be defined first.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [x] `Object.is` for supported data values.
- [ ] `Object.groupBy`.
- [ ] Object creation, descriptors, freezing/sealing, prototype APIs, and reflection APIs.
- [ ] A final policy for legal data keys named `__proto__`, `constructor`, or `prototype` (tool path segments
already allow them; see known semantic gaps).
## Arrays
- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments, `Array(n)` creates a
sparse array of that length (invalid lengths throw a `RangeError`). Holes behave like JS in iteration,
spread, join, and JSON; `sort` densifies holes into trailing `undefined`, and results returned to the
host normalize holes to `null`.
- [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments and `Array(n)` creates a sparse
array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like
JavaScript, and host results normalize holes to `null`.
- [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with
`(value, index)` arguments.
- [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`.
@@ -190,8 +194,10 @@ ultimate source of truth.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [ ] `Array.prototype.toSpliced`.
- [ ] Canonical index handling: a key such as `"01"` must not alias index `1`.
- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS.
- [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than
aliasing index `1`.
- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own
`undefined` elements.
## Strings
@@ -204,8 +210,8 @@ ultimate source of truth.
- [x] `localeCompare`; locale and options arguments are currently ignored.
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [ ] Locale/options-aware `localeCompare` and locale formatting APIs.
- [ ] Exact native coercion across every string method; CodeMode often requires explicit strings/numbers.
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
reject instead of coercing.
- [ ] Native no-argument parity for `match()` and `search()`.
## Numbers and Math
@@ -221,20 +227,21 @@ ultimate source of truth.
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
- [ ] Safe interpreter coercion for `++` and `--` rather than host `Number(...)` coercion.
- [ ] Reliable feature detection for unknown static members.
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
`Number(...)` directly.
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
during property access.
- [ ] `Math.sumPrecise`.
- [ ] Global coercing `isFinite` and `isNaN`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify`.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
- [ ] `JSON.parse` reviver callbacks.
- [ ] `JSON.stringify` function/array replacers.
- [ ] Other console methods, timers, counters, groups, and host console access.
## Date
@@ -250,22 +257,22 @@ ultimate source of truth.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [ ] Date setters.
- [ ] `toUTCString`, locale methods, and other Date formatting methods.
- [ ] Exact native constructor coercion, local-time, and loose-equality semantics.
- [ ] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of
being coerced.
- [ ] Native Date loose-equality and default primitive-coercion semantics.
- [ ] Native `RangeError` branding for invalid `toISOString()` calls.
- [ ] Temporal and Intl date/time APIs.
## Regular expressions
- [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Captures, named groups, match indexes, and stateful global matching.
- [x] Integration with supported String methods, including async function replacers.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [ ] Writable `lastIndex`.
- [ ] Exposed metadata for the `d` and `v` flags.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
- [ ] Protection from pathological host-regex backtracking beyond the cooperative execution timeout.
## Map and Set
@@ -276,9 +283,8 @@ ultimate source of truth.
- [x] Materialized `keys`, `values`, and `entries` arrays for Map and Set.
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [ ] Set composition methods such as `union`, `intersection`, `difference`, and relation predicates.
- [ ] WeakMap and WeakSet.
- [ ] Native iterator objects and custom iterators.
- [ ] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
`isSupersetOf`, and `isDisjointFrom`.
## URL and URI helpers
@@ -301,48 +307,12 @@ ultimate source of truth.
an all-rejected `Promise.any`.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization.
- [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types.
- [x] Catchable interpreter failures and awaited tool failures.
- [x] Source locations on unsupported-syntax diagnostics when available.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
shift them.
- [x] Sanitized model-visible diagnostics and explicit safe `ToolError` messages.
- [ ] Distinct public categories for user throws, tool refusal, tool internal failure, invalid returned data, compile
failures, and genuine interpreter defects.
- [ ] Preservation of detailed recoverable failure categories inside `catch` and `Promise.allSettled`.
## Known semantic gaps
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] Canonicalize dotted tool names into namespace paths so every advertised dotted path is executable, one
canonical path can be both a callable tool and a namespace, and the last definition supplied for a canonical
path wins.
- [x] Allow blocked member names (`constructor`, `prototype`, `__proto__`) as tool path segments: segments are Map
keys and inert strings, never plain-object property accesses, so every advertised path is executable. Blocked
member access on data values stays rejected. Tool names with empty segments are rejected at construction.
- [ ] Define safe outbound handling for non-finite numbers and `undefined` so invalid values cannot silently become
`null` in render-only or OpenAPI tool calls.
- [ ] Make regular-expression execution genuinely timeout-safe, or narrow the timeout guarantee explicitly.
- [ ] Complete lexical declaration and destructuring semantics listed above.
- [x] Make callback acceptance consistent across built-ins: collections, sort, string replacers, `Array.from`
mappers, and promise reactions share one acceptance rule.
- [x] Reject every unsupported callable callback argument explicitly rather than silently ignoring it
(`JSON.stringify` replacers and `JSON.parse` revivers fail loudly). The iteration-method `thisArg` is
accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow semantics.
- [ ] Make async callback behavior consistent across built-ins; only string replacers settle async callback results
today.
- [ ] Resolve the built-in correctness gaps listed in the Array, String, Number, Date, and RegExp sections.
- [ ] Make tool search tokenization Unicode-aware.
- [ ] Design explicit tagged representations and size limits before adding binary values or streams.
## Intentional exclusions
These constraints preserve CodeMode's confinement and host-neutral scope. They are not TODO items.
- Ambient filesystem, process, environment, credential, network, or application access.
- `fetch`, timers, crypto, or other host globals unless a future host explicitly supplies a bounded capability.
- Static imports, dynamic imports, modules, npm packages, and module loading.
- `eval`, `Function(...)`, arbitrary host execution, and prototype mutation.
- Generic permission prompts, authorization policy, persistence, replay, or exactly-once side effects.
- Arbitrary method dispatch outside the documented allowlists.
- Automatic parsing of text tool results as JSON.
- Full browser, Node.js, Bun, or ECMAScript runtime compatibility.
- [ ] Distinguish user-thrown failures from interpreter defects and explicit tool refusals from sanitized internal tool
failures; preserve those categories in caught errors, promise rejection handlers, and `Promise.allSettled`
reasons.
+77 -40
View File
@@ -777,9 +777,9 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
@@ -798,38 +798,35 @@ export class Interpreter<R> {
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
consumed.add(String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
mutable,
property,
)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
break
}
yield* self.declarePattern(element, value[index], mutable, pattern)
yield* self.declarePattern(element, items[index], mutable, pattern)
}
return
}
@@ -858,15 +855,15 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
}
const source = value as SafeObject
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -878,36 +875,29 @@ export class Interpreter<R> {
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.assignPattern(getNode(property, "value"), source[key], property)
consumed.add(String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element)
yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element)
break
}
yield* self.assignPattern(element, value[index], pattern)
yield* self.assignPattern(element, items[index], pattern)
}
return
}
@@ -916,6 +906,26 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
const keyNode = getNode(property, "key")
if (getBoolean(property, "computed")) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
switch (node.type) {
case "Literal": {
@@ -1279,6 +1289,7 @@ export class Interpreter<R> {
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
const operator = getString(node, "operator")
const argument = getNode(node, "argument")
if (operator === "delete") return this.evaluateDeleteExpression(argument)
// Undeclared names short-circuit, but declared TDZ bindings must still throw.
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(getString(argument, "name"))) {
return Effect.succeed("undefined")
@@ -1286,6 +1297,7 @@ export class Interpreter<R> {
return Effect.map(this.evaluateExpression(argument), (value) => {
if (operator === "typeof") return typeofValue(value)
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
}
@@ -1729,6 +1741,7 @@ export class Interpreter<R> {
private getMemberReference(
node: AstNode,
operation: "read" | "delete" = "read",
): Effect.Effect<
| MemberReference
| ToolReference
@@ -1875,6 +1888,7 @@ export class Interpreter<R> {
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
if (
key !== "length" &&
!(typeof key === "string" && arrayMethods.has(key)) &&
@@ -1923,6 +1937,29 @@ export class Interpreter<R> {
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
}
private evaluateDeleteExpression(argument: AstNode): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? getNode(argument, "expression") : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", argument)
}
return Effect.map(this.getMemberReference(target, "delete"), (reference) => {
if (reference === OptionalShortCircuit) return true
if (
reference instanceof ComputedValue ||
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference ||
reference.target instanceof CodeModeURL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted in CodeMode.", target, "InvalidDataValue")
}
return Reflect.deleteProperty(reference.target, reference.key)
})
}
// Resolve side-effecting object and key expressions exactly once.
private modifyMember(
node: AstNode,
+6
View File
@@ -1,4 +1,5 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -44,6 +45,11 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values in CodeMode.", node, "InvalidDataValue")
}
return Object.is(args[0], args[1])
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
+145
View File
@@ -99,6 +99,84 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => {
})
})
describe("unary void", () => {
test("evaluates its operand and returns undefined", async () => {
expect(await value(`let count = 0; const result = void (count += 1); return [count, result === undefined]`)).toEqual([
1,
true,
])
})
test("discards opaque values", async () => {
expect(await value(`return void tools === undefined`)).toBe(true)
})
})
describe("property deletion", () => {
test("deletes plain object fields and reports missing fields as successful", async () => {
expect(
await value(`
const object = { keep: 1, remove: 2 }
return [delete object.remove, delete object.missing, object]
`),
).toEqual([true, true, { keep: 1 }])
})
test("evaluates computed object and key expressions once", async () => {
expect(
await value(`
const object = { remove: true }
let objectReads = 0
let keyReads = 0
function getObject() { objectReads++; return object }
function getKey() { keyReads++; return "remove" }
const removed = delete getObject()[getKey()]
return [removed, objectReads, keyReads, Object.hasOwn(object, "remove")]
`),
).toEqual([true, 1, 1, false])
})
test("deleting an array index creates a hole without changing its length", async () => {
expect(await value(`const values = [1, 2, 3]; const removed = delete values[1]; return [removed, values.length, 1 in values, values]`)).toEqual([
true,
3,
false,
[1, null, 3],
])
})
test("array length is not configurable", async () => {
expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2])
})
test("does not broaden unsupported array property assignment", async () => {
expect(
await value(`
const values = []
let rightHandSideRuns = 0
function next() { rightHandSideRuns++; return 1 }
try { values.field = next() } catch {}
return rightHandSideRuns
`),
).toBe(0)
})
test("optional deletion short-circuits without evaluating the key", async () => {
expect(
await value(`let keyReads = 0; const object = null; return [delete object?.[keyReads++], keyReads]`),
).toEqual([true, 0])
})
test("rejects deletion from opaque runtime references", async () => {
expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
})
test("keeps blocked property names unavailable", async () => {
expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
})
})
describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => {
test("guards run instead of the program crashing on a transient NaN", async () => {
expect(await value(`return parseInt("abc") || 0`)).toBe(0)
@@ -470,4 +548,71 @@ describe("destructuring assignment", () => {
test("returns the assigned value", async () => {
expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
})
test("supports computed object keys and evaluates them once", async () => {
expect(
await value(`
let calls = 0
const field = () => { calls++; return "name" }
const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" }
return { calls, name, rest }
`),
).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } })
})
test("supports object patterns over arrays", async () => {
expect(
await value(`
const { 0: first, length, slice, ...rest } = ["a", "b", "c"]
return { first, length, sliced: slice(1), rest }
`),
).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } })
})
test("preserves exact computed property names on arrays", async () => {
expect(
await value(`
const { ["01"]: item, ...rest } = [10, 20]
return { missing: item === undefined, rest }
`),
).toEqual({ missing: true, rest: { 0: 10, 1: 20 } })
})
test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => {
expect(
await value(`
const [letter, ...letters] = "A😀B"
const [[mapKey, mapValue]] = new Map([["key", 1]])
const [setFirst, setSecond] = new Set([2, 3])
const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2")
return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue }
`),
).toEqual({
letter: "A",
letters: ["😀", "B"],
mapKey: "key",
mapValue: 1,
setFirst: 2,
setSecond: 3,
queryKey: "q",
queryValue: "test",
})
})
test("supports iterable patterns in assignment and parameters", async () => {
expect(
await value(`
let first
let rest
;[first, ...rest] = new Set([1, 2, 3])
const read = ([[key, value]]) => key + value
return { first, rest, entry: read(new Map([["a", 4]])) }
`),
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
})
})
+18
View File
@@ -593,6 +593,24 @@ describe("Set", () => {
})
describe("stdlib integration", () => {
test("Object.is uses SameValue semantics", async () => {
expect(
await value(`
const object = {}
return [
Object.is(NaN, NaN),
Object.is(0, -0),
Object.is(object, object),
Object.is({}, {}),
]
`),
).toEqual([true, false, true, false])
})
test("Object.is rejects opaque runtime references", async () => {
expect((await error(`return Object.is(Math.max, Math.max)`)).kind).toBe("InvalidDataValue")
})
test("Object values and entries accept arrays", async () => {
expect(await value(`return [Object.values(["a", "b"]), Object.entries(["a", "b"])]`)).toEqual([
["a", "b"],
+112
View File
@@ -0,0 +1,112 @@
const token = "dummy-wellknown-token"
const port = Number(process.env.PORT ?? 8787)
const config = {
$schema: "https://opencode.ai/config.json",
share: "manual",
model: "example-primary/example-chat",
enabled_providers: ["example-primary", "example-secondary"],
disabled_providers: ["opencode", "anthropic", "openai", "google", "xai", "amazon-bedrock", "azure"],
provider: {
"example-primary": {
name: "Example Primary",
npm: "@ai-sdk/openai-compatible",
whitelist: ["example-chat", "example-code"],
options: {
baseURL: "https://models.example.com/v1",
apiKey: "{env:TOKEN}",
},
models: {
"example-chat": {
name: "Example Chat",
reasoning: true,
tool_call: true,
attachment: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 128000,
output: 16000,
},
},
"example-code": {
name: "Example Code",
reasoning: true,
tool_call: true,
limit: {
context: 200000,
output: 32000,
},
},
},
},
"example-secondary": {
name: "Example Secondary",
npm: "@ai-sdk/openai-compatible",
whitelist: ["example-fast"],
options: {
baseURL: "https://inference.example.org/v1",
apiKey: "{env:TOKEN}",
},
models: {
"example-fast": {
name: "Example Fast",
tool_call: true,
limit: {
context: 64000,
output: 8000,
},
},
},
},
},
mcp: {
"example-tools": {
type: "remote",
url: "https://tools.example.net/mcp",
enabled: false,
},
},
permission: {
bash: "ask",
edit: "ask",
webfetch: "ask",
read: {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
},
},
}
const server = Bun.serve({
hostname: "127.0.0.1",
port,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/.well-known/opencode") {
return Response.json({
auth: {
command: ["bun", "-e", `await Bun.sleep(5000); process.stdout.write(${JSON.stringify(token)})`],
env: "TOKEN",
},
remote_config: {
url: `${url.origin}/config/opencode.json`,
headers: { authorization: "Bearer {env:TOKEN}" },
},
})
}
if (url.pathname === "/config/opencode.json") {
if (request.headers.get("authorization") !== `Bearer ${token}`) {
return new Response("Unauthorized", { status: 401 })
}
return Response.json(config)
}
return new Response("Not found", { status: 404 })
},
})
console.log(`Well-known fixture listening at ${server.url.origin}`)
console.log(`Test with: bun run dev auth connect ${server.url.origin}`)
+34 -8
View File
@@ -2,8 +2,9 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
@@ -18,6 +19,7 @@ import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp"
@@ -109,6 +111,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
description: "Ordered plugin enablement directives and external package declarations",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
@@ -163,6 +166,7 @@ const layer = Layer.effect(
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
@@ -324,12 +328,17 @@ const layer = Layer.effect(
}
})
const reload = Effect.fn("Config.reload")(function* () {
const next = yield* discover()
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
@@ -352,12 +361,29 @@ const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown.refresh().pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
+14
View File
@@ -0,0 +1,14 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { ConfigPolicy } from "./policy"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+35
View File
@@ -0,0 +1,35 @@
export * as ConfigPolicyPlugin from "./policy"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Config.Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+13
View File
@@ -0,0 +1,13 @@
export * as ConfigPolicy from "./policy"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny"])
export type Effect = typeof Effect.Type
export const Info = Schema.Struct({
action: Schema.Literal("provider.use"),
resource: Schema.String,
effect: Effect,
})
export type Info = typeof Info.Type
+6 -4
View File
@@ -27,18 +27,19 @@ import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { ReferenceInstructions } from "./reference/instructions"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { SkillInstructions } from "./skill/instructions"
import { Snapshot } from "./snapshot"
import { InstructionDiscovery } from "./instruction-discovery"
import { InstructionBuiltIns } from "./instructions/builtins"
import { InstructionEntry } from "./session/instruction-entry"
import { SessionInstructions } from "./session/instructions"
import { SessionGenerateNode } from "./session/generate-node"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
@@ -75,12 +76,13 @@ const locationServiceNodes = [
ToolRegistry.node,
ToolRegistry.toolsNode,
Image.node,
SkillGuidance.node,
ReferenceGuidance.node,
SkillInstructions.node,
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
QuestionV2.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
@@ -1,4 +1,4 @@
export * as McpGuidance from "./guidance"
export * as McpInstructions from "./instructions"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
@@ -54,7 +54,7 @@ export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpInstructions") {}
export const layer = Layer.effect(
Service,
@@ -62,7 +62,7 @@ export const layer = Layer.effect(
const mcp = yield* MCP.Service
return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) {
load: Effect.fn("McpInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
+2
View File
@@ -10,6 +10,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
@@ -151,6 +152,7 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
@@ -1,4 +1,4 @@
export * as ReferenceGuidance from "./guidance"
export * as ReferenceInstructions from "./instructions"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
@@ -57,7 +57,7 @@ export interface Interface {
readonly load: () => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceInstructions") {}
const layer = Layer.effect(
Service,
@@ -65,7 +65,7 @@ const layer = Layer.effect(
const references = yield* Reference.Service
return Service.of({
load: Effect.fn("ReferenceGuidance.load")(function* () {
load: Effect.fn("ReferenceInstructions.load")(function* () {
const available = (yield* references.list())
.filter((reference) => reference.description !== undefined)
.map((reference) => ({
+51 -7
View File
@@ -33,10 +33,12 @@ import { LocationServiceMap } from "./location-service-map"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionPending } from "./session/pending"
import { SessionGenerate } from "./session/generate"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "./fs-util"
import { Image } from "./image"
import { Mime } from "./mime"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill"
@@ -175,6 +177,7 @@ export type Error =
| CommandV2.NotFoundError
| CommandV2.EvaluationError
| MessageNotFoundError
| SessionGenerate.Error
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -243,6 +246,11 @@ export interface Interface {
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -531,9 +539,13 @@ const layer = Layer.effect(
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
const prompt = yield* resolvePrompt({ text: input.text, files: input.files, agents: input.agents }).pipe(
Effect.provideService(FSUtil.Service, fs),
)
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents },
image,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionPending.Message.make({
type: "user",
@@ -564,6 +576,11 @@ const layer = Layer.effect(
}),
),
),
generate: Effect.fn("V2Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("V2Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -859,10 +876,13 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
}
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (
input: PromptInput.Prompt,
image: Effect.Effect<Image.Interface>,
) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file, image), { concurrency: 8 })
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
@@ -872,6 +892,7 @@ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
image: Effect.Effect<Image.Interface>,
) {
const resolved = input.uri.startsWith("data:")
? {
@@ -900,9 +921,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
.join("\n"),
)
: resolved.bytes
return FileAttachment.create({
data: Base64.make(Buffer.from(content).toString("base64")),
const normalized = yield* normalizeImageAttachment(
input,
Base64.make(Buffer.from(content).toString("base64")),
mime,
image,
)
return FileAttachment.create({
data: normalized.data,
mime: normalized.mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
@@ -910,6 +937,23 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
})
})
const normalizeImageAttachment = Effect.fn("V2Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: Base64,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data, mime }
const service = yield* image
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* service.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
Effect.mapError((error) => new AttachmentError({ uri: label, message: error.message })),
)
return { data: Base64.make(normalized.content), mime: normalized.mime }
})
const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
+20 -14
View File
@@ -8,10 +8,10 @@ import { InstructionDiscovery } from "../instruction-discovery"
import { Instructions } from "../instructions/index"
import { InstructionBuiltIns } from "../instructions/builtins"
import { Location } from "../location"
import { McpGuidance } from "../mcp/guidance"
import { McpInstructions } from "../mcp/instructions"
import { PluginSupervisor } from "../plugin/supervisor"
import { ReferenceGuidance } from "../reference/guidance"
import { SkillGuidance } from "../skill/guidance"
import { ReferenceInstructions } from "../reference/instructions"
import { SkillInstructions } from "../skill/instructions"
import { AgentNotFoundError } from "./error"
import { SessionHistory } from "./history"
import { InstructionEntry } from "./instruction-entry"
@@ -34,13 +34,19 @@ export interface Loaded {
readonly messages: ReadonlyArray<SessionMessage.Info>
}
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, and instruction sources; `load` adds the model and active history for
* that selection. This module does not build or execute the model request.
*/
export interface Interface {
/** Resolves the Session, selected agent, and current instruction sources before durable Step preparation. */
/** Selects the Session, agent, and instruction sources used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Loads the selected model and active history after instruction sync and pending-input promotion. */
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
}
/** Location-scoped model-context loader for durable Session Steps. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContext") {}
const layer = Layer.effect(
@@ -52,11 +58,11 @@ const layer = Layer.effect(
const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service
const location = yield* Location.Service
const mcpGuidance = yield* McpGuidance.Service
const mcpInstructions = yield* McpInstructions.Service
const models = yield* SessionRunnerModel.Service
const plugins = yield* PluginSupervisor.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
@@ -72,9 +78,9 @@ const layer = Layer.effect(
[
builtins.load(sessionID),
discovery.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
mcpGuidance.load(agent),
skillInstructions.load(agent),
referenceInstructions.load(),
mcpInstructions.load(agent),
entries.load(sessionID),
],
{ concurrency: "unbounded" },
@@ -108,11 +114,11 @@ export const node = makeLocationNode({
InstructionDiscovery.node,
InstructionEntry.node,
Location.node,
McpGuidance.node,
McpInstructions.node,
PluginSupervisor.node,
ReferenceGuidance.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionStore.node,
SkillGuidance.node,
SkillInstructions.node,
],
})
@@ -0,0 +1,69 @@
export * as SessionGenerateNode from "./generate-node"
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionContext } from "./context"
import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const layer = Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: {},
})
return (yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: [],
toolChoice: "none",
}),
)).text
}),
})
}),
)
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
+21
View File
@@ -0,0 +1,21 @@
export * as SessionGenerate from "./generate"
import type { LLMError } from "@opencode-ai/ai"
import { Context, type Effect } from "effect"
import type { Instructions } from "../instructions"
import type { AgentNotFoundError } from "./error"
import type { SessionRunnerModel } from "./runner/model"
import type { SessionSchema } from "./schema"
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError
export interface Interface {
/** Generates text from current Session context without mutating the Session. */
readonly generate: (input: {
readonly sessionID: SessionSchema.ID
readonly prompt: string
}) => Effect.Effect<string, Error>
}
/** Location-scoped transient generation from Session context. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
+36 -8
View File
@@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
),
)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
decodeMessageRow,
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -75,10 +79,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
return yield* db
.transaction(() =>
Effect.gen(function* () {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
const messages = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
const messages = yield* messageEntries(db, sessionID)
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
return {
initial: assembled.initial,
@@ -89,6 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
.pipe(Effect.orDie)
})
export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
)
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
return {
initial: assembled.initial,
messages: entries.map((entry) => entry.message),
instructionUpdate: assembled.update,
}
}),
)
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
+121 -35
View File
@@ -15,27 +15,52 @@ type DatabaseService = Database.Interface["db"]
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
export interface Observation extends Instructions.Admission {
readonly sessionID: SessionSchema.ID
readonly initial: boolean
readonly current: Instructions.Values
}
export const observe = Effect.fn("InstructionState.observe")(function* (
db: DatabaseService,
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const result = yield* observeAgainst(observed, stored?.current_values)
return {
sessionID,
initial: !stored,
...result,
}
})
export const commit = Effect.fn("InstructionState.commit")(function* (
db: DatabaseService,
events: EventV2.Interface,
observation: Observation,
) {
if (!observation.initial && Object.keys(observation.delta).length === 0) return
yield* events.publish(
SessionEvent.InstructionsUpdated,
{ sessionID: observation.sessionID, delta: observation.delta },
{
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
commit: () => insertBlobs(db, observation.blobs),
},
)
})
export const prepare = Effect.fn("InstructionState.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
) {
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const admission = yield* Instructions.diff(observed, stored?.current_values)
if (!stored || Object.keys(admission.delta).length > 0) {
yield* events.publish(
SessionEvent.InstructionsUpdated,
{ sessionID, delta: admission.delta },
{
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
...(!stored ? { metadata: { instructions: { initial: true } } } : {}),
commit: () => insertBlobs(db, admission.blobs),
},
)
}
yield* commit(db, events, yield* observe(db, instructions, sessionID))
})
export const apply = Effect.fn("InstructionState.apply")(function* (
@@ -97,28 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const folded = fold(yield* instructionEvents(db, sessionID))
if (!folded) {
const state = yield* stateFromEvents(db, sessionID)
if (!state) {
yield* reset(db, sessionID)
return undefined
}
const state = {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
yield* db
.insert(InstructionStateTable)
.values(state)
.onConflictDoUpdate({
target: InstructionStateTable.session_id,
set: {
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
epoch_start: state.epoch_start,
through_seq: state.through_seq,
initial_values: state.initial_values,
current_values: state.current_values,
},
})
.run()
@@ -126,13 +144,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
return state
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
const assembleState = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
state: typeof InstructionStateTable.$inferSelect,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
const updates = rows.map((row) => ({
row,
@@ -162,7 +179,49 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* (
})
values = Instructions.applyDelta(values, delta)
}
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const assembled = yield* assembleState(db, sessionID, instructions, state)
return { initial: assembled.initial, updates: assembled.updates }
})
export const preview = Effect.fn("InstructionState.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
observed: Instructions.ReadResult,
) {
const state = yield* readState(db, sessionID)
const result = yield* observeAgainst(observed, state?.current_values)
const blobs = new Map<Instructions.Hash, Schema.Json>(
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
)
if (!state) {
const values = dereference(result.current, blobs)
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
}
const assembled = yield* assembleState(db, sessionID, instructions, state)
return {
initial: assembled.initial,
updates: assembled.updates,
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
}
})
const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) {
const admission = yield* Instructions.diff(observed, previous)
return {
current: Instructions.applyHashDelta(previous ?? {}, admission.delta),
...admission,
}
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -177,7 +236,26 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* rebuild(db, sessionID)
const latest = yield* db
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* stateFromEvents(db, sessionID)
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* stateFromEvents(db, sessionID)
})
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const folded = fold(yield* instructionEvents(db, sessionID))
return folded ? foldedState(sessionID, folded) : undefined
})
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
@@ -185,8 +263,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess
.limit(1)
.get()
.pipe(Effect.orDie)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
@@ -338,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
}, undefined)
}
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
return {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
}
+119
View File
@@ -0,0 +1,119 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
type ToolCallResolution =
| { readonly type: "reject"; readonly error: SessionError.Error }
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
interface Prepared {
readonly request: LLMRequest
readonly resolveToolCall: (name: string) => ToolCallResolution
}
interface PrepareInput {
readonly context: SessionContext.Loaded
readonly step: number
}
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
* Session state.
*/
export interface Interface {
/** Builds one outbound model request and its matching tool-call capability. */
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelRequest") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const registry = yield* ToolRegistry.Service
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.context.session
const agent = input.context.agent
const resolved = input.context.model
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = executableTools?.definitions ?? []
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system,
messages,
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
: []
})
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session),
},
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const resolveToolCall = (name: string): ToolCallResolution => {
if (!executableTools)
return {
type: "reject",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
}
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
return {
type: "reject",
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
}
return { type: "settle", settle: executableTools.settle }
}
return {
request,
resolveToolCall,
}
})
return Service.of({ prepare })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, ToolRegistry.node],
})
+15 -88
View File
@@ -1,16 +1,6 @@
export * as SessionRunnerLLM from "./llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/ai"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -19,30 +9,25 @@ import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state"
import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import PROMPT_DEFAULT from "./prompt/base.txt"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { PluginHooks } from "../../plugin/hooks"
import { SessionModelHeaders } from "../model-headers"
type StepTokens = {
readonly input: number
@@ -68,20 +53,14 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
)
}
/**
* Runs one durable coding-agent Session until it settles. Each step reloads projected history,
* materializes tools, makes one model request, and settles local calls before continuation.
*/
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -146,59 +125,18 @@ const layer = Layer.effect(
const loaded = yield* context.load(selected)
const session = loaded.session
const agent = loaded.agent
const agentInfo = agent.info
const resolved = loaded.model
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const compactionInput = { session, messages: loaded.messages, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error })
}
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session),
},
providerOptions: { openai: { promptCacheKey } },
system: [agentInfo.system ? agentInfo.system : PROMPT_DEFAULT, loaded.initial]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(loaded.messages, resolved.ref, providerMetadataKey),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
const prepared = yield* modelRequests.prepare({
context: loaded,
step: currentStep,
})
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const contextEvent: SessionHooks["context"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft but cannot advertise tools excluded by
// permissions, registration state, or the selected agent's step limit.
yield* hooks.trigger("session", "context", contextEvent)
const hookedRequest = LLM.updateRequest(request, {
system: contextEvent.system,
messages: contextEvent.messages,
tools: Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
@@ -209,7 +147,7 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
providerMetadataKey,
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
snapshot: startSnapshot,
assistantMessageID,
})
@@ -219,7 +157,7 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -231,15 +169,9 @@ const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: toolMaterialization
? `Tool is not available for this request: ${event.name}`
: "Tools are disabled after the maximum agent steps",
}),
)
const tool = prepared.resolveToolCall(event.name)
if (tool.type === "reject") {
yield* serialized(publisher.failUnsettledTools(tool.error))
return
}
needsContinuation = true
@@ -247,7 +179,7 @@ const layer = Layer.effect(
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
tool.settle({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@@ -337,11 +269,7 @@ const layer = Layer.effect(
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() &&
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
) {
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
@@ -457,7 +385,7 @@ const layer = Layer.effect(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step + 1
currentStep = error.step
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
@@ -571,9 +499,8 @@ export const node = makeLocationNode({
deps: [
EventV2.node,
llmClient,
ToolRegistry.node,
PluginHooks.node,
SessionContext.node,
SessionModelRequest.node,
SessionStore.node,
SessionCompaction.node,
SessionTitle.node,
@@ -1,4 +1,4 @@
export * as SkillGuidance from "./guidance"
export * as SkillInstructions from "./instructions"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
@@ -60,7 +60,7 @@ export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillGuidance") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillInstructions") {}
const layer = Layer.effect(
Service,
@@ -68,7 +68,7 @@ const layer = Layer.effect(
const skills = yield* SkillV2.Service
return Service.of({
load: Effect.fn("SkillGuidance.load")(function* (selection) {
load: Effect.fn("SkillInstructions.load")(function* (selection) {
const agent = selection.info
if (!agent) return Instructions.empty
const permitted = SkillV2.available(yield* skills.list(), agent)
+46 -10
View File
@@ -1,14 +1,14 @@
export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms may perform Effects before returning.
* plugin/config code. Transforms synchronously rebuild derived state.
*/
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
type TransformCallback<DraftApi> = (draft: DraftApi) => void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
export interface Registration {
@@ -34,6 +34,7 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
@@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
@@ -82,9 +87,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Effect.suspend(() => {
const result = transform(draft)
return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void
Effect.sync(() => {
transform(draft)
})
const materialize = Effect.fnUntraced(function* () {
@@ -97,7 +101,39 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
yield* commit(next)
})
const reload = () => semaphore.withPermit(materialize())
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const reload = Effect.fnUntraced(function* () {
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
return yield* Deferred.await(done)
})
const result: Interface<State, DraftApi> = {
get: () => state,
@@ -117,7 +153,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
batch.reloads.add(reload)
batch.reloads.add(materializeReload)
return
}
yield* materialize()
@@ -132,8 +168,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(reload)
else yield* reload()
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
+8 -11
View File
@@ -6,7 +6,6 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@@ -35,7 +34,6 @@ export const Plugin = {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
@@ -50,6 +48,12 @@ export const Plugin = {
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
output: Output,
structured: Schema.toEncoded(Output),
// Image base64 reaches the model through content items (normalized generically
// at tool settlement); persisting a second copy in structured would store the
// original unresized bytes in the message row.
toStructuredOutput: ({ output }) =>
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
@@ -117,21 +121,14 @@ export const Plugin = {
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image
.normalize(resource, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
}
if ("encoding" in content && content.encoding === "base64")
if ("encoding" in content && content.encoding === "base64" && !SUPPORTED_IMAGE_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
error instanceof ReadToolFileSystem.MediaIngestLimitError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
+45 -8
View File
@@ -3,6 +3,7 @@ export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope } from "effect"
import type { AgentV2 } from "../agent"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
@@ -57,6 +58,40 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
const image = yield* Image.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
// RFC 2397 permits parameters between the mime and ";base64".
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
if (base64 === undefined) return Effect.succeed(item)
const resource = item.name ?? `${item.mime} tool output`
return image
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
.pipe(
Effect.map((result) => ({
...item,
uri: `data:${result.mime};base64,${result.content}`,
mime: result.mime,
})),
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
)
})
const note = (reason: "decode" | "size", text: string) => {
const count = normalized.filter((item) => item === reason).length
if (count === 0) return []
return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }]
}
return [
...normalized.filter((item) => typeof item !== "string"),
...note("decode", "could not be decoded."),
...note("size", "could not be resized below the image size limit."),
]
})
type Registration = {
readonly tool: AnyTool
readonly name: string
@@ -84,10 +119,11 @@ const registryLayer = Layer.effect(
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) =>
input.progress?.({
structured: update.structured,
content: (update.content ?? []).map((part) =>
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
@@ -97,7 +133,8 @@ const registryLayer = Layer.effect(
name: part.name,
},
),
}) ?? Effect.void,
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
},
).pipe(
Effect.map((output) => ({ output })),
@@ -115,7 +152,7 @@ const registryLayer = Layer.effect(
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
output: pending.output,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
@@ -232,11 +269,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
+19 -1
View File
@@ -17,6 +17,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const PROGRESS_LINES = 25
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
@@ -210,6 +211,23 @@ export const Plugin = {
}
})
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
const trailingNewline = page.output.endsWith("\n")
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
const truncated = start > 0 || lines.length > PROGRESS_LINES
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
const notice = truncated
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
: ""
return {
output: `${notice}${output || "(no output)"}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
@@ -258,7 +276,7 @@ export const Plugin = {
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
captureProgress().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
+19
View File
@@ -4,6 +4,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Config } from "../config"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { SessionSchema } from "../session/schema"
@@ -43,6 +44,7 @@ export const Plugin = {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
const scope = yield* Scope.Scope
@@ -123,6 +125,23 @@ export const Plugin = {
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
let current = parent
let depth = 0
while (current.parentID) {
depth++
current = yield* runtime.session
.get(current.parentID)
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }),
),
)
}
const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1
if (depth >= limit)
return yield* new ToolFailure({
message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`,
})
const agent = yield* agents.resolve(input.agent)
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
if (agent.mode === "primary")
+3
View File
@@ -175,6 +175,9 @@ export const Info = Schema.Struct({
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
subagent_depth: Schema.optional(NonNegativeInt).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
+26
View File
@@ -77,6 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
@@ -84,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
? []
: [
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
...info.enabled_providers.map((resource) => ({
action: "provider.use" as const,
resource,
effect: "allow" as const,
})),
]),
...(info.disabled_providers ?? []).map((resource) => ({
action: "provider.use" as const,
resource,
effect: "deny" as const,
})),
]
if (info.experimental?.subagent_depth === undefined && !policies.length) return
return {
subagent_depth: info.experimental?.subagent_depth,
policies: policies.length ? policies : undefined,
}
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
+35 -7
View File
@@ -1,10 +1,12 @@
export * as WellKnown from "./wellknown"
import { Integration } from "@opencode-ai/schema/integration"
import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { isDeepStrictEqual } from "node:util"
import { makeGlobalNode } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { EventV2 } from "./event"
import { KV } from "./kv"
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
@@ -43,14 +45,18 @@ export interface Entry {
export interface Interface {
readonly entries: () => Effect.Effect<readonly Entry[], Error>
readonly snapshot: () => readonly Entry[]
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
readonly changes: Stream.Stream<void>
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
export const Event = {
Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }),
}
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
@@ -97,8 +103,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const kv = yield* KV.Service
const events = yield* EventV2.Service
const cache = yield* Ref.make(new Map<string, Entry>())
const changes = yield* PubSub.unbounded<void>()
const lock = Semaphore.makeUnsafe(1)
const load = Effect.fn("WellKnown.load")(function* () {
@@ -117,9 +123,32 @@ const layer = Layer.effect(
return entries
})
const refresh = Effect.fn("WellKnown.refresh")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, (origin) =>
inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
),
)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
yield* Ref.set(cache, next)
yield* events.publish(Event.Updated, {})
return true
}),
)
})
return Service.of({
entries: load,
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
refresh,
add: Effect.fn("WellKnown.add")(function* (value) {
return yield* lock.withPermit(
Effect.gen(function* () {
@@ -131,7 +160,7 @@ const layer = Layer.effect(
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
return entry
}),
)
@@ -151,11 +180,10 @@ const layer = Layer.effect(
next.delete(origin)
return next
})
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
}),
)
}),
changes: Stream.fromPubSub(changes),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
}),
@@ -163,4 +191,4 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] })
+3 -1
View File
@@ -2,11 +2,13 @@ export * as WellKnownPlugin from "./plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { WellKnown } from "../wellknown"
export const Plugin = define({
id: "opencode.wellknown",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const wellknown = yield* WellKnown.Service
yield* wellknown.entries().pipe(Effect.orDie)
yield* ctx.integration.transform((draft) => {
@@ -26,7 +28,7 @@ export const Plugin = define({
})
})
})
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() => ctx.integration.reload()),
Effect.forkScoped({ startImmediately: true }),
)
+4 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -76,7 +77,9 @@ describe("AgentV2", () => {
)
description = "New description"
hidden = false
yield* agent.reload()
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -261,7 +262,9 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.default())?.id).toBe(old)
configured = false
yield* catalog.reload()
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
}),
)
+27 -2
View File
@@ -53,9 +53,9 @@ const emptyWellknownNode = makeGlobalNode({
WellKnown.Service.of({
entries: () => Effect.succeed([]),
snapshot: () => [],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: () => Effect.die("unused Wellknown.resolve"),
}),
),
@@ -216,9 +216,9 @@ describe("Config", () => {
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
}),
),
@@ -283,6 +283,31 @@ describe("Config", () => {
}),
)
it.effect("migrates the v1 experimental subagent depth", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Config.Document({
type: "document",
info: decode({
experimental: {
policies: items.map((item) => ({ action: "provider.use", ...item })),
},
}),
})
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
)
})
describe("ConfigPolicyPlugin.Plugin", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.provider.update(ProviderV2.ID.anthropic, () => {})
catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {})
})
yield* addPlugin(() => [
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
{ effect: "allow", resource: "company-*" },
),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined()
expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined()
}),
)
it.effect("prevents project policy from overriding user-global policy", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => [
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
}),
)
it.live("reloads changed policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => entries)
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
entries = [policies({ effect: "allow", resource: "openai" })]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
)
}),
)
})
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for policy reload")
})
@@ -0,0 +1,425 @@
import { describe, expect } from "bun:test"
import { and, asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
const source = (name: string, read: Effect.Effect<string | Instructions.Unavailable | Instructions.Removed>) =>
Instructions.make({
key: Instructions.Key.make(name),
codec: Schema.toCodecJson(Schema.String),
read,
render: {
initial: String,
changed: (_previous, current) => current,
removed: (previous) => `Removed ${previous}`,
},
})
const setup = (sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "instruction-state-test",
directory: "/project",
title: "Instruction state test",
version: "test",
})
.run()
.pipe(Effect.orDie)
return { db, events: yield* EventV2.Service }
})
const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, "session.instructions.updated.2")))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) =>
Instructions.read(instructions).pipe(
Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
)
describe("InstructionState", () => {
it.effect("observes each source once without publishing events or inserting blobs", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_observe")
const { db, events } = yield* setup(sessionID)
const reads = { first: 0, second: 0 }
const instructions = Instructions.combine([
source(
"test/first",
Effect.sync(() => {
reads.first++
return "first"
}),
),
source(
"test/second",
Effect.sync(() => {
reads.second++
return "second"
}),
),
])
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === "session.instructions.updated") published.push(event)
}),
)
const observation = yield* InstructionState.observe(db, instructions, sessionID)
yield* unsubscribe
expect(reads).toEqual({ first: 1, second: 1 })
expect(observation).toEqual({
sessionID,
initial: true,
current: {
"test/first": Instructions.hash("first"),
"test/second": Instructions.hash("second"),
},
delta: {
"test/first": Instructions.hash("first"),
"test/second": Instructions.hash("second"),
},
blobs: {
[Instructions.hash("first")]: "first",
[Instructions.hash("second")]: "second",
},
})
expect(published).toEqual([])
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
}),
)
it.effect("commits initial metadata and changed and removed deltas without rereading sources", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_commit")
const { db, events } = yield* setup(sessionID)
let current = "initial"
let retired: string | Instructions.Removed = "retired"
let reads = 0
const instructions = Instructions.combine([
source(
"test/current",
Effect.sync(() => {
reads++
return current
}),
),
source(
"test/retired",
Effect.sync(() => {
reads++
return retired
}),
),
])
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === "session.instructions.updated") published.push(event)
}),
)
const initial = yield* InstructionState.observe(db, instructions, sessionID)
expect(reads).toBe(2)
yield* InstructionState.commit(db, events, initial)
expect(reads).toBe(2)
current = "changed"
retired = Instructions.removed
const changed = yield* InstructionState.observe(db, instructions, sessionID)
expect(reads).toBe(4)
expect(changed).toMatchObject({
sessionID,
initial: false,
current: { "test/current": Instructions.hash("changed") },
delta: {
"test/current": Instructions.hash("changed"),
"test/retired": "removed",
},
blobs: { [Instructions.hash("changed")]: "changed" },
})
yield* InstructionState.commit(db, events, changed)
expect(reads).toBe(4)
yield* unsubscribe
expect(published).toHaveLength(2)
expect(published[0]?.metadata).toEqual({ instructions: { initial: true } })
expect(published[1]?.metadata).toBeUndefined()
expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.delta)).toEqual([
{
"test/current": Instructions.hash("initial"),
"test/retired": Instructions.hash("retired"),
},
{
"test/current": Instructions.hash("changed"),
"test/retired": "removed",
},
])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
initial_values: {
"test/current": Instructions.hash("initial"),
"test/retired": Instructions.hash("retired"),
},
current_values: { "test/current": Instructions.hash("changed") },
})
expect(
Object.fromEntries(
(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).map((row) => [row.hash, row.value]),
),
).toEqual({
[Instructions.hash("initial")]: "initial",
[Instructions.hash("retired")]: "retired",
[Instructions.hash("changed")]: "changed",
})
}),
)
it.effect("keeps no-op observations free of events and blobs", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_noop")
const { db, events } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed("unchanged"))
yield* InstructionState.prepare(db, events, instructions, sessionID)
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const observation = yield* InstructionState.observe(db, instructions, sessionID)
expect(observation).toEqual({
sessionID,
initial: false,
current: { "test/context": Instructions.hash("unchanged") },
delta: {},
blobs: {},
})
yield* InstructionState.commit(db, events, observation)
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
}),
)
it.effect("assembles a fresh private update without repairing a missing cache", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.delete(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Changed context"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(
yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("reads through a stale cache without repairing it", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = "Committed update"
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.update(InstructionStateTable)
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Private update"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled.initial).toBe("Initial context")
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
expect(assembled.update).toBe("Private update")
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("assembles initial instructions without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed("Initial context"))
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Initial context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("retains a committed value when fresh instructions are unavailable", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
const { db, events } = yield* setup(sessionID)
let value: string | Instructions.Unavailable = "Committed context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = Instructions.unavailable
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Committed context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(error.keys).toEqual([Instructions.Key.make("test/context")])
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("keeps prepare equivalent to observe followed by commit", () =>
Effect.gen(function* () {
const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
const preparedSessionID = SessionSchema.ID.make("ses_instruction_prepared")
const { db, events } = yield* setup(observedSessionID)
yield* setup(preparedSessionID)
let value: string | Instructions.Removed = "initial"
let observedReads = 0
let preparedReads = 0
const observedInstructions = source(
"test/context",
Effect.sync(() => {
observedReads++
return value
}),
)
const preparedInstructions = source(
"test/context",
Effect.sync(() => {
preparedReads++
return value
}),
)
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
value = next
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
)
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
}
expect(observedReads).toBe(4)
expect(preparedReads).toBe(4)
expect((yield* instructionEvents(db, observedSessionID)).map((event) => event.data.delta)).toEqual(
(yield* instructionEvents(db, preparedSessionID)).map((event) => event.data.delta),
)
const states = yield* db.select().from(InstructionStateTable).orderBy(asc(InstructionStateTable.session_id)).all()
expect(states).toHaveLength(2)
expect(
states.map((state) => ({
epoch_start: state.epoch_start,
through_seq: state.through_seq,
initial_values: state.initial_values,
current_values: state.current_values,
})),
).toEqual([
{
epoch_start: 0,
through_seq: 2,
initial_values: { "test/context": Instructions.hash("initial") },
current_values: {},
},
{
epoch_start: 0,
through_seq: 2,
initial_values: { "test/context": Instructions.hash("initial") },
current_values: {},
},
])
}),
)
})
+7
View File
@@ -0,0 +1,7 @@
import { Image } from "@opencode-ai/core/image"
import { Effect, Layer } from "effect"
/** Passthrough resizer for tests that build ToolRegistry.node without a Location. */
export const imagePassthrough = Layer.mock(Image.Service, {
normalize: (_resource, content) => Effect.succeed(content),
})
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const build = AgentV2.ID.make("build")
const selection = (permissions: PermissionV2.Ruleset = []) => {
const info = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions })
return { id: info.id, info }
}
const instructions = (server: string, text: string) =>
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
[
MCP.node,
Layer.mock(MCP.Service, {
instructions: () => Effect.succeed(catalog()),
tools: () => Effect.succeed(tools()),
}),
],
])
describe("McpInstructions", () => {
it.effect("renders instructions for servers with at least one permitted tool", () =>
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(
selection([
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
]),
)
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe(
[
"<mcp_instructions>",
' <server name="alpha">',
' Use tools from this server through `execute` under `tools["alpha"]`.',
" Alpha line one",
" Alpha line two",
" </server>",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
"</mcp_instructions>",
].join("\n"),
)
}).pipe(
Effect.provide(
layer(
() => [
instructions("beta", "Beta instructions"),
instructions("unused", "No tools"),
instructions("hidden", "Denied tool"),
instructions("alpha", "Alpha line one\nAlpha line two"),
],
() => [tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")],
),
),
),
)
it.effect("omits instructions when the agent cannot use execute", () =>
Effect.gen(function* () {
const service = yield* McpInstructions.Service
const generation = yield* service
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
.pipe(Effect.flatMap(readInitial))
expect(generation.text).toBe("")
}).pipe(
Effect.provide(
layer(
() => [instructions("alpha", "Alpha instructions")],
() => [tool("alpha")],
),
),
),
)
it.effect("renders additions, changes, and removal", () => {
let catalog = [instructions("alpha", "Alpha instructions")]
const tools = [tool("alpha"), tool("beta")]
return Effect.gen(function* () {
const service = yield* McpInstructions.Service
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
const added = yield* readUpdate(yield* service.load(selection()), initialized)
expect(added.text).toBe(
[
"New MCP server instructions are available in addition to those previously listed:",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
].join("\n"),
)
catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
const changed = yield* readUpdate(yield* service.load(selection()), added)
expect(changed.text).toBe(
[
"The available MCP server instructions have changed. This list supersedes the previous one.",
"<mcp_instructions>",
' <server name="alpha">',
' Use tools from this server through `execute` under `tools["alpha"]`.',
" Updated alpha",
" </server>",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
"</mcp_instructions>",
].join("\n"),
)
catalog = [instructions("beta", "Beta instructions")]
const removed = yield* readUpdate(yield* service.load(selection()), changed)
expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
catalog = []
expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe(
"MCP server instructions are no longer available.",
)
}).pipe(
Effect.provide(
layer(
() => catalog,
() => tools,
),
),
)
})
})
+3
View File
@@ -29,7 +29,9 @@ import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -250,6 +252,7 @@ const it = testEffect(
[PermissionV2.node, permissions],
[EventV2.node, events],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
@@ -3,18 +3,18 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { it } from "./lib/effect"
import { readInitial, readUpdate } from "./lib/instructions"
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
const instructionsLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]])
describe("ReferenceGuidance", () => {
describe("ReferenceInstructions", () => {
it.effect("lists available references in the instructions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* readInitial(yield* guidance.load())
const instructions = yield* ReferenceInstructions.Service
const generation = yield* readInitial(yield* instructions.load())
expect(generation.text).toContain("<available_references>")
expect(generation.text).toContain("<name>docs</name>")
@@ -22,7 +22,7 @@ describe("ReferenceGuidance", () => {
expect(generation.text).toContain("<description>Use for product documentation</description>")
}).pipe(
Effect.provide(
guidanceLayer(
instructionsLayer(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
@@ -43,22 +43,22 @@ describe("ReferenceGuidance", () => {
),
)
it.effect("omits guidance when no references are available", () =>
it.effect("omits instructions when no references are available", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* readInitial(yield* guidance.load())
const instructions = yield* ReferenceInstructions.Service
const generation = yield* readInitial(yield* instructions.load())
expect(generation.text).toBe("")
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
}).pipe(Effect.provide(instructionsLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
)
it.effect("omits references without descriptions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* readInitial(yield* guidance.load())
const instructions = yield* ReferenceInstructions.Service
const generation = yield* readInitial(yield* instructions.load())
expect(generation.text).toBe("")
}).pipe(
Effect.provide(
guidanceLayer(
instructionsLayer(
Layer.mock(Reference.Service, {
list: () =>
Effect.succeed([
@@ -84,11 +84,11 @@ describe("ReferenceGuidance", () => {
})
let references = [reference("docs", "Use for product documentation")]
return Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const initialized = yield* readInitial(yield* guidance.load())
const instructions = yield* ReferenceInstructions.Service
const initialized = yield* readInitial(yield* instructions.load())
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
const added = yield* readUpdate(yield* guidance.load(), initialized)
const added = yield* readUpdate(yield* instructions.load(), initialized)
expect(added.text).toBe(
[
"New project references are available in addition to those previously listed:",
@@ -101,9 +101,11 @@ describe("ReferenceGuidance", () => {
)
references = [reference("examples", "Use for examples")]
expect((yield* readUpdate(yield* guidance.load(), added)).text).toBe(
expect((yield* readUpdate(yield* instructions.load(), added)).text).toBe(
"The following project references are no longer available and must not be used: docs.",
)
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
}).pipe(
Effect.provide(instructionsLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))),
)
})
})
+303
View File
@@ -0,0 +1,303 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionPendingTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die(new Error("unused")),
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
Effect.sync(() => {
requests.push(request)
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => instruction),
render: {
initial: String,
changed: (_previous, current) => current,
},
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
InstructionBuiltIns.node,
PluginHooks.node,
SessionGenerateNode.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, builtins],
[InstructionDiscovery.node, discovery],
[SkillInstructions.node, skills],
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
)
const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
Effect.all({
sequence: EventV2.latestSequence(db, sessionID),
events: db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
messages: db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie),
pending: db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie),
instructions: db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie),
session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
})
const userTexts = (request: LLMRequest) =>
request.messages.flatMap((message) =>
message.role === "user"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
)
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const agents = yield* AgentV2.Service
const instructionBuiltIns = yield* InstructionBuiltIns.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "generate-test",
directory: "/project",
title: "Generate test",
version: "test",
agent: AgentV2.ID.make("build"),
})
.run()
.pipe(Effect.orDie)
return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, events, instructions } = yield* setup
yield* InstructionState.prepare(db, events, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
text: "{}",
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
input: {},
executed: false,
})
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "system"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Changed context"])
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
it.effect("blocks unavailable initial instructions before generation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = Instructions.unavailable
const { db } = yield* setup
const before = yield* durableState(db, sessionID)
const generate = yield* SessionGenerate.Service
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toEqual([])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
+17 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import { mkdtemp, rm } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
@@ -24,7 +24,10 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
@@ -49,10 +52,22 @@ const execution = Layer.succeed(
awaitIdle: () => Effect.void,
}),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
imagePassthrough as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[[SessionExecution.node, execution]],
[
[SessionExecution.node, execution],
[LocationServiceMap.node, locations],
],
),
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
@@ -556,7 +556,10 @@ describe("SessionRunnerModel", () => {
})
const packages = [
["@opencode-ai/ai/providers/google-vertex", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/anthropic", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
["@opencode-ai/ai/providers/anthropic", "authToken"],
["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
] as const
@@ -33,9 +33,9 @@ import { Location } from "@opencode-ai/core/location"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
@@ -76,9 +76,11 @@ const model = OpenAIChat.route
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
@@ -102,9 +104,9 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[McpGuidance.node, mcpGuidance],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[McpInstructions.node, mcpInstructions],
[Config.node, config],
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
@@ -140,8 +142,8 @@ const it = testEffect(
SessionRunnerModel.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
SkillGuidance.node,
ReferenceGuidance.node,
SkillInstructions.node,
ReferenceInstructions.node,
Config.node,
Snapshot.node,
SessionRunnerLLM.node,
@@ -156,8 +158,8 @@ const it = testEffect(
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[Config.node, config],
[Snapshot.node, Snapshot.noopLayer],
[PluginSupervisor.node, pluginSupervisor],
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
@@ -28,7 +29,28 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
)
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const imageStore = Layer.mock(Image.Service, {
normalize: (resource, content) => {
if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
if (resource === "too-large.png")
return Effect.fail(
new Image.SizeError({
resource,
width: 9_000,
height: 9_000,
bytes: content.content.length,
maxWidth: 2_000,
maxHeight: 2_000,
maxBytes: 5,
}),
)
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
},
})
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
[ToolOutputStore.node, outputStore],
[Image.node, imageStore],
])
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
@@ -255,6 +277,75 @@ describe("ToolRegistry", () => {
}),
)
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "snapshot" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
])
}),
)
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
...call("progressive"),
progress: (update) =>
Effect.sync(() => {
updates.push(update)
}),
})
expect(updates).toEqual([
{
structured: { stage: "capture" },
content: [
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
},
])
}),
)
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+47 -22
View File
@@ -63,9 +63,9 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -318,7 +318,7 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
),
})
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, {
const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) =>
Effect.succeed(
skillBaselines.has(agent.id)
@@ -335,8 +335,10 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, {
: Instructions.empty,
),
})
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty),
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@@ -382,11 +384,11 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[PermissionV2.node, permission],
[Config.node, config],
[McpGuidance.node, mcpGuidance],
[McpInstructions.node, mcpInstructions],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[PluginSupervisor.node, pluginSupervisor],
])
@@ -424,8 +426,8 @@ const it = testEffect(
InstructionBuiltIns.node,
InstructionDiscovery.node,
InstructionEntry.node,
SkillGuidance.node,
ReferenceGuidance.node,
SkillInstructions.node,
ReferenceInstructions.node,
Config.node,
Snapshot.node,
SessionRunnerLLM.node,
@@ -440,8 +442,8 @@ const it = testEffect(
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
[SkillInstructions.node, skillInstructions],
[ReferenceInstructions.node, referenceInstructions],
[Snapshot.node, Snapshot.noopLayer],
[SessionExecution.node, execution],
[Config.node, config],
@@ -1424,7 +1426,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("updates selected-agent skill guidance after an agent switch", () =>
it.effect("updates selected-agent skill instructions after an agent switch", () =>
Effect.gen(function* () {
const session = yield* setup
const events = yield* EventV2.Service
@@ -4026,11 +4028,19 @@ describe("SessionRunnerLLM", () => {
{ attempt: 5, at: 30_000 },
])
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.started.1" },
{ type: "session.step.failed.1" },
])
}),
)
it.effect("counts retry attempts against the agent step allowance", () =>
it.effect("retries a physical attempt without consuming the logical agent step", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* AgentV2.Service
@@ -4039,21 +4049,36 @@ describe("SessionRunnerLLM", () => {
agent.steps = 2
}),
)
yield* admit(session, "Bound retries by steps")
yield* admit(session, "Retry without consuming a step")
const failure = providerUnavailable()
responseStream = Stream.fail(failure)
streamFailure = failure
responses = [reply.tool("call-after-retry", "echo", { text: "recovered" }), reply.stop()]
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(requests).toHaveLength(3)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.toolChoice).toBeUndefined()
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[1]?.messages.at(-1)).not.toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(requests[2]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[2]?.tools).toEqual([])
expect(requests[2]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(executions).toEqual(["recovered"])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(3)
expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(2)
}),
)
@@ -5,7 +5,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
@@ -40,11 +40,11 @@ const manual = SkillV2.Info.make({
})
const layer = (list: () => SkillV2.Info[]) =>
AppNodeBuilder.build(SkillGuidance.node, [
AppNodeBuilder.build(SkillInstructions.node, [
[SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
])
describe("SkillGuidance", () => {
describe("SkillInstructions", () => {
it.effect("renders described agent skills and updates the complete available list", () => {
const agent = AgentV2.Info.make({
...AgentV2.Info.empty(build),
@@ -52,8 +52,8 @@ describe("SkillGuidance", () => {
})
let skills = [hidden, denied, manual, effect]
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
expect(initialized.text).toBe(
[
@@ -72,7 +72,7 @@ describe("SkillGuidance", () => {
skills = []
expect(
yield* guidance
yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
@@ -90,11 +90,11 @@ describe("SkillGuidance", () => {
})
let skills = [effect]
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
skills = [effect, debugging]
const added = yield* guidance
const added = yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
expect(added.text).toBe(
@@ -109,7 +109,7 @@ describe("SkillGuidance", () => {
)
skills = [debugging]
const removed = yield* guidance
const removed = yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, added)))
expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
@@ -120,12 +120,12 @@ describe("SkillGuidance", () => {
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
let skills = [effect]
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
const instructions = yield* SkillInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
expect(
yield* guidance
yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
@@ -136,18 +136,18 @@ describe("SkillGuidance", () => {
}).pipe(Effect.provide(layer(() => skills)))
})
it.effect("omits guidance when the selected agent denies all skills", () => {
it.effect("omits instructions when the selected agent denies all skills", () => {
const agent = AgentV2.Info.make({
...AgentV2.Info.empty(build),
permissions: [{ action: "skill", resource: "*", effect: "deny" }],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
it.effect("omits guidance when a resource-specific denial follows the global denial", () => {
it.effect("omits instructions when a resource-specific denial follows the global denial", () => {
const agent = AgentV2.Info.make({
...AgentV2.Info.empty(build),
permissions: [
@@ -156,8 +156,8 @@ describe("SkillGuidance", () => {
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
@@ -170,14 +170,14 @@ describe("SkillGuidance", () => {
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toContain(
"<name>Effect</name>",
)
const instructions = yield* SkillInstructions.Service
expect(
(yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text,
).toContain("<name>Effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})
it.effect("omits guidance when a specifically allowed skill is denied again", () => {
it.effect("omits instructions when a specifically allowed skill is denied again", () => {
const agent = AgentV2.Info.make({
...AgentV2.Info.empty(build),
permissions: [
@@ -187,8 +187,8 @@ describe("SkillGuidance", () => {
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect((yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
const instructions = yield* SkillInstructions.Service
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
}).pipe(Effect.provide(layer(() => [effect])))
})
})
+34 -7
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@@ -35,7 +36,7 @@ describe("State", () => {
}),
)
it.effect("runs effectful transforms during every reload", () =>
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
@@ -43,15 +44,15 @@ describe("State", () => {
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) =>
Effect.sync(() => {
editor.add(value)
}),
)
yield* state.transform((editor) => {
editor.add(value)
})
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["second"])
}),
)
@@ -112,4 +113,30 @@ describe("State", () => {
expect(finalized).toBe(2)
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
}),
)
})
+3
View File
@@ -8,7 +8,9 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
@@ -84,6 +86,7 @@ const it = testEffect(
[PermissionV2.node, permission],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
]),
)
+39 -18
View File
@@ -291,6 +291,9 @@ describe("ReadTool", () => {
name: "pixel.png",
mime: "image/png",
encoding: "base64",
// Image base64 is carried by the content file item only; structured is slimmed
// so the original bytes are never persisted twice.
content: "",
})
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
@@ -364,7 +367,7 @@ describe("ReadTool", () => {
}),
)
it.effect("rejects invalid image data returned by the filesystem", () =>
it.effect("drops undecodable image data at settlement", () =>
Effect.gen(function* () {
readResult = {
uri: "file:///truncated.png",
@@ -381,11 +384,17 @@ describe("ReadTool", () => {
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
],
})
}),
)
it.effect("rejects oversized images when resizing is disabled", () =>
it.effect("drops oversized images at settlement when resizing is disabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
@@ -409,14 +418,20 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
})
}),
)
@@ -460,7 +475,7 @@ describe("ReadTool", () => {
}),
)
it.effect("enforces max base64 bytes after resize attempts", () =>
it.effect("drops images that cannot fit max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = {
@@ -481,14 +496,20 @@ describe("ReadTool", () => {
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("/1 bytes")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
],
})
}),
)
+11 -8
View File
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
const progressLinesCommand = (lines: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
: `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
),
)
it.live("reports bounded output progress for a running command", () =>
it.live("reports the latest output lines for a running command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
const lines = ShellTool.PROGRESS_LINES + 10
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
...call({ command: progressLinesCommand(lines) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
expect(content.text).toStartWith(
`[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
)
expect(content.text).not.toContain("line 10\n")
expect(content.text).toContain("line 11\n")
expect(content.text).toContain(`line ${lines}\n`)
}),
)
},
+3
View File
@@ -12,7 +12,9 @@ import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { Image } from "@opencode-ai/core/image"
import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
@@ -90,6 +92,7 @@ describe("SkillTool", () => {
[PermissionV2.node, permission],
[SkillV2.node, skills],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
)
+72
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -164,6 +165,77 @@ describe("SubagentTool", () => {
),
)
it.live("prevents subagents from launching subagents by default", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service
const root = yield* sessions.create({ location })
const parent = yield* sessions.create({ parentID: root.id, title: "parent" })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-nested-subagent",
name: SubagentTool.name,
input: { agent: "reviewer", description: "nested", prompt: "should fail" },
},
}),
).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") })
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
}),
),
),
)
it.live("allows nested subagents up to the configured depth", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_depth: 2 } })),
)
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service
const root = yield* sessions.create({ location })
const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const settled = yield* settleTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-configured-nested-subagent",
name: SubagentTool.name,
input: { agent: "reviewer", description: "nested", prompt: "should run" },
},
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
}),
),
),
)
it.live("runs a foreground child session and returns the final assistant text", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+3
View File
@@ -11,7 +11,9 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webFetchToolNode = makeLocationNode({
@@ -50,6 +52,7 @@ const toolLayer = (replacements: LayerNode.Replacements = []) =>
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
...replacements,
])
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
@@ -10,7 +10,9 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
const webSearchToolNode = makeLocationNode({
@@ -138,6 +140,7 @@ const it = testEffect(
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[Image.node, imagePassthrough],
],
),
)
+39 -2
View File
@@ -3,11 +3,12 @@ import { Effect, Fiber, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { KV } from "@opencode-ai/core/kv"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { testEffect } from "./lib/effect"
const it = testEffect(FetchHttpClient.layer)
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node])))
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node])))
it.live("loads embedded and remote configuration", () =>
Effect.acquireUseRelease(
@@ -65,7 +66,10 @@ serviceIt.live("persists sources in one KV value", () =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const kv = yield* KV.Service
const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const entry = yield* wellknown.add(`${server.url.origin}/`)
expect(entry.origin).toBe(server.url.origin)
@@ -80,3 +84,36 @@ serviceIt.live("persists sources in one KV value", () =>
(server) => Effect.promise(() => server.stop(true)),
),
)
serviceIt.live("refreshes changed manifests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
let command = "first"
return {
server: Bun.serve({
port: 0,
fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }),
}),
update: () => {
command = "second"
},
}
}),
({ server, update }) =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const events = yield* EventV2.Service
yield* wellknown.add(server.url.origin)
expect(yield* wellknown.refresh()).toBe(false)
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
update()
expect(yield* wellknown.refresh()).toBe(true)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
+46 -25
View File
@@ -46,6 +46,8 @@ import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { ClientProvider, useClient } from "./context/client"
import { StartupLoading } from "./component/startup-loading"
import { DevToolsSidebar } from "./component/devtools-sidebar"
import { DevTools } from "./devtools"
import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data"
import { LocationProvider, useLocation } from "./context/location"
@@ -86,6 +88,8 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
registerOpencodeSpinner()
const appGlobalBindingCommands = [
@@ -252,9 +256,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const pluginRuntime = createPluginRuntime()
yield* Effect.tryPromise(async () => {
const appStarted = performance.now()
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const modeStarted = performance.now()
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
themePerformance.set("Detect light/dark mode", `${(performance.now() - modeStarted).toFixed(2)} ms`)
if (renderer.isDestroyed) return
await render(() => {
@@ -342,6 +349,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EditorContextProvider>
<PluginProvider packages={input.packages}>
<App
started={appStarted}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@@ -398,10 +406,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials }) {
function App(props: { pair?: DialogPairCredentials; started: number }) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? false)
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@@ -421,6 +430,11 @@ function App(props: { pair?: DialogPairCredentials }) {
const plugins = usePlugin()
const clipboard = useClipboard()
createEffect(() => {
if (!themeState.ready) return
themePerformance.set("Total", `${(performance.now() - props.started).toFixed(2)} ms`)
})
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -1086,31 +1100,38 @@ function App(props: { pair?: DialogPairCredentials }) {
<Show when={Flag.OPENCODE_SHOW_TTFD}>
<TimeToFirstDraw />
</Show>
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
<PluginRoute
fallback={(id, name) => (
<PluginRouteMissing id={id} name={name} onHome={() => route.navigate({ type: "home" })} />
)}
/>
</Match>
</Switch>
<box flexGrow={1} minHeight={0} flexDirection="row">
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
<PluginRoute
fallback={(id, name) => (
<PluginRouteMissing id={id} name={name} onHome={() => route.navigate({ type: "home" })} />
)}
/>
</Match>
</Switch>
</box>
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
</Show>
</box>
<box flexShrink={0}>
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
</Show>
<Show when={devtools()}>
<DevToolsSidebar />
</Show>
</box>
<Show when={!startup.skipInitialLoading}>
<StartupLoading ready={plugins.ready} />
</Show>
@@ -0,0 +1,41 @@
import { TextAttributes } from "@opentui/core"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { DevTools } from "../devtools"
export function DevToolsSidebar() {
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={themeV2.background()}
>
<For each={DevTools.data()}>
{(group) => (
<box flexShrink={0} marginBottom={1}>
<box marginBottom={1}>
<text fg={themeV2.background.action.primary()} attributes={TextAttributes.BOLD}>
{group.title}
</text>
</box>
<For each={group.entries}>
{(entry) => (
<box flexDirection="row">
<text fg={themeV2.text.subdued()}>{entry.key}</text>
<box flexGrow={1} />
<text fg={themeV2.text()}>{String(entry.value)}</text>
</box>
)}
</For>
</box>
)}
</For>
</box>
)
}
@@ -206,6 +206,14 @@ const settings: Setting[] = [
values: [false, true],
labels: ["off", "on"],
},
{
title: "DevTools",
category: "Debug",
path: ["debug", "devtools"],
default: false,
values: [false, true],
labels: ["off", "on"],
},
]
export function DialogConfig() {
+4 -4
View File
@@ -5,10 +5,10 @@ import { tint } from "../theme/color"
import { logo } from "../logo"
export function Logo() {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const shadow = tint(themeV2.background(), fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
@@ -52,8 +52,8 @@ export function Logo() {
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
<box flexDirection="row">{renderLine(line, themeV2.text.subdued(), false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], themeV2.text(), true)}</box>
</box>
)}
</For>
+77 -45
View File
@@ -190,7 +190,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -297,8 +297,8 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
if (props.disabled) input.cursorColor = themeV2.background.surface.offset()
if (!props.disabled) input.cursorColor = themeV2.text()
})
const usage = createMemo(() => {
@@ -1306,10 +1306,10 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (leader()) return theme.border
if (store.mode === "shell") return theme.primary
if (leader()) return themeV2.border()
if (store.mode === "shell") return themeV2.background.action.primary()
const agent = local.agent.current()
if (!agent) return theme.border
if (!agent) return themeV2.border()
return local.agent.color(agent.id)
})
@@ -1326,7 +1326,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const borderHighlight = createMemo(() => tint(themeV2.border(), highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -1346,7 +1346,7 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => {
const agent = status() === "running" ? local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border
const color = agent ? local.agent.color(agent.id) : themeV2.border()
return {
frames: createFrames({
color,
@@ -1383,16 +1383,16 @@ export function Prompt(props: PromptProps) {
paddingRight={2}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
backgroundColor={themeV2.background.action.secondary("focused")}
flexGrow={1}
width="100%"
>
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
placeholderColor={themeV2.text.subdued()}
textColor={leader() ? themeV2.text.subdued() : themeV2.text()}
focusedTextColor={leader() ? themeV2.text.subdued() : themeV2.text()}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
@@ -1452,15 +1452,17 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
input.cursorColor = themeV2.text()
}, 0)
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled) return
r.target?.focus()
}}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
focusedBackgroundColor={themeV2.background.action.secondary("focused")}
cursorColor={
props.disabled ? themeV2.background.surface.offset() : themeV2.text()
}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1472,22 +1474,32 @@ export function Prompt(props: PromptProps) {
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().id)}
</text>
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
<text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>auto</text>
<text fg={fadeColor(themeV2.text.subdued(), agentMetaAlpha())}>auto</text>
</Show>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
fg={fadeColor(
leader() ? themeV2.text.subdued() : themeV2.text(),
modelMetaAlpha(),
)}
>
{local.model.parsed().model}
</text>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<text fg={fadeColor(themeV2.text.subdued(), modelMetaAlpha())}>
{currentProviderLabel()}
</text>
<Show when={showVariant()}>
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text fg={fadeColor(themeV2.text.subdued(), variantMetaAlpha())}>·</text>
<text>
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
<span
style={{
fg: fadeColor(themeV2.text.feedback.warning(), variantMetaAlpha()),
bold: true,
}}
>
{local.model.variant.current()}
</span>
</text>
@@ -1512,15 +1524,15 @@ export function Prompt(props: PromptProps) {
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
vertical: themeV2.background.action.secondary("focused").a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
borderColor={themeV2.background.action.secondary("focused")}
customBorderChars={
theme.backgroundElement.a !== 0
themeV2.background.action.secondary("focused").a !== 0
? {
...EmptyBorder,
horizontal: "▀",
@@ -1537,13 +1549,25 @@ export function Prompt(props: PromptProps) {
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={config.animations ?? true} fallback={<text fg={theme.textMuted}>[]</text>}>
<Show
when={config.animations ?? true}
fallback={<text fg={themeV2.text.subdued()}>[]</text>}
>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
<text
fg={store.interrupt > 0 ? themeV2.background.action.primary() : themeV2.text()}
>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
<span
style={{
fg:
store.interrupt > 0
? themeV2.background.action.primary()
: themeV2.text.subdued(),
}}
>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
@@ -1552,16 +1576,16 @@ export function Prompt(props: PromptProps) {
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3}>
<Spinner color={theme.accent}>
<Spinner color={themeV2.hue.accent(500)}>
{progress()}
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
<span style={{ fg: themeV2.text.subdued() }}>{".".repeat(move.creatingDots())}</span>
</Spinner>
</box>
)}
</Match>
<Match when={move.pendingNew()}>
<box paddingLeft={3}>
<text fg={theme.accent}>(new working copy)</text>
<text fg={themeV2.hue.accent(500)}>(new working copy)</text>
</box>
</Match>
<Match when={true}>
@@ -1570,7 +1594,7 @@ export function Prompt(props: PromptProps) {
fallback={props.hint ?? <text />}
>
{(location) => (
<text fg={theme.textMuted} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text fg={themeV2.text.subdued()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
</text>
)}
@@ -1580,47 +1604,55 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
<text
fg={
editorContextLabelState() === "pending"
? themeV2.hue.accent(500)
: themeV2.text.subdued()
}
>
{file()}
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={theme.textMuted} wrapMode="none">
<text fg={themeV2.text.subdued()} wrapMode="none">
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: theme.text }}>{shortcut()} </span>}
{(shortcut) => <span style={{ fg: themeV2.text() }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: theme.textMuted }}> · </span>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
{(label) => <span style={{ fg: themeV2.text.subdued() }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: theme.textMuted }}> · </span>
<span style={{ fg: themeV2.text.subdued() }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: theme.textMuted }}>{statusItems().join(" · ")}</span>
<span style={{ fg: themeV2.text.subdued() }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
<text fg={themeV2.text()}>
{agentShortcut()} <span style={{ fg: themeV2.text.subdued() }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
<text fg={themeV2.text()}>
{paletteShortcut()} <span style={{ fg: themeV2.text.subdued() }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
<text fg={themeV2.text()}>
esc <span style={{ fg: themeV2.text.subdued() }}>exit shell mode</span>
</text>
</Match>
</Switch>
+5
View File
@@ -126,6 +126,11 @@ export const Info = Schema.Struct({
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
debug: Schema.optional(
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools sidebar" }),
}),
).annotate({ description: "Debugging settings" }),
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
})
+8 -4
View File
@@ -134,11 +134,13 @@ export const Definitions = {
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_next_user: "session.message.user.next",
messages_previous_user: "session.message.user.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
+22 -11
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event"
@@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme().theme
const { theme, themeV2, mode } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
theme.secondary,
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
theme.info,
])
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
[
themeV2.hue.blue(step),
themeV2.hue.purple(step),
themeV2.hue.green(step),
themeV2.hue.orange(step),
themeV2.hue.red(step),
themeV2.hue.cyan(step),
],
(first, second) => first.equals(second),
)
})
return {
list() {
return agents()
@@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const slots = createMemo(() => {
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
const existing = new Set(
data.session
.list()
.filter((x) => x.parentID === undefined)
.map((x) => x.id),
)
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})
+75 -12
View File
@@ -13,10 +13,14 @@ import {
setSystemTheme,
subscribeThemes,
upsertTheme,
type Theme,
type ThemeJson,
} from "../theme"
import { generateSystem, terminalMode } from "../theme/system"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
import { resolveThemeFile } from "../theme/v2/resolve"
import { migrateV1 } from "../theme/v2/v1-migrate"
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useConfig } from "../config"
@@ -24,6 +28,9 @@ import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { DevTools } from "../devtools"
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
export type ThemeSource = Readonly<{
discover(): Promise<Record<string, unknown>>
@@ -77,6 +84,24 @@ type State = {
ready: boolean
}
type ContextName = "elevated" | "overlay"
type ThemeService = {
theme: Theme
themeV2: ComponentTheme
contextual(context: ContextName): ThemeService
readonly selected: string
all: typeof allThemes
has: typeof hasTheme
syntax: Accessor<SyntaxStyle>
mode: Accessor<"dark" | "light">
locked: Accessor<boolean>
lock(): void
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): void
set(theme: string): boolean
readonly ready: boolean
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
@@ -141,6 +166,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onMount(() => {
void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => {
valuesV2()
setStore("ready", true)
})
})
@@ -149,6 +175,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
const started = performance.now()
return renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
@@ -172,6 +199,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
})
.finally(() => themePerformance.set("Resolve system palette", duration(performance.now() - started)))
}
let systemRefreshRunning = false
@@ -257,23 +285,49 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
themeRefreshTimeouts.length = 0
})
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
return resolveTheme(store.themes.opencode, store.mode)
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
const values = createMemo(() => resolveTheme(source(), store.mode))
const valuesV2 = createMemo(() => {
const started = performance.now()
const file = migrateV1(source())
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
const resolveStarted = performance.now()
const result = resolveThemeFile(file, store.mode, sourceName())
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
return result
})
const themeV2 = createComponentTheme(valuesV2)
const contextsV2 = {
elevated: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:elevated"]
if (!theme) throw new Error("Theme context is not defined: elevated")
return theme
}),
overlay: createComponentTheme(() => {
const theme = valuesV2().contexts["@context:overlay"]
if (!theme) throw new Error("Theme context is not defined: overlay")
return theme
}),
}
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error Properties are forwarded to the current reactive value.
return values()[prop]
},
}),
const theme = new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error Properties are forwarded to the current reactive value.
return values()[prop]
},
})
function contextual(context: ContextName) {
return contextualServices[context]
}
const service: ThemeService = {
theme,
themeV2,
contextual,
get selected() {
return store.active
},
@@ -299,9 +353,18 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return store.ready
},
}
const contextualServices = {
elevated: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.elevated }),
overlay: Object.assign(Object.create(service) as ThemeService, { themeV2: contextsV2.overlay }),
}
return service
},
})
function duration(milliseconds: number) {
return `${milliseconds.toFixed(2)} ms`
}
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
const renderer = useRenderer()
const retained = new Set<SyntaxStyle>()
+41
View File
@@ -0,0 +1,41 @@
export * as DevTools from "."
import { createSignal } from "solid-js"
export type Value = string | number | boolean | null
export type Group = Readonly<{
id: string
title: string
entries: readonly Readonly<{ key: string; value: Value }>[]
}>
const [groups, setGroups] = createSignal<readonly Group[]>([])
export function register(input: { id: string; title: string }) {
setGroups((groups) => {
if (groups.some((group) => group.id === input.id)) {
return groups.map((group) => (group.id === input.id ? { ...group, title: input.title } : group))
}
return [...groups, { ...input, entries: [] }]
})
return {
set(key: string, value: Value) {
setGroups((groups) =>
groups.map((group) => {
if (group.id !== input.id) return group
if (group.entries.some((entry) => entry.key === key)) {
return {
...group,
entries: group.entries.map((entry) => (entry.key === key ? { key, value } : entry)),
}
}
return { ...group, entries: [...group.entries, { key, value }] }
}),
)
},
}
}
export const data = groups
@@ -8,7 +8,7 @@ import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
@@ -16,13 +16,13 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={theme.textMuted} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={themeV2.text.subdued()} />}
</Show>
)
}
function Mcp(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
@@ -30,25 +30,27 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme.text}>
<text fg={themeV2.text()}>
<Switch>
<Match when={failed()}>
<span style={{ fg: theme.error }}> </span>
<span style={{ fg: themeV2.text.feedback.error() }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme.success : theme.textMuted }}> </span>
<span style={{ fg: count() > 0 ? themeV2.text.feedback.success() : themeV2.text.subdued() }}>
{" "}
</span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
<text fg={themeV2.text.subdued()}>/status</text>
</box>
</Show>
)
}
function View(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
@@ -75,7 +77,7 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{InstallationVersion}</text>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box>
</box>
)
@@ -38,7 +38,7 @@ export type ComposerProps = {
}
export function Composer(props: ComposerProps) {
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [store, setStore] = createStore({
tabs: {} as Record<string, Tab>,
@@ -111,8 +111,8 @@ export function Composer(props: ComposerProps) {
<box
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
backgroundColor={theme.backgroundPanel}
borderColor={themeV2.border()}
backgroundColor={themeV2.background()}
paddingLeft={1}
paddingRight={2}
paddingTop={1}
@@ -123,7 +123,7 @@ export function Composer(props: ComposerProps) {
<Show
when={tabList().length > 1}
fallback={
<text fg={theme.text} attributes={TextAttributes.BOLD}>
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
{tabList()[0]?.label ?? ""}
</text>
}
@@ -134,7 +134,7 @@ export function Composer(props: ComposerProps) {
const isActive = createMemo(() => store.active === t.id)
return (
<text
fg={isActive() ? theme.text : theme.textMuted}
fg={isActive() ? themeV2.text() : themeV2.text.subdued()}
attributes={isActive() ? TextAttributes.BOLD : undefined}
>
{t.label}
@@ -144,7 +144,7 @@ export function Composer(props: ComposerProps) {
</For>
</box>
</Show>
<text fg={theme.textMuted} onMouseUp={close}>
<text fg={themeV2.text.subdued()} onMouseUp={close}>
esc
</text>
</box>
@@ -154,19 +154,19 @@ export function Composer(props: ComposerProps) {
<For each={footerHints()}>
{(hint) => (
<text>
<span style={{ fg: theme.text }}>
<span style={{ fg: themeV2.text() }}>
<b>{hint.label}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{hint.shortcut}</span>
<span style={{ fg: themeV2.text.subdued() }}>{hint.shortcut}</span>
</text>
)}
</For>
<Show when={tabList().length > 1}>
<text>
<span style={{ fg: theme.text }}>
<span style={{ fg: themeV2.text() }}>
<b>tabs</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>/</span>
<span style={{ fg: themeV2.text.subdued() }}>/</span>
</text>
</Show>
</box>
@@ -4,7 +4,7 @@ import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
@@ -12,8 +12,7 @@ export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const { themeV2 } = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -97,7 +96,7 @@ export function ShellTab(props: { sessionID: string }) {
return (
<Show when={composer.active("shell")}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No shell commands</text>}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
<For each={entries()}>
{(shell, index) => {
const active = createMemo(() => index() === store.selected)
@@ -106,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={active() ? themeV2.background.action.primary("selected") : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())}
>
<text
fg={active() ? fg : theme.text}
fg={themeV2.text.action.primary(active() ? "focused" : "default")}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -4,7 +4,7 @@ import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useClient } from "../../../context/client"
import { useTheme, selectedForeground } from "../../../context/theme"
import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
@@ -21,8 +21,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const route = useRouteData("session")
const data = useData()
const client = useClient()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const { themeV2 } = useTheme()
const navigate = useRoute().navigate
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -39,7 +38,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
result.push({
sessionID: sibling.id,
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
result.push({
sessionID: child.id,
@@ -195,12 +202,8 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={themeV2.background.action.primary(active() ? "focused" : "default")}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
>
<box flexGrow={1} minWidth={0} flexDirection="row">
<text
fg={active() ? fg : entry.current ? theme.primary : theme.text}
fg={themeV2.text.action.primary(
active() ? "focused" : entry.current ? "selected" : "default",
)}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text>
</box>
<Show when={status()}>
<text fg={active() ? fg : theme.textMuted} wrapMode="none">
<text fg={active() ? themeV2.text.action.primary() : themeV2.text.subdued()} wrapMode="none">
{status()}
</text>
</Show>
+10 -10
View File
@@ -7,7 +7,7 @@ import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
export function Footer() {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const data = useData()
const route = useRoute()
const mcp = createMemo(
@@ -54,35 +54,35 @@ export function Footer() {
return (
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{directory()}</text>
<text fg={themeV2.text.subdued()}>{directory()}</text>
<box gap={2} flexDirection="row" flexShrink={0}>
<Switch>
<Match when={store.welcome}>
<text fg={theme.text}>
Get started <span style={{ fg: theme.textMuted }}>/connect</span>
<text fg={themeV2.text()}>
Get started <span style={{ fg: themeV2.text.subdued() }}>/connect</span>
</text>
</Match>
<Match when={connected()}>
<Show when={permissions().length > 0}>
<text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission
<text fg={themeV2.text.feedback.warning()}>
<span style={{ fg: themeV2.text.feedback.warning() }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""}
</text>
</Show>
<Show when={mcp()}>
<text fg={theme.text}>
<text fg={themeV2.text()}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}> </span>
<span style={{ fg: themeV2.text.feedback.error() }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: theme.success }}> </span>
<span style={{ fg: themeV2.text.feedback.success() }}> </span>
</Match>
</Switch>
{mcp()} MCP
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
<text fg={themeV2.text.subdued()}>/status</text>
</Match>
</Switch>
</box>
+113 -60
View File
@@ -3,8 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import open from "open"
import { selectedForeground, useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { useTheme } from "../../context/theme"
import type { FormField, FormValue } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
import { useClient } from "../../context/client"
@@ -146,7 +145,7 @@ function requestOptions(form: FormWithLocation) {
export function FormPrompt(props: { form: FormWithLocation }) {
const client = useClient()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const keymap = Keymap.use()
@@ -753,27 +752,27 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={theme.accent}
borderColor={themeV2.hue.accent(500)}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{props.form.title}</text>
<text fg={themeV2.text.subdued()}>{props.form.title}</text>
</box>
<Show when={message()}>
<box paddingLeft={1}>
<text fg={theme.text}>{message()}</text>
<text fg={themeV2.text()}>{message()}</text>
</box>
</Show>
<Show when={!single() && !tabbed()}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>
<text fg={themeV2.text.subdued()}>
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
</text>
<Show when={fields().length > 0}>
<text fg={theme.textMuted}>
<text fg={themeV2.text.subdued()}>
· {answered()}/{fields().length} completed
</text>
</Show>
@@ -787,10 +786,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const isAnswered = () => store.answers[item.key] !== undefined
return (
<box
paddingLeft={1}
paddingRight={1}
paddingRight={2}
backgroundColor={
isTab() ? theme.accent : tabHover() === index() ? theme.backgroundElement : theme.backgroundPanel
isTab()
? themeV2.background.formfield("selected")
: tabHover() === index()
? themeV2.background.formfield("focused")
: themeV2.background()
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
@@ -801,7 +803,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<text
fg={
isTab() ? selectedForeground(theme, theme.accent) : isAnswered() ? theme.text : theme.textMuted
isTab()
? themeV2.text.formfield("selected")
: tabHover() === index()
? themeV2.text.formfield("focused")
: isAnswered()
? themeV2.text()
: themeV2.text.subdued()
}
>
{truncate(fieldLabel(item), 24)}
@@ -811,10 +819,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
</For>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
confirm()
? themeV2.background.formfield("selected")
: tabHover() === "confirm"
? themeV2.background.formfield("focused")
: themeV2.background()
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
@@ -823,7 +833,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
selectTabFromMouse()
}}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
<text fg={themeV2.text.formfield(confirm() ? "selected" : "default")}>Confirm</text>
</box>
</box>
</Show>
@@ -832,13 +842,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{(external) => (
<box paddingLeft={1} gap={1}>
<Show when={external().title}>
<text fg={theme.text}>{external().title}</text>
<text fg={themeV2.text()}>{external().title}</text>
</Show>
<Show when={external().description}>
<text fg={theme.textMuted}>{external().description}</text>
<text fg={themeV2.text.subdued()}>{external().description}</text>
</Show>
<text
fg={theme.primary}
fg={themeV2.background.action.primary()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
openExternal()
@@ -846,7 +856,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
{external().url}
</text>
<text fg={store.answers[external().key] === true ? theme.success : theme.textMuted}>
<text
fg={
store.answers[external().key] === true
? themeV2.text.feedback.success()
: themeV2.text.subdued()
}
>
{store.answers[external().key] === true
? "✓ Acknowledged"
: store.externalReady[external().key]
@@ -860,7 +876,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={!confirm() && answerField()}>
<box paddingLeft={1} gap={1}>
<box>
<text fg={theme.text}>
<text fg={themeV2.text()}>
{answerField()!.description ?? fieldLabel(answerField()!)}
{answerField()!.required ? " (required)" : ""}
{multi() ? " (select all that apply)" : ""}
@@ -879,12 +895,12 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
placeholder={placeholder()}
placeholderColor={theme.textMuted}
placeholderColor={themeV2.text.subdued()}
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
textColor={themeV2.text()}
focusedTextColor={themeV2.text()}
cursorColor={themeV2.text()}
/>
</box>
</Show>
@@ -908,23 +924,36 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
>
<box flexDirection="row">
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
<box
backgroundColor={
active() ? themeV2.background.formfield("focused") : themeV2.background()
}
paddingRight={1}
>
<text fg={themeV2.text.formfield(active() ? "focused" : "default")}>
{`${i() + 1}.`}
</text>
</box>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
<box
backgroundColor={
active() ? themeV2.background.formfield("focused") : themeV2.background()
}
>
<text
fg={themeV2.text.formfield(
active() ? "focused" : picked() ? "selected" : "default",
)}
>
{multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
<text fg={themeV2.text.formfield("selected")}>{picked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={row.description}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{row.description}</text>
<text fg={themeV2.text.subdued()}>{row.description}</text>
</box>
</Show>
</box>
@@ -941,18 +970,31 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
>
<box flexDirection="row">
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
<box
backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
paddingRight={1}
>
<text fg={themeV2.text.formfield(other() ? "focused" : "default")}>
{`${rows().length + 1}.`}
</text>
</box>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
<box
backgroundColor={other() ? themeV2.background.formfield("focused") : themeV2.background()}
>
<text
fg={
other()
? themeV2.text.formfield("focused")
: customPicked()
? themeV2.text.feedback.success()
: themeV2.text()
}
>
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
</text>
</box>
<Show when={!multi()}>
<text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
<text fg={themeV2.text.feedback.success()}>{customPicked() ? " ✓" : ""}</text>
</Show>
</box>
<Show when={store.editing}>
@@ -968,18 +1010,18 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
initialValue={input()}
placeholder="Type your own answer"
placeholderColor={theme.textMuted}
placeholderColor={themeV2.text.subdued()}
minHeight={1}
maxHeight={6}
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
textColor={themeV2.text()}
focusedTextColor={themeV2.text()}
cursorColor={themeV2.text()}
/>
</box>
</Show>
<Show when={!store.editing && input()}>
<box paddingLeft={3}>
<text fg={theme.textMuted}>{input()}</text>
<text fg={themeV2.text.subdued()}>{input()}</text>
</box>
</Show>
</box>
@@ -992,7 +1034,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<Show when={tabbed()}>
<box paddingLeft={1}>
<text fg={theme.text}>Review</text>
<text fg={themeV2.text()}>Review</text>
</box>
</Show>
<scrollbox
@@ -1007,8 +1049,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span style={{ fg: acknowledged() ? theme.success : theme.error }}>
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? themeV2.text.feedback.success()
: themeV2.text.feedback.error(),
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
@@ -1022,10 +1070,15 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span style={{ fg: themeV2.text.subdued() }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
<span
style={{
fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted,
fg:
invalid() || missing()
? themeV2.text.feedback.error()
: answered()
? themeV2.text()
: themeV2.text.subdued(),
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
@@ -1049,41 +1102,41 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row" gap={2}>
<Show when={!single()}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
<text fg={themeV2.text()}>
{"⇆"} <span style={{ fg: themeV2.text.subdued() }}>tab</span>
</text>
</Show>
<Show when={!confirm() && !textual() && !externalField()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
<text fg={themeV2.text()}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
</text>
</Show>
<Show when={confirm() && fields().length > 0}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
<text fg={themeV2.text()}>
{"↑↓"} <span style={{ fg: themeV2.text.subdued() }}>scroll</span>
</text>
</Show>
<text
fg={theme.text}
fg={themeV2.text()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (externalField()) acknowledgeExternal()
}}
>
enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>
enter <span style={{ fg: themeV2.text.subdued() }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<text fg={theme.text} onMouseUp={copyExternal}>
c <span style={{ fg: theme.textMuted }}>copy</span>
<text fg={themeV2.text()} onMouseUp={copyExternal}>
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
</text>
</Show>
<text fg={theme.text} onMouseUp={cancel}>
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
<text fg={themeV2.text()} onMouseUp={cancel}>
esc <span style={{ fg: themeV2.text.subdued() }}>dismiss</span>
</text>
</box>
<Show when={store.error}>
<text fg={theme.error}>{store.error}</text>
<text fg={themeV2.text.feedback.error()}>{store.error}</text>
</Show>
</box>
</box>
+285 -209
View File
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const context = createContext<{
width: number
sessionID: string
@@ -108,7 +112,7 @@ export function Session() {
const paths = useTuiPaths()
const configState = useConfig()
const config = configState.data
const { theme } = useTheme()
const { themeV2 } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
@@ -180,6 +184,23 @@ export function Session() {
const client = useClient()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const clearMessageNavigation = () => {
setNavigationSlack(0)
setNavigationMessage(undefined)
}
createEffect(
on(
() => [dimensions().width, dimensions().height] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
},
),
)
createEffect(
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
@@ -239,53 +260,55 @@ export function Session() {
dialog.clear()
}
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
const visibleMessages = children
.filter((c) => {
if (!c.id) return false
const message = messagesList.find((m) => m.id === c.id)
if (!message) return false
if (message.type === "user") return Boolean(message.text.trim())
return (
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
const alignMessage = (messageID: string, top: number) => {
scroll.stickyScroll = false
setNavigationMessage(messageID)
setNavigationSlack(
messageNavigationSlack({
top,
viewportHeight: scroll.viewport.height,
scrollHeight: scroll.scrollHeight,
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
})
.sort((a, b) => a.y - b.y)
if (visibleMessages.length === 0) return null
if (direction === "next") {
// Find first message below current position
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
}
// Find last message above current position
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
})
}
// Helper: Scroll to message in direction or fallback to page scroll
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
const targetID = findNextVisibleMessage(direction)
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (!target) {
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
alignMessage(target.id, target.top)
dialog.clear()
}
const jumpToMessage = (messageID: string) => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
}
function toBottom() {
clearMessageNavigation()
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -299,6 +322,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -309,6 +333,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -319,6 +344,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
dialog.clear()
},
@@ -329,6 +355,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
dialog.clear()
},
@@ -339,6 +366,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -349,6 +377,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -362,6 +391,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
dialog.clear()
},
@@ -372,6 +402,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -412,8 +443,7 @@ export function Session() {
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(messageID)
}}
/>
))
@@ -574,10 +604,7 @@ export function Session() {
const message = messages[i]
if (!message || message.type !== "user" || !message.text.trim()) continue
{
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(message.id)
break
}
}
@@ -597,6 +624,20 @@ export function Session() {
hidden: true,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Next user message",
name: "session.message.user.next",
category: "Session",
hidden: true,
run: () => scrollToMessage("next", dialog, true),
},
{
title: "Previous user message",
name: "session.message.user.previous",
category: "Session",
hidden: true,
run: () => scrollToMessage("prev", dialog, true),
},
{
title: "Copy last assistant message",
name: "messages.copy",
@@ -767,20 +808,20 @@ export function Session() {
},
},
{
title: "Next child session",
title: "Next subagent",
name: "session.child.next",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
run: () => unavailable("Sibling session navigation"),
run: () => unavailable("Subagent navigation"),
},
{
title: "Previous child session",
title: "Previous subagent",
name: "session.child.previous",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
run: () => unavailable("Sibling session navigation"),
run: () => unavailable("Subagent navigation"),
},
])
@@ -810,7 +851,10 @@ export function Session() {
createEffect(
on(
() => route.sessionID,
() => setComposer("open", false),
() => {
setComposer("open", false)
clearMessageNavigation()
},
),
)
@@ -842,20 +886,21 @@ export function Session() {
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
backgroundColor: themeV2.background.action.secondary("focused"),
foregroundColor: themeV2.border(),
},
}}
stickyScroll={true}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
{(row) => (
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</For>
@@ -870,6 +915,9 @@ export function Session() {
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
@@ -942,9 +990,13 @@ export function Session() {
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
return (
<box marginTop={1} flexShrink={0}>
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -987,7 +1039,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
}
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const shortcut = useCommandShortcut("session.background")
const visible = createMemo(() => {
const current = props.messages.findLast(
@@ -1005,8 +1057,8 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
<Show when={visible() && shortcut()}>
{(value) => (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={theme.textMuted}>
Press <span style={{ fg: theme.text }}>{value()}</span> to move running work to the background
<text fg={themeV2.text.subdued()}>
Press <span style={{ fg: themeV2.text() }}>{value()}</span> to move running work to the background
</text>
</box>
)}
@@ -1076,7 +1128,7 @@ function SessionReasoningGroupView(props: {
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const ctx = use()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1116,10 +1168,15 @@ function SessionReasoningGroupView(props: {
icon={expanded() ? "-" : "+"}
color={
!props.completed
? theme.text
? themeV2.text()
: hover() || expanded()
? theme.warning
: RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
? themeV2.text.feedback.warning()
: RGBA.fromValues(
themeV2.text.feedback.warning().r,
themeV2.text.feedback.warning().g,
themeV2.text.feedback.warning().b,
0.6,
)
}
complete={props.completed}
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
@@ -1160,7 +1217,7 @@ function SessionReasoningGroupView(props: {
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
borderColor={themeV2.background.action.secondary("focused")}
paddingLeft={1}
>
<code
@@ -1170,7 +1227,7 @@ function SessionReasoningGroupView(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
fg={themeV2.text.subdued()}
/>
</box>
</box>
@@ -1192,7 +1249,7 @@ function SessionGroupView(props: {
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const ctx = use()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
@@ -1228,7 +1285,7 @@ function SessionGroupView(props: {
<Show when={grouped().length > 0}>
<InlineToolRow
icon={props.completed ? "→" : "✱"}
color={hover() ? theme.text : theme.textMuted}
color={hover() ? themeV2.text() : themeV2.text.subdued()}
complete={props.completed}
pending={label()}
spinner={!props.completed}
@@ -1254,7 +1311,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const local = useLocal()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -1274,25 +1331,25 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
borderColor={themeV2.text.feedback.error()}
>
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
<span style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
<span style={{ fg: themeV2.text.subdued() }}> · interrupted</span>
</Show>
</text>
</box>
@@ -1302,7 +1359,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const { theme } = useTheme()
const { themeV2 } = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "model-switched")
@@ -1311,14 +1368,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
}
return (
<box paddingLeft={3}>
<text fg={theme.textMuted}>{text()}</text>
<text fg={themeV2.text.subdued()}>{text()}</text>
</box>
)
}
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const { theme } = useTheme()
const { themeV2 } = useTheme()
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
const source = () => stringValue(metadata()?.source)
const completion = () => source() === "subagent" || source() === "shell"
@@ -1339,15 +1396,15 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const suffix = () =>
Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - Bun.stringWidth(heading())))
const color = () => {
if (state() === "error") return theme.error
if (state() === "cancelled") return theme.warning
return theme.info
if (state() === "error") return themeV2.text.feedback.error()
if (state() === "cancelled") return themeV2.text.feedback.warning()
return themeV2.text.feedback.info()
}
return (
<Show
when={completion()}
fallback={
<InlineToolRow icon="◈" color={theme.textMuted} pending="Notice" complete={true}>
<InlineToolRow icon="◈" color={themeV2.text.subdued()} pending="Notice" complete={true}>
{text()}
</InlineToolRow>
}
@@ -1355,7 +1412,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: theme.textMuted }}>{suffix()}</span>
<span style={{ fg: themeV2.text.subdued() }}>{suffix()}</span>
</text>
</box>
</Show>
@@ -1363,9 +1420,9 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
}
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}>
<InlineToolRow icon="→" color={themeV2.text.subdued()} pending="Skill" complete={true}>
Skill {props.message.name}
</InlineToolRow>
)
@@ -1373,11 +1430,11 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
const ctx = use()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const status = () => props.message.status
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
const content = createMemo(() => text().trim())
const color = () => (status() === "failed" ? theme.error : theme.textMuted)
const color = () => (status() === "failed" ? themeV2.text.feedback.error() : themeV2.text.subdued())
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1406,8 +1463,8 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
content={content()}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdownText}
bg={theme.background}
fg={themeV2.markdown()}
bg={themeV2.background()}
/>
</box>
</Show>
@@ -1416,15 +1473,15 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
}
function CompactionQueued() {
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<box flexDirection="row" alignItems="center">
<box border={["top"]} borderColor={theme.border} flexGrow={1} />
<box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}></text>
<text fg={theme.textMuted}>Compaction queued</text>
<text fg={themeV2.text.subdued()}></text>
<text fg={themeV2.text.subdued()}>Compaction queued</text>
</box>
<box border={["top"]} borderColor={theme.border} flexGrow={1} />
<box border={["top"]} borderColor={themeV2.border()} flexGrow={1} />
</box>
)
}
@@ -1445,7 +1502,7 @@ function RevertMessage(props: {
}>
}) {
const ctx = use()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const route = useRouteData("session")
const client = useClient()
const toast = useToast()
@@ -1470,15 +1527,15 @@ function RevertMessage(props: {
marginTop={1}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
borderColor={themeV2.background()}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() ? themeV2.background.action.secondary("focused") : themeV2.background()}
>
<text fg={theme.textMuted}>
<text fg={themeV2.text.subdued()}>
{props.count} message{props.count === 1 ? "" : "s"} reverted
</text>
<Show when={props.files.length > 0}>
@@ -1486,7 +1543,7 @@ function RevertMessage(props: {
<For each={props.files}>
{(file) => (
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(file.status)}</text>
<text fg={themeV2.text.subdued()}>{statusLabel(file.status)}</text>
<FilePath
value={file.file}
maxWidth={Math.max(
@@ -1496,21 +1553,21 @@ function RevertMessage(props: {
(file.additions > 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) -
(file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0),
)}
fg={theme.text}
fg={themeV2.text()}
/>
<Show when={file.additions > 0}>
<text fg={theme.diffAdded}>+{file.additions}</text>
<text fg={themeV2.diff.text.added()}>+{file.additions}</text>
</Show>
<Show when={file.deletions > 0}>
<text fg={theme.diffRemoved}>-{file.deletions}</text>
<text fg={themeV2.diff.text.removed()}>-{file.deletions}</text>
</Show>
</box>
)}
</For>
</box>
</Show>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{redoKey()}</span> or /redo to restore
<text fg={themeV2.text.subdued()}>
<span style={{ fg: themeV2.text() }}>{redoKey()}</span> or /redo to restore
</text>
</box>
</box>
@@ -1518,7 +1575,7 @@ function RevertMessage(props: {
}
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
return (
@@ -1528,13 +1585,13 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
borderColor={themeV2.background()}
>
<text fg={theme.text}>$ {props.message.command}</text>
<text fg={themeV2.text()}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.textMuted}>{output()}</text>
<text fg={themeV2.text.subdued()}>{output()}</text>
</Show>
</box>
)
@@ -1545,7 +1602,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const queued = createMemo(
@@ -1558,9 +1615,8 @@ function UserMessage(props: { message: SessionMessageUser }) {
return (
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? theme.border : color()}
borderColor={queued() ? themeV2.border() : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1583,19 +1639,27 @@ function UserMessage(props: { message: SessionMessageUser }) {
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() ? themeV2.background.action.secondary("focused") : themeV2.background()}
flexShrink={0}
>
<text fg={theme.text}>{props.message.text}</text>
<text fg={themeV2.text()}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const label = file.mime === "application/x-directory" ? "dir" : "file"
return (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background, bold: true }}>{` ${label} `}</span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
<text fg={themeV2.text()}>
<span
style={{
bg: themeV2.hue.accent(500),
fg: themeV2.background(),
bold: true,
}}
>
{` ${label} `}
</span>
<span style={{ bg: themeV2.background.action.secondary("focused"), fg: themeV2.text.subdued() }}>
{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
</span>
@@ -1614,7 +1678,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const model = createMemo(
() =>
ctx
@@ -1700,11 +1764,11 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
borderColor={themeV2.text.feedback.error()}
>
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
@@ -1712,12 +1776,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
<span style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
@@ -1728,12 +1792,12 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
<text fg={themeV2.text.subdued()}>
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
</text>
</box>
@@ -1743,7 +1807,7 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const pathFormatter = usePathFormatter()
const label = (part: SessionMessageAssistantTool) => {
const input = typeof part.state.input === "string" ? {} : part.state.input
@@ -1756,7 +1820,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={theme.textMuted}
color={themeV2.text.subdued()}
complete={!props.active}
pending="Exploring"
spinner={props.active}
@@ -1766,7 +1830,7 @@ function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; activ
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={part.state.status === "error" ? theme.error : theme.textMuted}>
<text fg={part.state.status === "error" ? themeV2.text.feedback.error() : themeV2.text.subdued()}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
@@ -1783,7 +1847,7 @@ function ReasoningPart(props: {
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -1811,7 +1875,7 @@ function ReasoningPart(props: {
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
borderColor={themeV2.background.action.secondary("focused")}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
@@ -1829,7 +1893,7 @@ function ReasoningPart(props: {
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
borderColor={themeV2.background.action.secondary("focused")}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
@@ -1839,7 +1903,7 @@ function ReasoningPart(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
fg={themeV2.text.subdued()}
/>
</box>
</box>
@@ -1861,11 +1925,16 @@ function ReasoningHeader(props: {
title: string | null
duration?: string
}) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
? RGBA.fromValues(
themeV2.text.feedback.warning().r,
themeV2.text.feedback.warning().g,
themeV2.text.feedback.warning().b,
0.6,
)
: themeV2.text.feedback.warning()
return (
<Switch>
@@ -1900,7 +1969,7 @@ function ReasoningHeader(props: {
function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
const ctx = use()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
@@ -1911,8 +1980,8 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdownText}
bg={theme.background}
fg={themeV2.markdown()}
bg={themeV2.background()}
/>
</box>
</Show>
@@ -2001,7 +2070,7 @@ type ToolProps = {
part: SessionMessageAssistantTool
}
function GenericTool(props: ToolProps) {
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const output = createMemo(() => props.output?.trim() ?? "")
const args = createMemo(() => JSON.stringify(props.input, null, 2))
const [expanded, setExpanded] = createSignal(false)
@@ -2019,7 +2088,7 @@ function GenericTool(props: ToolProps) {
<Show when={Object.keys(props.input).length > 0}>
<box gap={1}>
<text>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Input </span>
<span style={{ bg: themeV2.background.action.secondary("focused"), fg: themeV2.text.subdued() }}> Input </span>
</text>
<box paddingLeft={1}>
<code
@@ -2028,7 +2097,7 @@ function GenericTool(props: ToolProps) {
syntaxStyle={syntax()}
conceal={false}
drawUnstyledText={false}
fg={theme.text}
fg={themeV2.text()}
/>
</box>
</box>
@@ -2037,10 +2106,10 @@ function GenericTool(props: ToolProps) {
{(value) => (
<box gap={1}>
<text>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> Output </span>
<span style={{ bg: themeV2.background.action.secondary("focused"), fg: themeV2.text.subdued() }}> Output </span>
</text>
<box paddingLeft={1}>
<text fg={theme.text} wrapMode="word">
<text fg={themeV2.text()} wrapMode="word">
{value()}
</text>
</box>
@@ -2066,7 +2135,7 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
onClick?: () => void
}) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const ctx = use()
const data = useData()
const renderer = useRenderer()
@@ -2092,10 +2161,10 @@ function InlineTool(props: {
const clickable = createMemo(() => Boolean(props.onClick || failed()))
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
if (failed()) return theme.error
if (hover() && props.onClick) return theme.text
return theme.textMuted
if (permission()) return themeV2.text.feedback.warning()
if (failed()) return themeV2.text.feedback.error()
if (hover() && props.onClick) return themeV2.text()
return themeV2.text.subdued()
})
return (
@@ -2103,7 +2172,7 @@ function InlineTool(props: {
icon={props.icon}
iconColor={props.iconColor}
color={fg()}
errorColor={theme.error}
errorColor={themeV2.text.feedback.error()}
failed={failed()}
denied={Boolean(denied())}
error={error()}
@@ -2225,9 +2294,9 @@ function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.El
}
function StatusBadge(props: { children: string }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<text flexShrink={0} bg={theme.backgroundElement} fg={theme.textMuted}>
<text flexShrink={0} bg={themeV2.background.action.secondary("focused")} fg={themeV2.text.subdued()}>
{" "}
{props.children}{" "}
</text>
@@ -2242,7 +2311,7 @@ function BlockTool(props: {
part?: SessionMessageAssistantTool
spinner?: boolean
}) {
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const ctx = use()
const data = useData()
const renderer = useRenderer()
@@ -2260,9 +2329,11 @@ function BlockTool(props: {
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
backgroundColor={
hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
borderColor={themeV2.background()}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -2277,9 +2348,9 @@ function BlockTool(props: {
{(title) => (
<Show
when={props.spinner}
fallback={<text fg={permission() ? theme.warning : theme.textMuted}>{title()}</text>}
fallback={<text fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title()}</text>}
>
<Spinner color={permission() ? theme.warning : theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>{title().replace(/^# /, "")}</Spinner>
</Show>
)}
</Show>
@@ -2290,33 +2361,33 @@ function BlockTool(props: {
<Show
when={props.spinner}
fallback={
<text flexShrink={0} fg={permission() ? theme.warning : theme.textMuted}>
<text flexShrink={0} fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
{path().label}
</text>
}
>
<Spinner color={permission() ? theme.warning : theme.textMuted}>
<Spinner color={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}>
{path().label.replace(/^# /, "")}
</Spinner>
</Show>
<FilePath
value={path().value}
maxWidth={Math.max(2, ctx.width - 4 - Bun.stringWidth(path().label) - (props.spinner ? 2 : 0))}
fg={permission() ? theme.warning : theme.textMuted}
fg={permission() ? themeV2.text.feedback.warning() : themeV2.text.subdued()}
/>
</box>
)}
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
<text fg={themeV2.text.feedback.error()}>{error()}</text>
</Show>
</box>
)
}
function Shell(props: ToolProps) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const ctx = use()
const client = useClient()
const data = useData()
@@ -2324,7 +2395,7 @@ function Shell(props: ToolProps) {
const request = data.session.permission.list(ctx.sessionID)?.[0]
return request?.source?.type === "tool" && request.source.callID === props.part.id
})
const color = createMemo(() => (permission() ? theme.warning : theme.text))
const color = createMemo(() => (permission() ? themeV2.text.feedback.warning() : themeV2.text()))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const backgroundRunning = createMemo(() => {
const id = shellID()
@@ -2386,7 +2457,7 @@ function Shell(props: ToolProps) {
isRunning() || props.part.state.status === "streaming" ? (
<Spinner color={color()}>Writing command...</Spinner>
) : (
<text fg={theme.textMuted}>Writing command...</text>
<text fg={themeV2.text.subdued()}>Writing command...</text>
)
}
>
@@ -2394,14 +2465,14 @@ function Shell(props: ToolProps) {
when={isRunning()}
fallback={
<text>
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
</text>
}
>
<Spinner color={color()}>
<span style={{ fg: theme.text }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: theme.textMuted }}>{limited().slice(input().length)}</span>
<span style={{ fg: themeV2.text() }}>{limited().slice(0, input().length)}</span>
<span style={{ fg: themeV2.text.subdued() }}>{limited().slice(input().length)}</span>
</Spinner>
</Show>
</Show>
@@ -2414,7 +2485,7 @@ function Shell(props: ToolProps) {
}
function Write(props: ToolProps) {
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => {
return stringValue(props.input.content) ?? ""
@@ -2427,10 +2498,10 @@ function Write(props: ToolProps) {
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<line_number fg={themeV2.text.subdued()} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
fg={themeV2.text()}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
content={code()}
@@ -2462,7 +2533,7 @@ function Glob(props: ToolProps) {
}
function Read(props: ToolProps) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
@@ -2485,7 +2556,7 @@ function Read(props: ToolProps) {
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
<text paddingLeft={3} fg={themeV2.text.subdued()}>
Loaded {pathFormatter.format(filepath)}
</text>
</box>
@@ -2528,10 +2599,8 @@ function WebSearch(props: ToolProps) {
function Subagent(props: ToolProps) {
const { navigate } = useRoute()
const data = useData()
const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input))
const metadata = createMemo(() => (props.part.state.status === "streaming" ? {} : props.part.state.structured))
const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId))
const description = createMemo(() => stringValue(input().description))
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
@@ -2549,16 +2618,23 @@ function Subagent(props: ToolProps) {
if (id) navigate({ type: "session", sessionID: id })
}}
status={
input().background === true || metadata().status === "running" ? (
isBackgroundSubagent(props.metadata, props.part.state.status) ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
>
{`${Locale.titlecase(stringValue(input().agent) ?? stringValue(input().subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
{`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
</InlineTool>
)
}
export function isBackgroundSubagent(
metadata: Record<string, unknown>,
status: SessionMessageAssistantTool["state"]["status"],
) {
return status === "completed" && metadata.status === "running"
}
export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
@@ -2579,7 +2655,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
function Execute(props: ToolProps) {
const ctx = use()
const { theme } = useTheme()
const { themeV2 } = useTheme()
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
@@ -2599,7 +2675,7 @@ function Execute(props: ToolProps) {
<>
<InlineTool
icon={hasRuntimeError() ? "✗" : props.part.state.status === "completed" ? "✓" : "│"}
color={hasRuntimeError() ? theme.error : undefined}
color={hasRuntimeError() ? themeV2.text.feedback.error() : undefined}
spinner={isLoading()}
pending="execute"
complete={true}
@@ -2611,7 +2687,7 @@ function Execute(props: ToolProps) {
<box paddingLeft={3}>
<For each={outputPreview().split("\n")}>
{(line, index) => (
<text paddingLeft={3} fg={theme.error}>
<text paddingLeft={3} fg={themeV2.text.feedback.error()}>
{index() === 0 ? "↳ " : " "}
{line}
</text>
@@ -2625,7 +2701,7 @@ function Execute(props: ToolProps) {
function Edit(props: ToolProps) {
const ctx = use()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
@@ -2653,16 +2729,16 @@ function Edit(props: ToolProps) {
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
fg={themeV2.text()}
addedBg={themeV2.diff.background.added()}
removedBg={themeV2.diff.background.removed()}
contextBg={themeV2.diff.background.context()}
addedSignColor={themeV2.diff.highlight.added()}
removedSignColor={themeV2.diff.highlight.removed()}
lineNumberFg={themeV2.diff.lineNumber.text()}
lineNumberBg={themeV2.diff.background.context()}
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
/>
</box>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
@@ -2687,7 +2763,7 @@ function Edit(props: ToolProps) {
function ApplyPatch(props: ToolProps) {
const ctx = use()
const { theme, syntax } = useTheme()
const { themeV2, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => parseApplyPatchFiles(props.metadata.files))
const targets = createMemo(() => {
@@ -2727,7 +2803,7 @@ function ApplyPatch(props: ToolProps) {
<Show
when={file.type !== "delete"}
fallback={
<text fg={theme.diffRemoved}>
<text fg={themeV2.diff.text.removed()}>
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
</text>
}
@@ -2741,16 +2817,16 @@ function ApplyPatch(props: ToolProps) {
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
fg={themeV2.text()}
addedBg={themeV2.diff.background.added()}
removedBg={themeV2.diff.background.removed()}
contextBg={themeV2.diff.background.context()}
addedSignColor={themeV2.diff.highlight.added()}
removedSignColor={themeV2.diff.highlight.removed()}
lineNumberFg={themeV2.diff.lineNumber.text()}
lineNumberBg={themeV2.diff.background.context()}
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
/>
</box>
</Show>
@@ -2773,7 +2849,7 @@ function ApplyPatch(props: ToolProps) {
<FilePath
value={file.resource}
maxWidth={Math.max(2, ctx.width - 3)}
fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}
fg={file.type === "delete" ? themeV2.diff.text.removed() : themeV2.text.subdued()}
/>
</BlockTool>
)}
@@ -2802,7 +2878,7 @@ function ApplyPatch(props: ToolProps) {
}
function Question(props: ToolProps) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const questions = createMemo(() => parseQuestions(props.input.questions))
const answers = createMemo(() => parseQuestionAnswers(props.metadata.answers))
const count = createMemo(() => questions().length)
@@ -2820,8 +2896,8 @@ function Question(props: ToolProps) {
<For each={questions()}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(answers()?.[i()])}</text>
<text fg={themeV2.text.subdued()}>{q.question}</text>
<text fg={themeV2.text()}>{format(answers()?.[i()])}</text>
</box>
)}
</For>
@@ -2847,7 +2923,7 @@ function Skill(props: ToolProps) {
}
function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
const terminalEnvironment = useTuiTerminalEnvironment()
const errors = createMemo(() => {
const normalized = normalizePath(
@@ -2862,7 +2938,7 @@ function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
<box>
<For each={errors()}>
{(diagnostic) => (
<text fg={theme.error}>
<text fg={themeV2.text.feedback.error()}>
Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message}
</text>
)}
@@ -0,0 +1,48 @@
import type { SessionMessageInfo } from "@opencode-ai/client"
type MessageChild = {
readonly id?: string
readonly y: number
}
export function messageNavigationSlack(input: {
top: number
viewportHeight: number
scrollHeight: number
currentSlack: number
}) {
const contentHeight = input.scrollHeight - input.currentSlack
return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
}
export function findMessageBoundary(input: {
direction: "next" | "prev"
children: readonly MessageChild[]
messages: readonly SessionMessageInfo[]
scrollTop: number
viewportY: number
currentID?: string
userOnly?: boolean
}) {
const messages = new Map(input.messages.map((message) => [message.id, message]))
const visible = input.children
.flatMap((child) => {
if (!child.id) return []
const message = messages.get(child.id)
if (!message) return []
if (message.type === "user" && message.text.trim()) {
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: y }]
}
if (input.userOnly || message.type !== "assistant") return []
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
})
.sort((a, b) => a.y - b.y)
const current = visible.findIndex((child) => child.id === input.currentID)
if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
return visible.findLast((child) => child.y < input.scrollTop) ?? null
}
+75 -69
View File
@@ -3,7 +3,7 @@ import { dirname } from "node:path"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
import { useTheme } from "../../context/theme"
import type { PermissionV2Request } from "@opencode-ai/client"
import { useClient } from "../../context/client"
import { SplitBorder } from "../../ui/border"
@@ -20,7 +20,7 @@ type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
const themeState = useTheme()
const theme = themeState.theme
const themeV2 = themeState.themeV2
const syntax = themeState.syntax
const config = useConfig().data
const dimensions = useTerminalDimensions()
@@ -51,8 +51,8 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
backgroundColor: themeV2.background(),
foregroundColor: themeV2.scrollbar(),
},
}}
>
@@ -64,16 +64,16 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
fg={themeV2.text()}
addedBg={themeV2.diff.background.added()}
removedBg={themeV2.diff.background.removed()}
contextBg={themeV2.diff.background.context()}
addedSignColor={themeV2.diff.highlight.added()}
removedSignColor={themeV2.diff.highlight.removed()}
lineNumberFg={themeV2.diff.lineNumber.text()}
lineNumberBg={themeV2.diff.background.context()}
addedLineNumberBg={themeV2.diff.lineNumber.background.added()}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed()}
/>
</scrollbox>
</Show>
@@ -82,7 +82,7 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
when={props.patch}
fallback={
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
<text fg={themeV2.text.subdued()}>No diff provided</text>
</box>
}
>
@@ -92,8 +92,8 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
backgroundColor: themeV2.background(),
foregroundColor: themeV2.scrollbar(),
},
}}
>
@@ -103,7 +103,7 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
streaming={true}
syntaxStyle={syntax()}
content={patch()}
fg={theme.textMuted}
fg={themeV2.text.subdued()}
/>
</scrollbox>
)}
@@ -114,20 +114,20 @@ function EditBody(props: { request: PermissionV2Request; patch?: string }) {
}
function TextBody(props: { title: string; description?: string; icon?: string }) {
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
<text fg={themeV2.text.subdued()} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
<text fg={themeV2.text.subdued()}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
<text fg={themeV2.text()}>{props.description}</text>
</box>
</Show>
</>
@@ -153,7 +153,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
return {}
})
const { theme } = useTheme()
const { themeV2 } = useTheme()
return (
<Switch>
@@ -167,11 +167,11 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<text fg={themeV2.text.subdued()}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.save ?? []}>
{(pattern) => (
<text fg={theme.text}>
<text fg={themeV2.text()}>
{"- "}
{pattern}
</text>
@@ -235,7 +235,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(filePath)}</text>
</box>
</Show>
),
@@ -250,7 +250,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
@@ -265,7 +265,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
<text fg={themeV2.text.subdued()}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
@@ -281,7 +281,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
<text fg={themeV2.text.subdued()}>{"Path: " + pathFormatter.format(dir)}</text>
</box>
</Show>
),
@@ -294,7 +294,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
<text fg={themeV2.text()}>{"$ " + command}</text>
</box>
</Show>
),
@@ -315,7 +315,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
<text fg={themeV2.text()}>{"◉ " + desc}</text>
</box>
</Show>
),
@@ -330,7 +330,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
<text fg={themeV2.text.subdued()}>{"URL: " + url}</text>
</box>
</Show>
),
@@ -345,7 +345,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
<text fg={themeV2.text.subdued()}>{"Query: " + query}</text>
</box>
</Show>
),
@@ -370,9 +370,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
body: (
<Show when={patterns.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<text fg={themeV2.text.subdued()}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
<For each={patterns}>{(p) => <text fg={themeV2.text()}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
@@ -386,7 +386,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
title: "Continue after repeated failures",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
<text fg={themeV2.text.subdued()}>This keeps the session running despite repeated failures.</text>
</box>
),
}
@@ -397,7 +397,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
<text fg={themeV2.text.subdued()}>{"Tool: " + permission}</text>
</box>
),
}
@@ -408,15 +408,15 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const header = () => (
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
<text fg={themeV2.text.feedback.warning()}>{"△"}</text>
<text fg={themeV2.text()}>Permission required</text>
</box>
<Show when={current.title}>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
<text fg={themeV2.text.subdued()} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
<text fg={themeV2.text()}>{current.title}</text>
</box>
</Show>
</box>
@@ -469,7 +469,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
Keymap.createLayer(() => ({
@@ -495,18 +495,18 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
return (
<box
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={theme.error}
borderColor={themeV2.text.feedback.error()}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.error}>{"△"}</text>
<text fg={theme.text}>Reject permission</text>
<text fg={themeV2.text.feedback.error()}>{"△"}</text>
<text fg={themeV2.text()}>Reject permission</text>
</box>
<box paddingLeft={1}>
<text fg={theme.textMuted}>Tell OpenCode what to do differently</text>
<text fg={themeV2.text.subdued()}>Tell OpenCode what to do differently</text>
</box>
</box>
<box
@@ -516,7 +516,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
backgroundColor={themeV2.background.action.secondary("focused")}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
gap={1}
@@ -527,16 +527,16 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
val.traits = { status: "REJECT" }
}}
focused
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
textColor={themeV2.text()}
focusedTextColor={themeV2.text()}
cursorColor={themeV2.text()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
<text fg={themeV2.text()}>
enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
<text fg={themeV2.text()}>
esc <span style={{ fg: themeV2.text.subdued() }}>cancel</span>
</text>
</box>
</box>
@@ -553,7 +553,7 @@ function Prompt<const T extends Record<string, string>>(props: {
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
@@ -654,9 +654,9 @@ function Prompt<const T extends Record<string, string>>(props: {
const content = () => (
<box
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={theme.warning}
borderColor={themeV2.background.action.primary("focused")}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
@@ -674,8 +674,8 @@ function Prompt<const T extends Record<string, string>>(props: {
when={props.header}
fallback={
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
<text fg={themeV2.text.feedback.warning()}>{"△"}</text>
<text fg={themeV2.text()}>{props.title}</text>
</box>
}
>
@@ -693,7 +693,7 @@ function Prompt<const T extends Record<string, string>>(props: {
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
backgroundColor={themeV2.background.action.secondary("focused")}
justifyContent={narrow() ? "flex-start" : "space-between"}
alignItems={narrow() ? "flex-start" : "center"}
>
@@ -703,14 +703,20 @@ function Prompt<const T extends Record<string, string>>(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
backgroundColor={themeV2.background.action.primary(
option === store.selected ? "focused" : "default",
)}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
<text
fg={themeV2.text.action.primary(
option === store.selected ? "focused" : "default",
)}
>
{props.options[option]}
</text>
</box>
@@ -719,15 +725,15 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.textMuted }}>{hint()}</span>
<text fg={themeV2.text()}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
<text fg={themeV2.text()}>
{"⇆"} <span style={{ fg: themeV2.text.subdued() }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
<text fg={themeV2.text()}>
enter <span style={{ fg: themeV2.text.subdued() }}>confirm</span>
</text>
</box>
</box>
+30
View File
@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}, [])
}
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set<string>()
return rows.map((row) => {
const id = rowBoundaryMessageID(row, byID)
if (!id || seen.has(id)) return undefined
seen.add(id)
return id
})
}
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
if (row.type === "message") {
const message = messages.get(row.messageID)
if (message?.type === "user" && message.text.trim()) return message.id
return undefined
}
const messageID =
row.type === "part"
? row.ref.messageID
: row.type === "group"
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: undefined
if (!messageID) return undefined
const message = messages.get(messageID)
if (message?.type === "assistant") return message.id
}
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
if (tool) return tool
+6 -6
View File
@@ -10,7 +10,7 @@ import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const data = useData()
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const config = useConfig().data
const session = createMemo(() => data.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -18,7 +18,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
return (
<Show when={session()}>
<box
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
width={42}
height="100%"
paddingTop={1}
@@ -32,8 +32,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
foregroundColor: theme.borderActive,
backgroundColor: themeV2.background(),
foregroundColor: themeV2.scrollbar(),
},
}}
>
@@ -45,11 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
title={session()!.title}
>
<box paddingRight={1}>
<text fg={theme.text}>
<text fg={themeV2.text()}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}>{session()!.location.workspaceID}</text>
<text fg={themeV2.text.subdued()}>{session()!.location.workspaceID}</text>
</Show>
</box>
</pluginRuntime.Slot>
@@ -46,7 +46,7 @@ export function SubagentFooter() {
}
})
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
@@ -61,18 +61,18 @@ export function SubagentFooter() {
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
borderColor={themeV2.border()}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
backgroundColor={themeV2.background()}
>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>
<text fg={themeV2.text()}>
<b>{subagentInfo()}</b>
</text>
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
<text fg={themeV2.text.subdued()} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
@@ -83,30 +83,30 @@ export function SubagentFooter() {
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? themeV2.background.action.secondary("focused") : themeV2.background()}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.parent")}</span>
<text fg={themeV2.text()}>
Parent <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "prev" ? themeV2.background.action.secondary("focused") : themeV2.background()}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.child.previous")}</span>
<text fg={themeV2.text()}>
Prev <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.previous")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatch("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "next" ? themeV2.background.action.secondary("focused") : themeV2.background()}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{shortcuts.get("session.child.next")}</span>
<text fg={themeV2.text()}>
Next <span style={{ fg: themeV2.text.subdued() }}>{shortcuts.get("session.child.next")}</span>
</text>
</box>
</box>
+88 -57
View File
@@ -1,13 +1,37 @@
import type { RGBA } from "@opentui/core"
import type { Accessor } from "solid-js"
import type { ActionState, ActionVariant, ResolvedActionState, ResolvedThemeView } from "./index"
import type {
ActionState,
ActionVariant,
FormfieldState,
ResolvedActionState,
ResolvedFormfieldState,
ResolvedThemeView,
HueStep,
} from "./index"
export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
const textAction = actions((variant, state) => current().color.text.action[variant][state])
const backgroundAction = actions((variant, state) => current().color.background.action[variant][state])
const text = Object.assign(() => current().color.text.default, {
subdued: () => current().color.text.subdued,
const textAction = actions((variant, state) => current().text.action[variant][state])
const backgroundAction = actions((variant, state) => current().background.action[variant][state])
const textFormfield = formfield((state) => current().text.formfield[state])
const backgroundFormfield = formfield((state) => current().background.formfield[state])
const hue = {
gray: (step: HueStep) => current().hue.gray[step],
red: (step: HueStep) => current().hue.red[step],
orange: (step: HueStep) => current().hue.orange[step],
yellow: (step: HueStep) => current().hue.yellow[step],
green: (step: HueStep) => current().hue.green[step],
cyan: (step: HueStep) => current().hue.cyan[step],
blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step],
interactive: (step: HueStep) => current().hue.interactive[step],
neutral: (step: HueStep) => current().hue.neutral[step],
}
const text = Object.assign(() => current().text.default, {
subdued: () => current().text.subdued,
action: textAction,
formfield: textFormfield,
feedback: {
error: feedbackText("error"),
warning: feedbackText("warning"),
@@ -15,81 +39,84 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
info: feedbackText("info"),
},
})
const background = Object.assign(() => current().color.background.default, {
const background = Object.assign(() => current().background.default, {
surface: {
offset: () => current().background.surface.offset,
overlay: () => current().background.surface.overlay,
},
action: backgroundAction,
formfield: backgroundFormfield,
feedback: {
error: () => current().color.background.feedback.error.default,
warning: () => current().color.background.feedback.warning.default,
success: () => current().color.background.feedback.success.default,
info: () => current().color.background.feedback.info.default,
error: () => current().background.feedback.error.default,
warning: () => current().background.feedback.warning.default,
success: () => current().background.feedback.success.default,
info: () => current().background.feedback.info.default,
},
})
const markdown = Object.assign(() => current().color.markdown.text, {
heading: () => current().color.markdown.heading,
link: () => current().color.markdown.link,
linkText: () => current().color.markdown.linkText,
code: () => current().color.markdown.code,
blockQuote: () => current().color.markdown.blockQuote,
emphasis: () => current().color.markdown.emphasis,
strong: () => current().color.markdown.strong,
horizontalRule: () => current().color.markdown.horizontalRule,
listItem: () => current().color.markdown.listItem,
listEnumeration: () => current().color.markdown.listEnumeration,
image: () => current().color.markdown.image,
imageText: () => current().color.markdown.imageText,
codeBlock: () => current().color.markdown.codeBlock,
const markdown = Object.assign(() => current().markdown.text, {
heading: () => current().markdown.heading,
link: () => current().markdown.link,
linkText: () => current().markdown.linkText,
code: () => current().markdown.code,
blockQuote: () => current().markdown.blockQuote,
emphasis: () => current().markdown.emphasis,
strong: () => current().markdown.strong,
horizontalRule: () => current().markdown.horizontalRule,
listItem: () => current().markdown.listItem,
listEnumeration: () => current().markdown.listEnumeration,
image: () => current().markdown.image,
imageText: () => current().markdown.imageText,
codeBlock: () => current().markdown.codeBlock,
})
function feedbackText(kind: "error" | "warning" | "success" | "info") {
return Object.assign(() => current().color.text.feedback[kind].default, {
subdued: () => current().color.text.feedback[kind].subdued,
return Object.assign(() => current().text.feedback[kind].default, {
subdued: () => current().text.feedback[kind].subdued,
})
}
return {
hue: () => current().hue,
color: {
text,
background,
border: () => current().color.border.default,
scrollbar: () => current().color.scrollbar.default,
diff: {
hue,
text,
background,
border: () => current().border.default,
scrollbar: () => current().scrollbar.default,
diff: {
text: {
added: () => current().color.diff.text.added,
removed: () => current().color.diff.text.removed,
context: () => current().color.diff.text.context,
hunkHeader: () => current().color.diff.text.hunkHeader,
added: () => current().diff.text.added,
removed: () => current().diff.text.removed,
context: () => current().diff.text.context,
hunkHeader: () => current().diff.text.hunkHeader,
},
background: {
added: () => current().color.diff.background.added,
removed: () => current().color.diff.background.removed,
context: () => current().color.diff.background.context,
added: () => current().diff.background.added,
removed: () => current().diff.background.removed,
context: () => current().diff.background.context,
},
highlight: {
added: () => current().color.diff.highlight.added,
removed: () => current().color.diff.highlight.removed,
added: () => current().diff.highlight.added,
removed: () => current().diff.highlight.removed,
},
lineNumber: {
text: () => current().color.diff.lineNumber.text,
text: () => current().diff.lineNumber.text,
background: {
added: () => current().color.diff.lineNumber.background.added,
removed: () => current().color.diff.lineNumber.background.removed,
added: () => current().diff.lineNumber.background.added,
removed: () => current().diff.lineNumber.background.removed,
},
},
},
syntax: {
comment: () => current().color.syntax.comment,
keyword: () => current().color.syntax.keyword,
function: () => current().color.syntax.function,
variable: () => current().color.syntax.variable,
string: () => current().color.syntax.string,
number: () => current().color.syntax.number,
type: () => current().color.syntax.type,
operator: () => current().color.syntax.operator,
punctuation: () => current().color.syntax.punctuation,
},
markdown,
syntax: {
comment: () => current().syntax.comment,
keyword: () => current().syntax.keyword,
function: () => current().syntax.function,
variable: () => current().syntax.variable,
string: () => current().syntax.string,
number: () => current().syntax.number,
type: () => current().syntax.type,
operator: () => current().syntax.operator,
punctuation: () => current().syntax.punctuation,
},
markdown,
}
}
@@ -103,4 +130,8 @@ function actions(get: (variant: ActionVariant, state: ResolvedActionState) => RG
})
}
function formfield(get: (state: ResolvedFormfieldState) => RGBA) {
return (state: FormfieldState | "default" = "default") => get(state)
}
export type ComponentTheme = ReturnType<typeof createComponentTheme>
+172 -124
View File
@@ -93,62 +93,91 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.interactive.600",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
secondary: {
default: "$hue.neutral.200",
$focused: "$hue.neutral.300",
$pressed: "$hue.neutral.400",
$selected: "$hue.neutral.300",
$disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
},
},
background: {
default: "$hue.neutral.100",
action: {
primary: {
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
},
secondary: {
default: "$hue.neutral.200", $hovered: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$selected: "$hue.neutral.300", $disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.300",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
syntax: {
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700",
removed: "$hue.red.700",
context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
@@ -159,7 +188,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
@@ -174,24 +203,19 @@ export const DEFAULT_THEME = {
image: "$hue.accent.600",
imageText: "$hue.cyan.600",
codeBlock: "$hue.neutral.900",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.200",
action: { primary: { default: "$hue.accent.500" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.500" } },
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.300",
action: { primary: { default: "$hue.accent.500" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
@@ -286,62 +310,91 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.interactive.500",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
secondary: {
default: "$hue.neutral.800",
$focused: "$hue.neutral.700",
$pressed: "$hue.neutral.900",
$selected: "$hue.neutral.700",
$disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
},
},
background: {
default: "$hue.neutral.900",
action: {
primary: {
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
},
secondary: {
default: "$hue.neutral.800", $hovered: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$selected: "$hue.neutral.700", $disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600", $hovered: "$hue.red.700", $pressed: "$hue.red.800",
$selected: "$hue.red.700", $disabled: "$hue.neutral.800",
},
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
syntax: {
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
@@ -352,7 +405,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
@@ -367,24 +420,19 @@ export const DEFAULT_THEME = {
image: "$hue.accent.400",
imageText: "$hue.cyan.400",
codeBlock: "$hue.neutral.100",
},
},
"@context:elevated": {
color: {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$hue.neutral.800",
action: { primary: { default: "$hue.accent.400" } },
},
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.interactive.400" } },
},
},
"@context:overlay": {
color: {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$hue.neutral.700",
action: { primary: { default: "$hue.accent.400" } },
},
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.interactive.400" } },
},
},
},

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