Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton ced6747737 refactor(tui): compose tab pulse layers 2026-08-02 02:26:35 +00:00
18 changed files with 360 additions and 827 deletions
+23 -23
View File
@@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
| `@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 | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
| `@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. |
## Highest-Risk Gaps
@@ -71,31 +71,30 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
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. Vertex xAI still needs catalog API selection.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
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 |
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| 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`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| 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/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 Open 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 Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| 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. |
| 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`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| 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/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 Open 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. |
| 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. |
## Suggested Next Work Slices
@@ -104,5 +103,6 @@ These are implementation/API slices, not separate npm packages.
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, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. 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.
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
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 Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
@@ -22,8 +22,6 @@ const signRequest = (input: {
readonly body: string
readonly headers: Headers.Headers
readonly credentials: Credentials
readonly service: string
readonly name: string
}) =>
Effect.tryPromise({
try: async () => {
@@ -36,26 +34,23 @@ const signRequest = (input: {
accessKeyId: input.credentials.accessKeyId,
secretAccessKey: input.credentials.secretAccessKey,
sessionToken: input.credentials.sessionToken,
service: input.service,
service: "bedrock",
}).sign()
return Object.fromEntries(signed.headers.entries())
},
catch: (error) =>
ProviderShared.invalidRequest(
`${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
export const sigV4 = (credentials: Credentials | undefined) =>
Auth.custom((input: AuthInput) => {
return Effect.gen(function* () {
if (!credentials) {
return yield* ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
)
}
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
@@ -64,8 +59,6 @@ export const sigV4 = (
body: input.body,
headers: headersForSigning,
credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
})
return Headers.setAll(headersForSigning, signed)
})
@@ -1,107 +0,0 @@
import { Auth } from "../route/auth"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { OpenAIChat } from "../protocols/openai-chat"
import { OpenAIResponses } from "../protocols/openai-responses"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
import { ProviderID, type ModelID } from "../schema"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
id: "bedrock-mantle-responses",
provider: id,
})
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
})
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
return rest
}
export const configure = (input: Config = {}) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: chat,
chat,
responses,
configure,
}
}
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
@@ -1,2 +0,0 @@
export { chatModel as model } from "../amazon-bedrock-mantle"
export type { Settings } from "../amazon-bedrock-mantle"
@@ -1,2 +0,0 @@
export { chatModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
@@ -1,2 +0,0 @@
export { responsesModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
-1
View File
@@ -1,7 +1,6 @@
export * as Anthropic from "./anthropic"
export * as AnthropicCompatible from "./anthropic-compatible"
export * as AmazonBedrock from "./amazon-bedrock"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
File diff suppressed because one or more lines are too long
@@ -23,16 +23,12 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
])
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)
expect(modules[19].model).toBe(modules[20].model)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
@@ -1,111 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { AmazonBedrockMantle } from "../../src/providers"
import { compileRequest, LLMClient } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { recordedTests } from "../recorded-test"
const credentials = {
region: "us-east-2",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
route: "bedrock-mantle-chat",
protocol: "openai-chat",
body: { model: "openai.gpt-oss-120b" },
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
}),
)
it.effect("uses the Mantle endpoint and signing service", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
}),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({
apiKey: "test-key",
baseURL: "https://mantle.test/v1",
}).chat("openai.gpt-oss-safeguard-20b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
}),
)
})
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
describe("Amazon Bedrock Mantle recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: AmazonBedrockMantle.configure({
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: "us-east-1",
}).responses("openai.gpt-oss-120b"),
prompt: "Reply with exactly: hello",
generation: { maxTokens: 256, temperature: 0 },
}),
)
expect(response.text.trim().toLowerCase()).toBe("hello")
}),
)
})
+1 -80
View File
@@ -10,15 +10,9 @@ export interface Mapping {
readonly body?: Readonly<Record<string, unknown>>
}
export function map(
packageName: string | undefined,
settings: Readonly<Record<string, unknown>>,
modelID?: string,
): Mapping | undefined {
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
const baseSettings = mapBaseSettings(settings)
switch (packageName) {
case "@ai-sdk/amazon-bedrock/mantle":
return mapBedrockMantle(settings, baseSettings, modelID)
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
@@ -42,79 +36,6 @@ export function map(
}
}
function mapBedrockMantle(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
modelID: string | undefined,
): Mapping | undefined {
const apiKey =
typeof settings.apiKey === "string"
? settings.apiKey
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials = mapBedrockCredentials(settings)
if (apiKey === undefined && credentials === undefined) return undefined
const safeguard = modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b"
return {
package: `@opencode-ai/ai/providers/amazon-bedrock/mantle/${safeguard ? "chat" : "responses"}`,
settings: {
...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
...(apiKey === undefined ? {} : { apiKey }),
...(credentials === undefined ? {} : { credentials }),
...(typeof settings.region === "string" ? { region: settings.region } : {}),
...mapOpenAIOptions(settings),
},
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
if (
isRecord(settings.credentials) &&
typeof settings.credentials.region === "string" &&
typeof settings.credentials.accessKeyId === "string" &&
typeof settings.credentials.secretAccessKey === "string"
) {
return {
region: settings.credentials.region,
accessKeyId: settings.credentials.accessKeyId,
secretAccessKey: settings.credentials.secretAccessKey,
...(typeof settings.credentials.sessionToken === "string"
? { sessionToken: settings.credentials.sessionToken }
: {}),
}
}
if (
typeof settings.region !== "string" ||
typeof settings.accessKeyId !== "string" ||
typeof settings.secretAccessKey !== "string"
)
return undefined
return {
region: settings.region,
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
...(typeof settings.sessionToken === "string" ? { sessionToken: settings.sessionToken } : {}),
}
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: { openai: options } }
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
+4 -11
View File
@@ -175,22 +175,15 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = {
...resolved.settings,
...credential?.metadata,
...(packageName === "@ai-sdk/amazon-bedrock/mantle"
? nativeCredentialSettings("@opencode-ai/ai/providers/amazon-bedrock/mantle", credential)
: {}),
}
const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map(packageName, configured, resolved.modelID ?? resolved.id)
: undefined
const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
-9
View File
@@ -34,15 +34,6 @@ export type ProviderPackage = ProviderPackageDefinition
const packages = new Map<string, Promise<unknown>>()
const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")],
["@opencode-ai/ai/providers/amazon-bedrock/mantle", () => import("@opencode-ai/ai/providers/amazon-bedrock/mantle")],
[
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
() => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
],
[
"@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
() => import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
],
["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")],
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
-81
View File
@@ -2,87 +2,6 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps Bedrock Mantle models to their supported native APIs", () => {
const settings = {
bearerToken: "token",
region: "us-west-2",
baseURL: "https://mantle.test/v1",
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
}
expect(AISDKNative.map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
settings: {
apiKey: "token",
baseURL: "https://mantle.test/v1",
region: "us-west-2",
providerOptions: {
openai: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
},
})
expect(AISDKNative.map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
store: false,
},
"openai.gpt-oss-120b",
),
).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock/mantle/responses",
settings: {
credentials: {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
region: "eu-west-1",
providerOptions: { openai: { store: false } },
},
})
})
test("keeps Bedrock Mantle on the AI SDK when native static auth is unavailable", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{ region: "us-east-1", profile: "production" },
"openai.gpt-oss-120b",
),
).toBeUndefined()
})
test("maps the legacy Bedrock endpoint override", () => {
expect(
AISDKNative.map(
"@ai-sdk/amazon-bedrock/mantle",
{ bearerToken: "token", endpoint: "https://mantle.private/v1", region: "us-east-1" },
"openai.gpt-oss-120b",
),
).toMatchObject({ settings: { baseURL: "https://mantle.private/v1" } })
})
test("maps OpenRouter settings to native destinations", () => {
expect(
AISDKNative.map("@openrouter/ai-sdk-provider", {
-29
View File
@@ -42,35 +42,6 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
})
describe("ModelResolver", () => {
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
const responses = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
settings: { region: "us-east-2" },
}),
credential,
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-safeguard-20b",
settings: { region: "us-east-2" },
}),
credential,
)
expect(responses.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
})
expect(chat.route).toMatchObject({
id: "bedrock-mantle-chat",
endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
})
}),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
+92 -64
View File
@@ -1,5 +1,5 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@@ -18,7 +18,7 @@ import {
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { TabPulse, TabPulseTimeline, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
@@ -97,6 +97,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
),
)
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
const pulseTimelines = new Map<string, TabPulseTimeline>()
const pulseTimeline = (sessionID: string) => {
const existing = pulseTimelines.get(sessionID)
if (existing) return existing
const created = new TabPulseTimeline()
pulseTimelines.set(sessionID, created)
return created
}
let rail: { screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
@@ -132,6 +140,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
<box flexShrink={0} flexDirection="column" gap={1}>
<For each={items()}>
{(tab, index) => {
const timeline = pulseTimeline(tab.sessionID)
onCleanup(() => pulseTimelines.delete(tab.sessionID))
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -185,6 +195,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousTimeline = () => {
const tab = previous()
return tab ? pulseTimeline(tab.sessionID) : undefined
}
const previousStatus = createMemo(() => {
const tab = previous()
return tab
@@ -244,24 +258,30 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
top={-1}
edge="above"
enabled={animations()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
outerGlow={previousGlows()}
breathe={status().attention}
outerBreathe={previousStatus().attention}
color={separatorLowerPulseColor()}
outerColor={separatorUpperPulseColor()}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: separatorLowerPulseColor(),
glowColor: separatorLowerColor(),
glowTail: 8,
completionColor: separatorLowerColor(),
}}
edgeLayer={{
timeline: previousTimeline(),
active: previousRuns(),
promptPulse: previousStatus().promptPulse,
complete: previousStatus().complete && !previousStatus().attention,
glow: previousGlows(),
breathe: previousStatus().attention,
color: separatorUpperPulseColor(),
glowColor: separatorUpperColor(),
glowTail: 5,
completionColor: separatorUpperColor(),
}}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
@@ -269,38 +289,41 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
breathe={status().attention}
outerBreathe={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
glowColor={tint(theme.background.default, glowHue(), 0.1)}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1)}
outerCompletionColor={theme.background.default}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: tint(theme.background.default, theme.text.default, 0.04),
glowColor: tint(theme.background.default, glowHue(), 0.1),
glowTail: 8,
completionColor: tint(theme.background.default, glowHue(), 0.1),
}}
edgeLayer={{
color: tint(theme.background.default, theme.text.default, 0.006),
glowColor: theme.background.default,
glowTail: 5,
completionColor: theme.background.default,
}}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={glowColor()}
completionColor={glowColor()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
completionColor: glowColor(),
}}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
@@ -346,15 +369,18 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
<box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
breathe={status().attention}
color={detailPulseColor()}
glowColor={detailGlowColor()}
glowTail={10}
completionColor={detailGlowColor()}
layer={{
timeline,
active: runs(),
promptPulse: status().promptPulse,
complete: complete() && !status().attention,
glow: glows(),
breathe: status().attention,
color: detailPulseColor(),
glowColor: detailGlowColor(),
glowTail: 10,
completionColor: detailGlowColor(),
}}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
@@ -651,15 +677,17 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
>
<TabPulse
enabled={animations()}
active={status().busy && !status().attention}
promptPulse={status().promptPulse}
complete={status().complete && !status().attention}
glow={glows()}
breathe={status().attention}
color={pulseColor()}
glowColor={glowColor()}
flashColor={flashColor()}
completionColor={accent()}
layer={{
active: status().busy && !status().attention,
promptPulse: status().promptPulse,
complete: status().complete && !status().attention,
glow: glows(),
breathe: status().attention,
color: pulseColor(),
glowColor: glowColor(),
flashColor: flashColor(),
completionColor: accent(),
}}
backgroundColor={background()}
onLevel={setSweepLevel}
/>
+230 -257
View File
@@ -1,29 +1,28 @@
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
import { extend } from "@opentui/solid"
export type TabPulseState = {
active?: boolean
promptPulse?: number
complete?: boolean
glow?: boolean
breathe?: boolean
}
export type TabPulseLayer = TabPulseState & {
timeline?: TabPulseTimeline
color: RGBA
glowColor?: RGBA
glowTail?: number
flashColor?: RGBA
completionColor?: RGBA
}
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
edge?: "above" | "below"
enabled?: boolean
active?: boolean
outerActive?: boolean
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color?: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
layer?: TabPulseLayer
edgeLayer?: TabPulseLayer
backgroundColor?: RGBA
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
onLevel?: (level: number) => void
@@ -310,71 +309,163 @@ class PulseState {
}
}
export class TabPulseTimeline {
private state: PulseState
private frame = -1
constructor(options: TabPulseState = {}, enabled = true) {
this.state = new PulseState({
enabled,
active: options.active ?? false,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow: options.glow ?? false,
breathe: options.breathe ?? false,
})
}
set(options: TabPulseState, enabled: boolean) {
return [
this.state.setEnabled(enabled),
// Stopping a run arms completion; apply complete afterward so an atomic idle update can consume it.
this.state.setActive(options.active ?? false),
this.state.setPromptPulse(options.promptPulse ?? 0),
this.state.setComplete(options.complete ?? false),
this.state.setGlow(options.glow ?? false),
this.state.setBreathe(options.breathe ?? false),
].some(Boolean)
}
setEnabled(value: boolean) {
return this.state.setEnabled(value)
}
advance(deltaTime: number, frame: number) {
if (frame === this.frame) return
this.frame = frame
this.state.advance(deltaTime)
}
get live() {
return this.state.live
}
get running() {
return this.state.running
}
get completion() {
return this.state.completion
}
get flash() {
return this.state.flash
}
get glowLevel() {
return this.state.glowLevel
}
fronts(width: number) {
return this.state.fronts(width)
}
}
class PulseLayer {
timeline: TabPulseTimeline
private source: TabPulseTimeline | undefined
private enabled: boolean
color: RGBA
glowColor: RGBA
glowTail: number
flashColor: RGBA
completionColor: RGBA
constructor(options: TabPulseLayer, enabled: boolean) {
this.enabled = enabled
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, enabled)
if (options.timeline) options.timeline.set(options, enabled)
this.color = options.color
this.glowColor = options.glowColor ?? options.color
this.glowTail = options.glowTail ?? GLOW_TAIL
this.flashColor = options.flashColor ?? options.color
this.completionColor = options.completionColor ?? options.color
}
set(options: TabPulseLayer) {
const sourceChanged = options.timeline !== this.source
if (sourceChanged) {
this.source = options.timeline
this.timeline = options.timeline ?? new TabPulseTimeline(options, this.enabled)
}
const stateChanged = sourceChanged
? (options.timeline?.set(options, this.enabled) ?? false)
: this.timeline.set(options, this.enabled)
const color = options.color
const glowColor = options.glowColor ?? color
const glowTail = options.glowTail ?? GLOW_TAIL
const flashColor = options.flashColor ?? color
const completionColor = options.completionColor ?? color
const changed = [
sourceChanged,
stateChanged,
!color.equals(this.color),
!glowColor.equals(this.glowColor),
glowTail !== this.glowTail,
!flashColor.equals(this.flashColor),
!completionColor.equals(this.completionColor),
].some(Boolean)
this.color = color
this.glowColor = glowColor
this.glowTail = glowTail
this.flashColor = flashColor
this.completionColor = completionColor
return changed
}
setEnabled(value: boolean) {
this.enabled = value
return this.timeline.setEnabled(value)
}
}
const DEFAULT_LAYER: TabPulseLayer = { color: RGBA.defaultForeground() }
class TabPulseRenderable extends Renderable {
private _enabled: boolean
private inner: PulseState
private outer: PulseState
private _color: RGBA
private _outerColor: RGBA
private _glowColor: RGBA
private _outerGlowColor: RGBA
private _glowTail: number
private _outerGlowTail: number
private primary: PulseLayer
private adjacent: PulseLayer
private primaryAssigned: boolean
private adjacentAssigned: boolean
private _edge: "above" | "below" | undefined
private _flashColor: RGBA
private _outerFlashColor: RGBA
private _completionColor: RGBA
private _outerCompletionColor: RGBA
private _backgroundColor: RGBA
private renderColor = RGBA.fromInts(0, 0, 0)
private outerRenderColor = RGBA.fromInts(0, 0, 0)
private primaryRenderColor = RGBA.fromInts(0, 0, 0)
private adjacentRenderColor = RGBA.fromInts(0, 0, 0)
private _onLevel: ((level: number) => void) | undefined
private lastLevel = 0
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
const enabled = options.enabled ?? true
const active = options.active ?? false
const glow = options.glow ?? false
const breathe = options.breathe ?? false
const primary = options.layer ?? DEFAULT_LAYER
const adjacent = options.edgeLayer ?? DEFAULT_LAYER
const edge = options.edge
const outerActive = options.outerActive ?? active
const outerGlow = options.outerGlow ?? glow
const outerBreathe = options.outerBreathe ?? breathe
super(ctx, {
...options,
height: 1,
live:
enabled &&
(active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
((primary.active ?? false) ||
((primary.glow ?? false) && (primary.breathe ?? false)) ||
(edge !== undefined &&
((adjacent.active ?? false) || ((adjacent.glow ?? false) && (adjacent.breathe ?? false))))),
})
this._enabled = enabled
this.inner = new PulseState({
enabled,
active,
promptPulse: options.promptPulse ?? 0,
complete: options.complete ?? false,
glow,
breathe,
})
this.outer = new PulseState({
enabled: enabled && edge !== undefined,
active: outerActive,
promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
complete: options.outerComplete ?? options.complete ?? false,
glow: outerGlow,
breathe: outerBreathe,
})
this._color = options.color ?? RGBA.defaultForeground()
this._outerColor = options.outerColor ?? this._color
this._glowColor = options.glowColor ?? this._color
this._outerGlowColor = options.outerGlowColor ?? this._glowColor
this._glowTail = options.glowTail ?? GLOW_TAIL
this._outerGlowTail = options.outerGlowTail ?? this._glowTail
this.primary = new PulseLayer(primary, enabled)
this.adjacent = new PulseLayer(adjacent, enabled && edge !== undefined)
this.primaryAssigned = options.layer !== undefined
this.adjacentAssigned = options.edgeLayer !== undefined
this._edge = edge
this._flashColor = options.flashColor ?? this._color
this._outerFlashColor = options.outerFlashColor ?? options.flashColor ?? this._outerColor
this._completionColor = options.completionColor ?? this._color
this._outerCompletionColor = options.outerCompletionColor ?? options.completionColor ?? this._outerColor
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
this._onLevel = options.onLevel
}
@@ -388,129 +479,42 @@ class TabPulseRenderable extends Renderable {
const quantized = Math.round(value * 32) / 32
if (quantized === this.lastLevel) return
this.lastLevel = quantized
this._onLevel?.(quantized)
this._onLevel(quantized)
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
this.inner.setEnabled(value)
this.outer.setEnabled(value && this._edge !== undefined)
this.live = this.inner.live || this.outer.live
this.requestRender()
this.primary.setEnabled(value)
this.adjacent.setEnabled(value && this._edge !== undefined)
this.changed()
}
set active(value: boolean) {
if (this.inner.setActive(value)) this.changed()
set layer(value: TabPulseLayer) {
if (!this.primaryAssigned) {
this.primaryAssigned = true
this.primary = new PulseLayer(value, this._enabled)
this.changed()
return
}
if (this.primary.set(value)) this.changed()
}
set outerActive(value: boolean) {
if (this.outer.setActive(value)) this.changed()
}
set promptPulse(value: number) {
if (this.inner.setPromptPulse(value)) this.changed()
}
set outerPromptPulse(value: number) {
if (this.outer.setPromptPulse(value)) this.changed()
}
set complete(value: boolean) {
if (this.inner.setComplete(value)) this.changed()
}
set outerComplete(value: boolean) {
if (this.outer.setComplete(value)) this.changed()
}
set glow(value: boolean) {
if (this.inner.setGlow(value)) this.changed()
}
set outerGlow(value: boolean) {
if (this.outer.setGlow(value)) this.changed()
}
set breathe(value: boolean) {
if (this.inner.setBreathe(value)) this.changed()
}
set outerBreathe(value: boolean) {
if (this.outer.setBreathe(value)) this.changed()
}
private changed() {
this.live = this.inner.live || this.outer.live
this.requestRender()
}
set color(value: RGBA) {
if (value.equals(this._color)) return
this._color = value
this.requestRender()
}
set glowColor(value: RGBA) {
if (value.equals(this._glowColor)) return
this._glowColor = value
this.requestRender()
}
set outerColor(value: RGBA) {
if (value.equals(this._outerColor)) return
this._outerColor = value
this.requestRender()
}
set outerGlowColor(value: RGBA) {
if (value.equals(this._outerGlowColor)) return
this._outerGlowColor = value
this.requestRender()
}
set glowTail(value: number) {
if (value === this._glowTail) return
this._glowTail = value
this.requestRender()
}
set outerGlowTail(value: number) {
if (value === this._outerGlowTail) return
this._outerGlowTail = value
this.requestRender()
set edgeLayer(value: TabPulseLayer) {
if (!this.adjacentAssigned) {
this.adjacentAssigned = true
this.adjacent = new PulseLayer(value, this._enabled && this._edge !== undefined)
this.changed()
return
}
if (this.adjacent.set(value)) this.changed()
}
set edge(value: "above" | "below" | undefined) {
if (value === this._edge) return
this._edge = value
this.outer.setEnabled(this._enabled && value !== undefined)
this.live = this.inner.live || this.outer.live
this.requestRender()
}
set flashColor(value: RGBA) {
if (value.equals(this._flashColor)) return
this._flashColor = value
this.requestRender()
}
set outerFlashColor(value: RGBA) {
if (value.equals(this._outerFlashColor)) return
this._outerFlashColor = value
this.requestRender()
}
set completionColor(value: RGBA) {
if (value.equals(this._completionColor)) return
this._completionColor = value
this.requestRender()
}
set outerCompletionColor(value: RGBA) {
if (value.equals(this._outerCompletionColor)) return
this._outerCompletionColor = value
this.requestRender()
this.adjacent.setEnabled(this._enabled && value !== undefined)
this.changed()
}
set backgroundColor(value: RGBA) {
@@ -519,38 +523,43 @@ class TabPulseRenderable extends Renderable {
this.requestRender()
}
private changed() {
this.live = this.primary.timeline.live || this.adjacent.timeline.live
this.requestRender()
}
protected override onUpdate(deltaTime: number): void {
if (!this._enabled) return
this.inner.advance(deltaTime)
this.outer.advance(deltaTime)
this.live = this.inner.live || this.outer.live
this.primary.timeline.advance(deltaTime, this.ctx.frameId)
this.adjacent.timeline.advance(deltaTime, this.ctx.frameId)
this.live = this.primary.timeline.live || this.adjacent.timeline.live
}
protected override renderSelf(buffer: OptimizedBuffer): void {
if (!this.visible || this.isDestroyed || this.width <= 0) return
const running = this.inner.running
const completion = this.inner.completion
const flash = this.inner.flash
const glowLevel = this.inner.glowLevel
const outerRunning = this.outer.running
const outerCompletion = this.outer.completion
const outerFlash = this.outer.flash
const outerGlowLevel = this.outer.glowLevel
const running = this.primary.timeline.running
const completion = this.primary.timeline.completion
const flash = this.primary.timeline.flash
const glowLevel = this.primary.timeline.glowLevel
const adjacentRunning = this.adjacent.timeline.running
const adjacentCompletion = this.adjacent.timeline.completion
const adjacentFlash = this.adjacent.timeline.flash
const adjacentGlowLevel = this.adjacent.timeline.glowLevel
if (
glowLevel === 0 &&
running === 0 &&
completion === 0 &&
flash === 0 &&
outerGlowLevel === 0 &&
outerRunning === 0 &&
outerCompletion === 0 &&
outerFlash === 0
adjacentGlowLevel === 0 &&
adjacentRunning === 0 &&
adjacentCompletion === 0 &&
adjacentFlash === 0
) {
this.emitLevel(0)
return
}
const [front, secondFront] = this.inner.fronts(this.width)
const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
const [front, secondFront] = this.primary.timeline.fronts(this.width)
const [adjacentFront, adjacentSecondFront] = this.adjacent.timeline.fronts(this.width)
if (this._onLevel)
this.emitLevel(
running === 0
@@ -558,8 +567,8 @@ class TabPulseRenderable extends Renderable {
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
running,
)
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
const glowTail = Math.min(this.primary.glowTail, Math.max(1, this.width - 2))
const adjacentGlowTail = Math.min(this.adjacent.glowTail, Math.max(1, this.width - 2))
for (let index = 0; index < this.width; index++) {
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
const sweep =
@@ -571,49 +580,49 @@ class TabPulseRenderable extends Renderable {
) *
0.14 *
running
const outerSweep =
outerRunning === 0
const adjacentSweep =
adjacentRunning === 0
? 0
: Math.max(
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, adjacentFront, RUN_HEAD, RUN_TAIL),
intensityAt(index, adjacentSecondFront, RUN_HEAD, RUN_TAIL),
) *
0.14 *
outerRunning
adjacentRunning
blendTabPulseColor(
this.renderColor,
this.primaryRenderColor,
this._backgroundColor,
this._glowColor,
this._color,
this._flashColor,
this._completionColor,
this.primary.glowColor,
this.primary.color,
this.primary.flashColor,
this.primary.completionColor,
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
sweep,
flash,
completion,
)
if (!this._edge) {
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.primaryRenderColor)
continue
}
blendTabPulseColor(
this.outerRenderColor,
this.adjacentRenderColor,
this._backgroundColor,
this._outerGlowColor,
this._outerColor,
this._outerFlashColor,
this._outerCompletionColor,
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
outerSweep,
outerFlash,
outerCompletion,
this.adjacent.glowColor,
this.adjacent.color,
this.adjacent.flashColor,
this.adjacent.completionColor,
adjacentGlowLevel === 0 ? 0 : glowIntensityAt(index, adjacentGlowTail) * GLOW_OPACITY * adjacentGlowLevel,
adjacentSweep,
adjacentFlash,
adjacentCompletion,
)
buffer.setCell(
this.screenX + index,
this.screenY,
this._edge === "above" ? "▄" : "▀",
this.renderColor,
this.outerRenderColor,
this.primaryRenderColor,
this.adjacentRenderColor,
)
}
}
@@ -632,26 +641,8 @@ export function TabPulse(props: {
width?: number
edge?: "above" | "below"
enabled?: boolean
active: boolean
outerActive?: boolean
promptPulse?: number
outerPromptPulse?: number
complete?: boolean
outerComplete?: boolean
glow?: boolean
outerGlow?: boolean
breathe?: boolean
outerBreathe?: boolean
color: RGBA
outerColor?: RGBA
glowColor?: RGBA
outerGlowColor?: RGBA
glowTail?: number
outerGlowTail?: number
flashColor?: RGBA
outerFlashColor?: RGBA
completionColor?: RGBA
outerCompletionColor?: RGBA
layer: TabPulseLayer
edgeLayer?: TabPulseLayer
backgroundColor: RGBA
onLevel?: (level: number) => void
}) {
@@ -663,26 +654,8 @@ export function TabPulse(props: {
zIndex={0}
width={props.width ?? "100%"}
enabled={props.enabled ?? true}
active={props.active}
outerActive={props.outerActive ?? props.active}
promptPulse={props.promptPulse ?? 0}
outerPromptPulse={props.outerPromptPulse ?? props.promptPulse ?? 0}
complete={props.complete ?? false}
outerComplete={props.outerComplete ?? props.complete ?? false}
glow={props.glow ?? false}
outerGlow={props.outerGlow ?? props.glow ?? false}
breathe={props.breathe ?? false}
outerBreathe={props.outerBreathe ?? props.breathe ?? false}
color={props.color}
outerColor={props.outerColor ?? props.color}
glowColor={props.glowColor ?? props.color}
outerGlowColor={props.outerGlowColor ?? props.glowColor ?? props.color}
glowTail={props.glowTail ?? GLOW_TAIL}
outerGlowTail={props.outerGlowTail ?? props.glowTail ?? GLOW_TAIL}
flashColor={props.flashColor ?? props.color}
outerFlashColor={props.outerFlashColor ?? props.flashColor ?? props.outerColor ?? props.color}
completionColor={props.completionColor ?? props.color}
outerCompletionColor={props.outerCompletionColor ?? props.completionColor ?? props.outerColor ?? props.color}
layer={props.layer}
edgeLayer={props.edge === undefined ? undefined : (props.edgeLayer ?? props.layer)}
backgroundColor={props.backgroundColor}
onLevel={props.onLevel}
/>
@@ -20,10 +20,12 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
() => (
<box width={8} height={1} backgroundColor={background}>
<TabPulse
active={true}
promptPulse={promptPulse()}
color={background}
flashColor={flash}
layer={{
active: true,
promptPulse: promptPulse(),
color: background,
flashColor: flash,
}}
backgroundColor={background}
/>
</box>