Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb8d775fec | |||
| eec6cd5234 | |||
| fc37ae4452 | |||
| 5fcef6773c | |||
| a5b28c2af2 | |||
| d01dfa57b7 | |||
| 198ca749fd | |||
| 75f9fd5208 | |||
| 282f3f7eb2 |
+19
-5
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
+12
-7
@@ -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,
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")({
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
}) {}
|
||||
@@ -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) =>
|
||||
+3
-3
@@ -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) => ({
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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] },
|
||||
),
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
+21
-19
@@ -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) }))),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-26
@@ -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])))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>>>(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user