Compare commits

...

9 Commits

Author SHA1 Message Date
Kit Langton fb8d775fec fix(core): reject unknown session interrupts 2026-07-16 10:50:11 -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
78 changed files with 2632 additions and 1083 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",
+2
View File
@@ -18,6 +18,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 +110,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")({
+10
View File
@@ -0,0 +1,10 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
}) {}
+4 -4
View File
@@ -27,13 +27,13 @@ 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"
@@ -75,8 +75,8 @@ const locationServiceNodes = [
ToolRegistry.node,
ToolRegistry.toolsNode,
Image.node,
SkillGuidance.node,
ReferenceGuidance.node,
SkillInstructions.node,
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
QuestionV2.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) =>
@@ -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) => ({
+7 -2
View File
@@ -276,7 +276,7 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -804,7 +804,12 @@ const layer = Layer.effect(
),
),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
}),
),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
+12 -12
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"
@@ -52,11 +52,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 +72,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 +108,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,
],
})
+41 -15
View File
@@ -15,27 +15,53 @@ 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 admission = yield* Instructions.diff(observed, stored?.current_values)
return {
sessionID,
initial: !stored,
current: Instructions.applyHashDelta(stored?.current_values ?? {}, admission.delta),
...admission,
}
})
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* (
@@ -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)
+41 -8
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")
@@ -169,7 +188,13 @@ export const Plugin = {
yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false })
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
}).pipe(
Effect.onInterrupt(() =>
runtime.session
.interrupt(child.id)
.pipe(Effect.catchTag("Session.NotFoundError", () => Effect.void)),
),
)
const info = yield* runtime.job.start({
id: child.id,
@@ -189,13 +214,21 @@ export const Plugin = {
}
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() =>
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
discard: true,
}),
),
)
const result = yield* runtime.job
.block({ id: child.id, sessionID: context.sessionID })
.pipe(
Effect.onInterrupt(() =>
Effect.all(
[
runtime.session
.interrupt(child.id)
.pipe(Effect.catchTag("Session.NotFoundError", () => Effect.void)),
runtime.job.cancel(child.id),
],
{ discard: true },
),
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
return {
+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",
}),
+4
View File
@@ -77,6 +77,10 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental:
info.experimental?.subagent_depth === undefined
? undefined
: { subagent_depth: info.experimental.subagent_depth },
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
+6
View File
@@ -283,6 +283,12 @@ 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 setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
@@ -0,0 +1,292 @@
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)
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("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: {},
},
])
}),
)
})
+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,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) }))),
)
})
})
+6 -3
View File
@@ -156,13 +156,16 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("delegates interruption without requiring a recorded Session", () =>
it.effect("rejects interruption for an unknown Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
interruptCalls.length = 0
const missing = SessionV2.ID.make("ses_missing")
yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
expect(yield* session.interrupt(missing).pipe(Effect.flip)).toEqual(
new SessionV2.NotFoundError({ sessionID: missing }),
)
expect(interruptCalls).toEqual([])
}),
)
@@ -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],
+16 -14
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
@@ -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])))
})
})
+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()),
+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" }),
})
+2 -2
View File
@@ -54,7 +54,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme().theme
const { theme, themeV2 } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -84,7 +84,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
theme.secondary,
themeV2.hue.accent(500),
theme.accent,
theme.success,
theme.warning,
+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,8 @@ 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 fg = themeV2.text.action.primary("focused")
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -96,8 +96,12 @@ 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>}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<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 +110,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() : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())}
>
<text
fg={active() ? fg : theme.text}
fg={active() ? fg : themeV2.text()}
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,8 @@ 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 fg = themeV2.text.action.primary("focused")
const navigate = useRoute().navigate
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -200,7 +200,7 @@ export function SubagentsTab(props: { sessionID: string }) {
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
<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 +213,7 @@ export function SubagentsTab(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() : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
@@ -222,7 +222,7 @@ export function SubagentsTab(props: { sessionID: string }) {
>
<box flexGrow={1} minWidth={0} flexDirection="row">
<text
fg={active() ? fg : entry.current ? theme.primary : theme.text}
fg={active() ? fg : entry.current ? themeV2.background.action.primary() : themeV2.text()}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -230,7 +230,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text>
</box>
<Show when={status()}>
<text fg={active() ? fg : theme.textMuted} wrapMode="none">
<text fg={active() ? fg : 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>
+171 -151
View File
@@ -108,7 +108,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)
@@ -842,8 +842,8 @@ 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}
@@ -987,7 +987,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 +1005,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 +1076,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 +1116,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 +1165,7 @@ function SessionReasoningGroupView(props: {
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
borderColor={themeV2.background.action.secondary("focused")}
paddingLeft={1}
>
<code
@@ -1170,7 +1175,7 @@ function SessionReasoningGroupView(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
fg={themeV2.text.subdued()}
/>
</box>
</box>
@@ -1192,7 +1197,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 +1233,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 +1259,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 +1279,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 +1307,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 +1316,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 +1344,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 +1360,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 +1368,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 +1378,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 +1411,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 +1421,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 +1450,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 +1475,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 +1491,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 +1501,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 +1523,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 +1533,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 +1550,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(
@@ -1560,7 +1565,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? theme.border : color()}
borderColor={queued() ? themeV2.border() : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box
@@ -1583,19 +1588,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 +1627,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 +1713,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 +1725,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 +1741,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 +1756,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 +1769,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 +1779,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 +1796,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 +1824,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 +1842,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 +1852,7 @@ function ReasoningPart(props: {
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
fg={themeV2.text.subdued()}
/>
</box>
</box>
@@ -1861,11 +1874,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 +1918,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 +1929,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 +2019,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 +2037,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 +2046,7 @@ function GenericTool(props: ToolProps) {
syntaxStyle={syntax()}
conceal={false}
drawUnstyledText={false}
fg={theme.text}
fg={themeV2.text()}
/>
</box>
</box>
@@ -2037,10 +2055,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 +2084,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 +2110,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 +2121,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 +2243,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 +2260,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 +2278,11 @@ function BlockTool(props: {
paddingBottom={1}
paddingLeft={2}
gap={1}
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
backgroundColor={
hover() ? themeV2.background.action.secondary() : themeV2.background()
}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
borderColor={themeV2.background()}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -2277,9 +2297,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 +2310,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 +2344,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 +2406,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 +2414,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 +2434,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 +2447,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 +2482,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 +2505,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>
@@ -2579,7 +2599,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 +2619,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 +2631,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 +2645,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 +2673,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 +2707,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 +2747,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 +2761,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 +2793,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 +2822,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 +2840,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 +2867,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 +2882,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>
)}
+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.text.feedback.warning()}
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>
+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>
+87 -57
View File
@@ -1,13 +1,36 @@
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],
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 +38,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 +129,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>
+74 -50
View File
@@ -95,8 +95,7 @@ export const DEFAULT_THEME = {
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
@@ -104,6 +103,13 @@ export const DEFAULT_THEME = {
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
@@ -113,25 +119,36 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.accent.600", $hovered: "$hue.accent.700", $pressed: "$hue.accent.800",
$selected: "$hue.accent.700", $disabled: "$hue.neutral.300",
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
$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",
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$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",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.300" },
@@ -174,24 +191,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.accent.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.accent.500" } },
},
},
},
@@ -288,8 +300,7 @@ export const DEFAULT_THEME = {
accent: "$hue.blue",
neutral: "$hue.gray",
},
color: {
text: {
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
@@ -297,6 +308,13 @@ export const DEFAULT_THEME = {
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
@@ -306,25 +324,36 @@ export const DEFAULT_THEME = {
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.accent.500", $hovered: "$hue.accent.600", $pressed: "$hue.accent.800",
$selected: "$hue.accent.600", $disabled: "$hue.neutral.800",
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
$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",
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$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",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$color.background.default" },
warning: { default: "$color.background.default" },
success: { default: "$color.background.default" },
info: { default: "$color.background.default" },
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.700" },
@@ -367,24 +396,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.accent.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.accent.400" } },
},
},
},
+23 -11
View File
@@ -1,11 +1,12 @@
import type {
BackgroundDefinition,
FormfieldColorDefinition,
ModeDefinition,
StatefulColorDefinition,
TextDefinition,
ThemeTokensDefinition,
} from "./index"
import { ActionState } from "./schema"
import { ActionState, FormfieldState } from "./schema"
export function expandTheme<Definition extends ModeDefinition>(definition: Definition): Definition {
return {
@@ -20,14 +21,10 @@ export function expandTheme<Definition extends ModeDefinition>(definition: Defin
}
export function expandTokens(definition: ThemeTokensDefinition): ThemeTokensDefinition {
if (!definition.color) return { ...definition }
return {
...definition,
color: {
...definition.color,
text: expandText(definition.color.text),
background: expandBackground(definition.color.background),
},
text: expandText(definition.text),
background: expandBackground(definition.background),
}
}
@@ -48,8 +45,9 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
if (!definition) return
return {
...definition,
subdued: definition.subdued ?? (definition.default ? "$color.text.default" : undefined),
action: expandActions(definition.action, "color.text.action"),
subdued: definition.subdued ?? (definition.default ? "$text.default" : undefined),
action: expandActions(definition.action, "text.action"),
formfield: expandFormfield(definition.formfield, "text.formfield"),
feedback: definition.feedback
? Object.fromEntries(
Object.entries(definition.feedback).map(([kind, feedback]) => {
@@ -57,7 +55,7 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
kind,
{
...feedback,
subdued: feedback.subdued ?? (feedback.default ? `$color.text.feedback.${kind}.default` : undefined),
subdued: feedback.subdued ?? (feedback.default ? `$text.feedback.${kind}.default` : undefined),
},
]
}),
@@ -68,7 +66,21 @@ function expandText(definition: TextDefinition | undefined): TextDefinition | un
function expandBackground(definition: BackgroundDefinition | undefined): BackgroundDefinition | undefined {
if (!definition) return
return { ...definition, action: expandActions(definition.action, "color.background.action") }
return {
...definition,
action: expandActions(definition.action, "background.action"),
formfield: expandFormfield(definition.formfield, "background.formfield"),
}
}
function expandFormfield(definition: FormfieldColorDefinition | undefined, path: string) {
if (!definition?.default) return definition
return {
...definition,
...Object.fromEntries(
FormfieldState.literals.map((state) => [`$${state}`, definition[`$${state}`] ?? `$${path}.default`]),
),
}
}
function expandActions<Definition extends Partial<Record<string, StatefulColorDefinition>>>(
+14 -13
View File
@@ -5,26 +5,28 @@ export function fallback(): ThemeTokensDefinition {
const red = "#ff0000"
return {
color: {
text: {
text: {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {
},
background: {
default: red,
surface: { offset: red, overlay: red },
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
border: { default: red },
scrollbar: { default: red },
diff: {
},
border: { default: red },
scrollbar: { default: red },
diff: {
text: { added: red, removed: red, context: red, hunkHeader: red },
background: { added: red, removed: red, context: red },
highlight: { added: red, removed: red },
lineNumber: { text: red, background: { added: red, removed: red } },
},
syntax: {
},
syntax: {
comment: red,
keyword: red,
function: red,
@@ -34,8 +36,8 @@ export function fallback(): ThemeTokensDefinition {
type: red,
operator: red,
punctuation: red,
},
markdown: {
},
markdown: {
text: red,
heading: red,
link: red,
@@ -50,7 +52,6 @@ export function fallback(): ThemeTokensDefinition {
image: red,
imageText: red,
codeBlock: red,
},
},
}
}
+13 -1
View File
@@ -4,6 +4,8 @@ export {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
type FormfieldStateKey,
HueAlias,
HueStep,
MarkdownDefinition,
@@ -16,6 +18,7 @@ export {
type BackgroundDefinition,
type DiffDefinition,
type FileThemeDefinition,
type FormfieldColorDefinition,
type HueDefinition,
type HueOverrideDefinition,
type MergeModeDefinition,
@@ -26,5 +29,14 @@ export {
type ThemeTokensDefinition,
} from "./schema"
export type { Hue, HueScale, ResolvedActionState, ResolvedTheme, ResolvedThemeView, StatefulColor } from "./types"
export type {
FormfieldColor,
Hue,
HueScale,
ResolvedActionState,
ResolvedFormfieldState,
ResolvedTheme,
ResolvedThemeView,
StatefulColor,
} from "./types"
export { migrateV1 } from "./v1-migrate"
+44 -16
View File
@@ -8,6 +8,7 @@ import {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
HueAlias,
HueStep,
ThemeDefinition,
@@ -25,11 +26,33 @@ import type {
} from "./index"
import { selectTheme, selectThemeMode } from "./select"
const decodeThemeDefinition = Schema.decodeUnknownSync(ThemeDefinition)
const decodeThemeFile = Schema.decodeUnknownSync(ThemeFile)
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile)
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark") {
const decoded = decodeThemeFile(file)
function decodeThemeDefinition(input: unknown) {
try {
return decodeThemeDefinitionSchema(input)
} catch (error) {
throw themeDecodeError(error, "theme")
}
}
function decodeThemeFile(input: unknown, name: string) {
try {
return decodeThemeFileSchema(input)
} catch (error) {
throw themeDecodeError(error, name)
}
}
function themeDecodeError(error: unknown, name: string) {
const message = Schema.isSchemaError(error) ? error.message : String(error)
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
}
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
const decoded = decodeThemeFile(file, name)
const selected = selectThemeMode(decoded, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
@@ -63,24 +86,28 @@ function resolveExpandedTheme(definition: ThemeDefinition): ResolvedTheme {
function tokens(definition: ThemeDefinition): ThemeTokensDefinition {
return {
color: definition.color,
text: definition.text,
background: definition.background,
border: definition.border,
scrollbar: definition.scrollbar,
diff: definition.diff,
syntax: definition.syntax,
markdown: definition.markdown,
}
}
function contextualize(base: ThemeTokensDefinition, override: ThemeTokensDefinition) {
const result = mergeTheme(base, override)
const baseText = base.color?.text?.action
const contextText = override.color?.text?.action
const baseBackground = base.color?.background?.action
const contextBackground = override.color?.background?.action
const color = result["color"] as NonNullable<ThemeTokensDefinition["color"]>
const baseText = base.text?.action
const contextText = override.text?.action
const baseBackground = base.background?.action
const contextBackground = override.background?.action
const text = result["text"] as NonNullable<ThemeTokensDefinition["text"]>
const background = result["background"] as NonNullable<ThemeTokensDefinition["background"]>
return {
...result,
color: {
...color,
text: { ...color.text, action: contextualActions(baseText, contextText) },
background: { ...color.background, action: contextualActions(baseBackground, contextBackground) },
},
text: { ...text, action: contextualActions(baseText, contextText) },
background: { ...background, action: contextualActions(baseBackground, contextBackground) },
} as ThemeTokensDefinition
}
@@ -171,6 +198,7 @@ function createResolver(source: Record<string, unknown>) {
}
function resolveColor(value: string, path: string, stack: string[]) {
if (value === "transparent") return RGBA.fromInts(0, 0, 0, 0)
if (isHex(value)) return RGBA.fromHex(value)
if (!value.startsWith("$")) throw new Error(`Invalid color "${value}" at "${path}"`)
const target = value.slice(1)
@@ -189,7 +217,7 @@ function createResolver(source: Record<string, unknown>) {
function resolvedKey(key: string) {
if (!key.startsWith("$")) return key
const state = key.slice(1)
return (ActionState.literals as readonly string[]).includes(state) ? state : key
return ([...ActionState.literals, ...FormfieldState.literals] as readonly string[]).includes(state) ? state : key
}
function read(source: Record<string, unknown>, path: string) {
+35 -16
View File
@@ -12,10 +12,14 @@ export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["hovered", "pressed", "selected", "focused", "disabled"])
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"])
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
export const FormfieldState = Schema.Literals(["focused", "pressed", "disabled", "selected"])
export type FormfieldState = Schema.Schema.Type<typeof FormfieldState>
export type FormfieldStateKey = `$${FormfieldState}`
export const FeedbackKind = Schema.Literals(["error", "warning", "success", "info"])
export type FeedbackKind = Schema.Schema.Type<typeof FeedbackKind>
@@ -24,7 +28,11 @@ export type Mode = Schema.Schema.Type<typeof Mode>
const HexColor = Schema.String.check(Schema.isPattern(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i))
const ColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$", Schema.NonEmptyString])])
const ColorValue = Schema.Union([
HexColor,
Schema.Literal("transparent"),
Schema.TemplateLiteral(["$", Schema.NonEmptyString]),
])
const HueName = Schema.Union([BaseHue, HueAlias])
const HueColorValue = Schema.Union([HexColor, Schema.TemplateLiteral(["$hue.", HueName, ".", HueStep])])
@@ -65,14 +73,21 @@ export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinit
const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$hovered: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
const FormfieldColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
})
export type FormfieldColorDefinition = Schema.Schema.Type<typeof FormfieldColorDefinition>
const ActionColorDefinition = Schema.Struct({
primary: Schema.optional(StatefulColorDefinition),
secondary: Schema.optional(StatefulColorDefinition),
@@ -92,6 +107,7 @@ const TextDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
subdued: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(FormfieldColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),
@@ -105,7 +121,14 @@ export type TextDefinition = Schema.Schema.Type<typeof TextDefinition>
const BackgroundDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
surface: Schema.optional(
Schema.Struct({
offset: Schema.optional(ColorValue),
overlay: Schema.optional(ColorValue),
}),
),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(FormfieldColorDefinition),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(BackgroundFeedbackDefinition),
@@ -163,17 +186,13 @@ const DiffDefinition = Schema.Struct({
export type DiffDefinition = Schema.Schema.Type<typeof DiffDefinition>
const ThemeTokensDefinition = Schema.Struct({
color: Schema.optional(
Schema.Struct({
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
}),
),
text: Schema.optional(TextDefinition),
background: Schema.optional(BackgroundDefinition),
border: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
scrollbar: Schema.optional(Schema.Struct({ default: Schema.optional(ColorValue) })),
diff: Schema.optional(DiffDefinition),
syntax: Schema.optional(SyntaxDefinition),
markdown: Schema.optional(MarkdownDefinition),
})
export type ThemeTokensDefinition = Schema.Schema.Type<typeof ThemeTokensDefinition>
+37 -30
View File
@@ -4,6 +4,7 @@ import type {
ActionVariant,
BaseHue,
FeedbackKind,
FormfieldState,
HueAlias,
HueStep,
MarkdownToken,
@@ -12,43 +13,49 @@ import type {
} from "./schema"
export type ResolvedActionState = "default" | ActionState
export type ResolvedFormfieldState = "default" | FormfieldState
export type HueScale = Readonly<Record<HueStep, RGBA>>
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
export type FormfieldColor = Readonly<Record<ResolvedFormfieldState, RGBA>>
export type ResolvedThemeView = {
readonly hue: Hue
readonly color: {
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
}
readonly border: { readonly default: RGBA }
readonly scrollbar: { readonly default: RGBA }
readonly diff: {
readonly text: {
readonly added: RGBA
readonly removed: RGBA
readonly context: RGBA
readonly hunkHeader: RGBA
}
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
readonly lineNumber: {
readonly text: RGBA
readonly background: { readonly added: RGBA; readonly removed: RGBA }
}
}
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
readonly text: {
readonly default: RGBA
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {
readonly default: RGBA
readonly surface: {
readonly offset: RGBA
readonly overlay: RGBA
}
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA }>>
}
readonly border: { readonly default: RGBA }
readonly scrollbar: { readonly default: RGBA }
readonly diff: {
readonly text: {
readonly added: RGBA
readonly removed: RGBA
readonly context: RGBA
readonly hunkHeader: RGBA
}
readonly background: { readonly added: RGBA; readonly removed: RGBA; readonly context: RGBA }
readonly highlight: { readonly added: RGBA; readonly removed: RGBA }
readonly lineNumber: {
readonly text: RGBA
readonly background: { readonly added: RGBA; readonly removed: RGBA }
}
}
readonly syntax: Readonly<Record<SyntaxToken, RGBA>>
readonly markdown: Readonly<Record<MarkdownToken, RGBA>>
}
export type ResolvedTheme = ResolvedThemeView & {
+150 -103
View File
@@ -1,5 +1,6 @@
import { RGBA } from "@opentui/core"
import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
@@ -7,109 +8,141 @@ type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItem
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
version: 2,
light: migrateMode(resolveV1(theme, "light")),
dark: migrateMode(resolveV1(theme, "dark")),
standalone: true,
light: migrateMode(resolveV1(theme, "light"), "light"),
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
}
}
function migrateMode(theme: Theme): ThemeFile["light"] {
function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
return {
hue: { accent: accentScale(theme.accent) },
color: {
text: {
hue: {
...DEFAULT_THEME[mode].hue,
accent: hueScale(theme.secondary),
},
text: {
default: color("text"),
subdued: color("textMuted"),
action: {
primary: {
default: selected,
$disabled: color("textMuted"),
$focused: selected,
},
secondary: {
default: "$text.default",
$disabled: color("textMuted"),
},
destructive: { default: destructive, $disabled: color("textMuted") },
},
formfield: {
default: color("text"),
subdued: color("textMuted"),
action: {
primary: { default: selected },
secondary: {
default: color("text"),
$hovered: color("text"),
$pressed: color("text"),
$selected: color("text"),
$focused: color("text"),
$disabled: color("textMuted"),
},
destructive: { default: destructive },
},
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
},
$focused: color("primary"),
$pressed: color("primary"),
$disabled: color("textMuted"),
$selected: color("primary"),
},
background: {
default: color("background"),
action: {
primary: { default: color("primary") },
secondary: {
default: color("backgroundMenu"),
$hovered: color("backgroundElement"),
$pressed: color("backgroundElement"),
$selected: color("backgroundMenu"),
$focused: color("backgroundElement"),
$disabled: color("backgroundMenu"),
},
destructive: { default: color("error") },
},
},
border: { default: color("border") },
scrollbar: { default: color("borderActive") },
diff: {
text: {
added: color("diffAdded"),
removed: color("diffRemoved"),
context: color("diffContext"),
hunkHeader: color("diffHunkHeader"),
},
background: {
added: color("diffAddedBg"),
removed: color("diffRemovedBg"),
context: color("diffContextBg"),
},
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
lineNumber: {
text: color("diffLineNumber"),
background: {
added: color("diffAddedLineNumberBg"),
removed: color("diffRemovedLineNumberBg"),
},
},
},
syntax: {
comment: color("syntaxComment"),
keyword: color("syntaxKeyword"),
function: color("syntaxFunction"),
variable: color("syntaxVariable"),
string: color("syntaxString"),
number: color("syntaxNumber"),
type: color("syntaxType"),
operator: color("syntaxOperator"),
punctuation: color("syntaxPunctuation"),
},
markdown: {
text: color("markdownText"),
heading: color("markdownHeading"),
link: color("markdownLink"),
linkText: color("markdownLinkText"),
code: color("markdownCode"),
blockQuote: color("markdownBlockQuote"),
emphasis: color("markdownEmph"),
strong: color("markdownStrong"),
horizontalRule: color("markdownHorizontalRule"),
listItem: color("markdownListItem"),
listEnumeration: color("markdownListEnumeration"),
image: color("markdownImage"),
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
feedback: {
error: { default: color("error") },
warning: { default: color("warning") },
success: { default: color("success") },
info: { default: color("info") },
},
},
"@context:elevated": { color: { background: { default: color("backgroundPanel") } } },
"@context:overlay": { color: { background: { default: color("backgroundMenu") } } },
background: {
default: color("background"),
surface: {
offset: color("backgroundPanel"),
overlay: color("backgroundMenu"),
},
action: {
primary: { default: color("primary"), $focused: color("primary") },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
$pressed: color("backgroundElement"),
},
destructive: { default: color("error") },
},
formfield: {
default: "$background.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: color("border") },
scrollbar: { default: color("borderActive") },
diff: {
text: {
added: color("diffAdded"),
removed: color("diffRemoved"),
context: color("diffContext"),
hunkHeader: color("diffHunkHeader"),
},
background: {
added: color("diffAddedBg"),
removed: color("diffRemovedBg"),
context: color("diffContextBg"),
},
highlight: { added: color("diffHighlightAdded"), removed: color("diffHighlightRemoved") },
lineNumber: {
text: color("diffLineNumber"),
background: {
added: color("diffAddedLineNumberBg"),
removed: color("diffRemovedLineNumberBg"),
},
},
},
syntax: {
comment: color("syntaxComment"),
keyword: color("syntaxKeyword"),
function: color("syntaxFunction"),
variable: color("syntaxVariable"),
string: color("syntaxString"),
number: color("syntaxNumber"),
type: color("syntaxType"),
operator: color("syntaxOperator"),
punctuation: color("syntaxPunctuation"),
},
markdown: {
text: color("markdownText"),
heading: color("markdownHeading"),
link: color("markdownLink"),
linkText: color("markdownLinkText"),
code: color("markdownCode"),
blockQuote: color("markdownBlockQuote"),
emphasis: color("markdownEmph"),
strong: color("markdownStrong"),
horizontalRule: color("markdownHorizontalRule"),
listItem: color("markdownListItem"),
listEnumeration: color("markdownListEnumeration"),
image: color("markdownImage"),
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
background: {
default: "$background.surface.offset",
action: {
primary: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } },
}
}
@@ -159,17 +192,17 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255)
}
function accentScale(accent: RGBA) {
function hueScale(color: RGBA) {
return {
100: mix(accent, 255, 0.66),
200: mix(accent, 255, 0.33),
300: hex(accent),
400: mix(accent, 0, 0.1),
500: mix(accent, 0, 0.2),
600: mix(accent, 0, 0.3),
700: mix(accent, 0, 0.4),
800: mix(accent, 0, 0.5),
900: mix(accent, 0, 0.6),
100: mix(color, 255, 0.8),
200: mix(color, 255, 0.6),
300: mix(color, 255, 0.4),
400: mix(color, 255, 0.2),
500: hex(color),
600: mix(color, 0, 0.15),
700: mix(color, 0, 0.3),
800: mix(color, 0, 0.45),
900: mix(color, 0, 0.6),
}
}
@@ -195,8 +228,22 @@ function hexInts(r: number, g: number, b: number, a: number) {
function ansi(code: number) {
if (code < 16) {
const colors = [
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
"#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(colors[code] ?? "#000000")
}
+3 -3
View File
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const { theme, themeV2 } = useTheme()
const dimensions = useTerminalDimensions()
return (
@@ -37,11 +37,11 @@ export function Toast() {
customBorderChars={SplitBorder.customBorderChars}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={themeV2.text()}>
{current().title}
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
<text fg={themeV2.text()} wrapMode="word" width="100%">
{current().message}
</text>
</box>
+2
View File
@@ -15,6 +15,7 @@ test("resolves nested config and keybind defaults", () => {
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
diffs: { view: "split" },
debug: { devtools: true },
},
{ terminalSuspend: true },
)
@@ -23,6 +24,7 @@ test("resolves nested config and keybind defaults", () => {
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
})
test("provides config and its host interface", async () => {
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { DevTools } from "../src/devtools"
test("registers and updates grouped DevTools data", () => {
const group = DevTools.register({ id: "test", title: "Test data" })
group.set("Duration", "1.00 ms")
group.set("Duration", "2.00 ms")
group.set("Count", 2)
expect(DevTools.data().find((item) => item.id === "test")).toEqual({
id: "test",
title: "Test data",
entries: [
{ key: "Duration", value: "2.00 ms" },
{ key: "Count", value: 2 },
],
})
})
+19 -12
View File
@@ -14,22 +14,29 @@ test("provides reactive property, variant, state, and context accessors", () =>
return key ? resolved().contexts[key] ?? resolved() : resolved()
})
expect(theme.color.text()).toBe(resolved().color.text.default)
expect(theme.color.text.subdued()).toBe(resolved().color.text.subdued)
expect(theme.color.text.action()).toBe(resolved().color.text.action.primary.default)
expect(theme.color.text.action.primary("pressed")).toBe(resolved().color.text.action.primary.pressed)
expect(theme.color.background.action.secondary("disabled")).toBe(
resolved().color.background.action.secondary.disabled,
expect(theme.text()).toBe(resolved().text.default)
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
expect(theme.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed)
expect(theme.background.action.secondary("disabled")).toBe(
resolved().background.action.secondary.disabled,
)
expect(theme.color.scrollbar()).toBe(resolved().color.scrollbar.default)
expect(theme.color.diff.text.added()).toBe(resolved().color.diff.text.added)
expect(theme.background.surface.offset()).toBe(resolved().background.surface.offset)
expect(theme.background.surface.overlay()).toBe(resolved().background.surface.overlay)
expect(theme.scrollbar()).toBe(resolved().scrollbar.default)
expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
setContext("@context:elevated")
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
expect(theme.color.background.action.primary("selected")).toBe(
resolved().contexts["@context:elevated"]!.color.background.action.primary.selected,
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
expect(theme.background.action.primary("focused")).toBe(
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
)
expect(theme.background.formfield("selected")).toBe(
resolved().contexts["@context:elevated"]!.background.formfield.selected,
)
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
expect(theme.color.text()).toBe(resolved().contexts["@context:elevated"]!.color.text.default)
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
})
+76 -53
View File
@@ -14,32 +14,36 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.color.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.color.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.color.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.color.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[300])
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
)
expect(lightTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:elevated"]?.color.background.action.primary.default).toBe(
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:elevated"]?.color.text.action.primary.default).toBe(
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:overlay"]?.color.background.action.primary.default).toBe(
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
)
expect(darkTheme.contexts["@context:overlay"]?.color.text.action.primary.default).toBe(
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
})
@@ -50,16 +54,16 @@ test("merges partial files with the selected OpenCode defaults", () => {
version: 2,
light: {
hue: light.hue,
color: { text: { default: "#123456" } },
text: { default: "#123456" },
},
dark: { hue: dark.hue },
},
"light",
)
expect(theme.color.text.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.text.subdued.toInts()).toEqual([18, 52, 86, 255])
expect(theme.color.background.action.destructive.pressed).toBeInstanceOf(RGBA)
expect(theme.text.default.toInts()).toEqual([18, 52, 86, 255])
expect(theme.text.subdued.toInts()).toEqual([18, 52, 86, 255])
expect(theme.background.action.destructive.pressed).toBeInstanceOf(RGBA)
})
test("expands user structural fallbacks before merging defaults", () => {
@@ -68,7 +72,7 @@ test("expands user structural fallbacks before merging defaults", () => {
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { default: "#123456" } } } },
background: { action: { primary: { default: "#123456" } } },
},
dark: { hue: dark.hue },
},
@@ -79,17 +83,17 @@ test("expands user structural fallbacks before merging defaults", () => {
version: 2,
light: {
hue: light.hue,
color: { background: { action: { primary: { $pressed: "#654321" } } } },
background: { action: { primary: { $pressed: "#654321" } } },
},
dark: { hue: dark.hue },
},
"light",
)
expect(expanded.color.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
expect(isolatedState.color.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
expect(isolatedState.color.background.action.primary.hovered.toInts()).toEqual(
resolveTheme(light).color.background.action.primary.hovered.toInts(),
expect(expanded.background.action.primary.pressed.toInts()).toEqual([18, 52, 86, 255])
expect(isolatedState.background.action.primary.pressed.toInts()).toEqual([101, 67, 33, 255])
expect(isolatedState.background.action.primary.focused.toInts()).toEqual(
resolveTheme(light).background.action.primary.focused.toInts(),
)
})
@@ -98,70 +102,89 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", (
const lightTheme = resolveThemeFile(file, "light")
const darkTheme = resolveThemeFile(file, "dark")
expect(lightTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(lightTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.color.background.default.toInts()).toEqual([255, 0, 0, 255])
expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.text.default.toInts()).toEqual([255, 0, 0, 255])
expect(darkTheme.background.default.toInts()).toEqual([255, 0, 0, 255])
})
test("uses defaults for the selected mode when it merges the other mode", () => {
const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
expect(theme.color.background.default.toInts()).toEqual(resolveTheme(dark).color.background.default.toInts())
expect(theme.background.default.toInts()).toEqual(resolveTheme(dark).background.default.toInts())
})
test("resolves matched action variants and states", () => {
const theme = resolveTheme(light)
expect(theme.color.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.color.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.color.background.action.destructive.disabled).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
})
test("resolves transparent colors", () => {
const theme = resolveThemeFile({
version: 2,
light: { background: { formfield: { default: "transparent" } } },
dark: { background: { formfield: { default: "transparent" } } },
})
expect(theme.background.formfield.default.toInts()).toEqual([0, 0, 0, 0])
})
test("reports theme decoding failures as native errors", () => {
expect(() =>
resolveThemeFile(
{
version: 2,
light: { text: { default: "opaque" } },
dark: {},
} as never,
"light",
"custom",
),
).toThrow('Invalid theme: custom "opaque" is an invalid value')
})
test("context overrides rewire semantic references and apply state precedence", () => {
const definition = override(light, {
color: {
text: {
default: "#111111",
action: {
primary: { default: "$color.text.default", $pressed: "#222222" },
secondary: { default: "$color.text.default" },
},
text: {
default: "#111111",
action: {
primary: { default: "$text.default", $pressed: "#222222" },
secondary: { default: "$text.default" },
},
},
"@context:elevated": {
color: {
text: {
default: "#333333",
action: { primary: { default: "#444444", $selected: "#555555" } },
},
text: {
default: "#333333",
action: { primary: { default: "#444444", $focused: "#555555" } },
},
},
})
const theme = resolveTheme(definition)
const overlay = theme.contexts["@context:elevated"]!
expect(overlay.color.text.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.color.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
expect(overlay.color.text.action.primary.selected.toInts()).toEqual([85, 85, 85, 255])
expect(overlay.text.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.text.action.secondary.default.toInts()).toEqual([51, 51, 51, 255])
expect(overlay.text.action.primary.pressed.toInts()).toEqual([68, 68, 68, 255])
expect(overlay.text.action.primary.focused.toInts()).toEqual([85, 85, 85, 255])
})
test("rejects missing, base, and contextual reference cycles", () => {
expect(() => resolveTheme(override(light, { color: { text: { default: "$missing.color" } } }))).toThrow(
'Theme reference "$missing.color" was not found',
expect(() => resolveTheme(override(light, { text: { default: "$missing" } }))).toThrow(
'Theme reference "$missing" was not found',
)
expect(() =>
resolveTheme(
override(light, {
color: { text: { default: "$color.text.subdued", subdued: "$color.text.default" } },
text: { default: "$text.subdued", subdued: "$text.default" },
}),
),
).toThrow("Circular theme reference")
expect(() =>
resolveTheme(
override(light, {
"@context:elevated": { color: { text: { default: "$color.text.default" } } },
"@context:elevated": { text: { default: "$text.default" } },
}),
),
).toThrow("Circular theme reference")
@@ -179,9 +202,9 @@ test("validates complete hues, resolved groups, and hue-only syntax", () => {
expect(() =>
resolveTheme({
...light,
color: { ...light.color, syntax: { ...light.color?.syntax, keyword: "$color.text.default" } },
syntax: { ...light.syntax, keyword: "$text.default" },
} as unknown as ThemeDefinition),
).toThrow("$color.text.default")
).toThrow("$text.default")
})
function override(base: ThemeDefinition, value: Partial<ThemeDefinition>) {
+5 -5
View File
@@ -3,8 +3,8 @@ import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/the
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
const hue = {} as HueDefinition
const light = { hue, color: { text: { default: "#111111", subdued: "#222222" } } } satisfies ThemeDefinition
const dark = { hue, color: { text: { default: "#eeeeee", subdued: "#dddddd" } } } satisfies ThemeDefinition
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
const dark = { hue, text: { default: "#eeeeee", subdued: "#dddddd" } } satisfies ThemeDefinition
test("requires and selects independent light and dark themes", () => {
const file = { version: 2, light, dark } satisfies ThemeFile
@@ -18,13 +18,13 @@ test("merges an expanded mode override over the other mode", () => {
const file = {
version: 2,
light,
dark: { mergeMode: true, color: { text: { default: "#ffffff" } } },
dark: { mergeMode: true, text: { default: "#ffffff" } },
} satisfies ThemeFile
const selected = selectTheme(file, "dark")
expect(selected.hue).toBeDefined()
expect(selected.color?.text?.default).toBe("#ffffff")
expect(selected.color?.text?.subdued).toBe("$color.text.default")
expect(selected.text?.default).toBe("#ffffff")
expect(selected.text?.subdued).toBe("$text.default")
})
test("rejects mutual mode merging", () => {
+13 -8
View File
@@ -9,6 +9,7 @@ const text = {
secondary: { default: "$hue.neutral.900" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: { default: "$hue.neutral.600", $selected: "$hue.neutral.100" },
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
},
@@ -16,32 +17,36 @@ const text = {
const background = {
default: "$hue.neutral.100",
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" },
},
formfield: { default: "$hue.neutral.100", $selected: "$hue.accent.600" },
feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition
const definition = {
hue: {} as ThemeDefinition["hue"],
color: { text, background, border: { default: "$hue.neutral.300" } },
text,
background,
border: { default: "$hue.neutral.300" },
"@context:elevated": {
color: {
text: { default: "$hue.neutral.800" },
background: { default: "$hue.neutral.200" },
},
text: { default: "$hue.neutral.800" },
background: { default: "$hue.neutral.200" },
},
"@context:overlay": { color: { background: { default: "$hue.neutral.300" } } },
"@context:overlay": { background: { default: "$hue.neutral.300" } },
} satisfies ThemeDefinition
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
test("supports property-first definitions, variants, states, and contexts", () => {
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
expect(text.formfield.$selected).toBe("$hue.neutral.100")
expect(background.action.destructive.default).toBe("$hue.red.600")
expect(definition["@context:elevated"].color?.text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].color?.background?.default).toBe("$hue.neutral.300")
expect(background.surface.offset).toBe("$hue.neutral.200")
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
expect(file.light).toBe(definition)
})
+39 -16
View File
@@ -8,24 +8,47 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
const resolved = resolveThemeFile(migrated, "light")
expect(migrated.standalone).toBeUndefined()
expect(migrated.standalone).toBeTrue()
expect(migrated.light.hue?.accent).toBeObject()
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
expect(migrated.light.hue.accent[300]).toBe(hex(legacy.accent))
expect(migrated.light.color?.background?.default).toBe(hex(legacy.background))
expect(migrated.light.color?.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.color?.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.color?.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.color?.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.color?.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.color.background.action.secondary.hovered.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.color.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.color.background.default.toInts()).toEqual(
expect(migrated.light.hue.accent[500]).toBe(hex(legacy.secondary))
expect(migrated.light.background?.default).toBe(hex(legacy.background))
expect(migrated.light.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
expect(resolved.background.action.secondary.focused.toInts()).toEqual(legacy.backgroundElement.toInts())
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundMenu.toInts())
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[500].toInts()).toEqual(legacy.secondary.toInts())
expect(resolved.hue.accent[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan(
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
)
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.color.background.default.toInts()).toEqual(
expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
selectedForeground(legacy, legacy.primary).toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
})
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
@@ -35,8 +58,8 @@ test("preserves V1 selected foreground behavior on transparent backgrounds", ()
delete source.theme.selectedListItemText
const migrated = migrateV1(source)
expect(migrated.light.color?.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.color?.text?.action?.primary?.default).toBe("#ffffff")
expect(migrated.light.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff")
})
test("retains V1 circular reference errors", () => {
@@ -50,8 +73,8 @@ test("retains V1 circular reference errors", () => {
test("migrates every built-in V1 theme in both modes", () => {
for (const source of Object.values(DEFAULT_THEMES)) {
const migrated = migrateV1(source)
expect(resolveThemeFile(migrated, "light").color.text.default).toBeDefined()
expect(resolveThemeFile(migrated, "dark").color.text.default).toBeDefined()
expect(resolveThemeFile(migrated, "light").text.default).toBeDefined()
expect(resolveThemeFile(migrated, "dark").text.default).toBeDefined()
}
})
+19
View File
@@ -0,0 +1,19 @@
# TUI Theme V2 Migration Checklist
- [x] Add semantic accent foreground and border tokens so components stop
reading `hue.accent[300]` directly.
- [ ] Add paired badge or label foreground/background tokens to replace V1
`secondary` usages.
- [ ] Add strong warning and error background treatments with matching readable
foregrounds.
- [x] Use `text.default` for active cursors, `background.surface.offset` for
disabled cursors, and a lighter accent hue for focused form borders.
- [x] Add `background.surface.offset` and `background.surface.overlay`, map
them from V1 panel/menu backgrounds, and use them as contextual surface
defaults.
- [ ] Replace `selectedForeground` with complete V2 foreground/background pairs
or a V2 contrast helper that supports transparent themes.
- [ ] Decide whether thinking opacity is fixed at `0.6` or belongs in a separate
presentation-token system.
- [ ] Generate syntax styles from resolved V2 tokens, then migrate each UI
surface and remove the V1 proxy once no flat V1 color reads remain.