mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 08:25:00 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16e7ce3c3 | |||
| 5d729521d3 | |||
| c391983ae0 | |||
| f158abd694 | |||
| 0e3da4b4f1 | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f | |||
| 85ea15e56d | |||
| 6a543791b6 | |||
| 47c6840752 | |||
| 14e4c3dfd7 | |||
| ee6577936d | |||
| 8285a18f18 | |||
| cfcd8de62a | |||
| c2db18c59b | |||
| 70393a1d18 | |||
| eab59ea021 | |||
| 4569659b03 | |||
| b5afaed053 | |||
| 97f5fb3399 | |||
| 18843682ad | |||
| 9eb9c1db7d | |||
| 8846336e11 | |||
| f6b5f6b6b3 | |||
| 02df1e9737 | |||
| 5b84e39f6d | |||
| 6d3f84b4ff | |||
| 255e9bbf06 | |||
| 25b6a179e2 | |||
| 80874c241f | |||
| f99469bdf8 | |||
| fe0eb4ea8f | |||
| a484f87680 | |||
| 3b6ab392ac | |||
| 6c567dc745 | |||
| 92567700f6 | |||
| 90c84639e3 |
@@ -4,6 +4,13 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -384,7 +383,6 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -405,7 +403,6 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
@@ -427,7 +424,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
+23
-23
@@ -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 | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
|
||||
| `@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. |
|
||||
|
||||
## Highest-Risk Gaps
|
||||
|
||||
@@ -71,30 +71,31 @@ 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; 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.
|
||||
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.
|
||||
|
||||
## 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 | 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. |
|
||||
| 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. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
@@ -103,6 +104,5 @@ 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. 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.
|
||||
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.
|
||||
|
||||
@@ -33,10 +33,9 @@ const model = OpenAI.configure({
|
||||
//
|
||||
// - `generation`: common controls such as max tokens, temperature, topP/topK,
|
||||
// penalties, seed, and stop sequences.
|
||||
// - `cache`: provider-neutral prompt caching behavior and cache affinity.
|
||||
// - `providerOptions`: namespaced provider-native behavior. For example,
|
||||
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or
|
||||
// OpenRouter routing/reasoning.
|
||||
// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
|
||||
// config, or OpenRouter routing/reasoning.
|
||||
// - `http`: last-resort serializable overlays for final request body, headers,
|
||||
// and query params. Prefer typed `providerOptions` when a field is stable.
|
||||
//
|
||||
@@ -46,7 +45,9 @@ const request = LLM.request({
|
||||
system: "You are concise and practical.",
|
||||
prompt: "Tell me a joke",
|
||||
generation: { maxTokens: 80, temperature: 0.7 },
|
||||
cache: { mode: "auto", key: "tutorial-joke" },
|
||||
providerOptions: {
|
||||
openai: { promptCacheKey: "tutorial-joke" },
|
||||
},
|
||||
})
|
||||
|
||||
// 3. `generate` sends the request and collects the event stream into one
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CacheExplicit, type CachePolicy } from "./schema/options"
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||
|
||||
const AUTO: Omit<CacheExplicit, "mode" | "key"> = {
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
system: true,
|
||||
messages: { tail: 1 },
|
||||
}
|
||||
|
||||
const NONE: Omit<CacheExplicit, "mode" | "key"> = {}
|
||||
const NONE: CachePolicyObject = {}
|
||||
const BREAKPOINT_CAP = 4
|
||||
|
||||
// Resolution rules:
|
||||
@@ -29,8 +29,8 @@ const BREAKPOINT_CAP = 4
|
||||
// - "auto" → tools + first/last system + final message boundary.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): Omit<CacheExplicit, "mode" | "key"> => {
|
||||
if (policy === undefined || (policy !== "none" && policy.mode === "auto")) return AUTO
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
if (policy === undefined || policy === "auto") return AUTO
|
||||
if (policy === "none") return NONE
|
||||
return policy
|
||||
}
|
||||
@@ -103,7 +103,7 @@ const markMessageAt = (
|
||||
|
||||
const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CacheExplicit["messages"]>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<Message> => {
|
||||
@@ -133,11 +133,7 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (
|
||||
request.model.route.id === "openrouter" &&
|
||||
(request.cache === undefined || (request.cache !== "none" && request.cache.mode === "auto"))
|
||||
)
|
||||
return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
cacheKey,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Usage,
|
||||
@@ -543,7 +542,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(cacheKey(request.cache) ? { prompt_cache_key: cacheKey(request.cache) } : {}),
|
||||
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
|
||||
...(options.include ? { include: options.include } : {}),
|
||||
...(options.reasoningEffort || options.reasoningSummary
|
||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||
|
||||
@@ -22,6 +22,8 @@ const signRequest = (input: {
|
||||
readonly body: string
|
||||
readonly headers: Headers.Headers
|
||||
readonly credentials: Credentials
|
||||
readonly service: string
|
||||
readonly name: string
|
||||
}) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
@@ -34,23 +36,26 @@ const signRequest = (input: {
|
||||
accessKeyId: input.credentials.accessKeyId,
|
||||
secretAccessKey: input.credentials.secretAccessKey,
|
||||
sessionToken: input.credentials.sessionToken,
|
||||
service: "bedrock",
|
||||
service: input.service,
|
||||
}).sign()
|
||||
return Object.fromEntries(signed.headers.entries())
|
||||
},
|
||||
catch: (error) =>
|
||||
ProviderShared.invalidRequest(
|
||||
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`${input.name} 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) =>
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
)
|
||||
}
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
@@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) =>
|
||||
body: input.body,
|
||||
headers: headersForSigning,
|
||||
credentials,
|
||||
service: options.service ?? "bedrock",
|
||||
name: options.name ?? "Bedrock Converse",
|
||||
})
|
||||
return Headers.setAll(headersForSigning, signed)
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export interface Resolved {
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: string
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
@@ -49,6 +50,7 @@ export const resolve = (request: LLMRequest): Resolved => {
|
||||
return {
|
||||
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
|
||||
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||
reasoningSummary:
|
||||
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
@@ -33,7 +32,6 @@ const patterns = [
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
@@ -44,11 +42,15 @@ const patterns = [
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
export { chatModel as model } from "../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../amazon-bedrock-mantle"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { chatModel as model } from "../../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||
@@ -0,0 +1,2 @@
|
||||
export { responsesModel as model } from "../../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OpenResponsesOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
|
||||
@@ -17,6 +17,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||
const openai = Object.fromEntries(
|
||||
definedEntries({
|
||||
store: options?.store,
|
||||
promptCacheKey: options?.promptCacheKey,
|
||||
reasoningEffort: options?.reasoningEffort,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
include: options?.include,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { cacheKey, ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
@@ -55,6 +55,7 @@ export interface OpenRouterOptions {
|
||||
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||
readonly models?: ReadonlyArray<string>
|
||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||
readonly promptCacheKey?: string
|
||||
readonly provider?: OpenRouterProviderRouting
|
||||
readonly reasoning?: Readonly<{
|
||||
enabled?: boolean
|
||||
@@ -121,7 +122,6 @@ export const protocol = Protocol.make({
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions?.openrouter),
|
||||
...(cacheKey(request.cache) ? { prompt_cache_key: cacheKey(request.cache) } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
@@ -161,6 +161,7 @@ const bodyOptions = (input: unknown) => {
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
|
||||
@@ -256,17 +256,18 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
const CacheKey = { key: Schema.optional(Schema.String) }
|
||||
|
||||
export const CacheAuto = Schema.Struct({
|
||||
mode: Schema.Literal("auto"),
|
||||
...CacheKey,
|
||||
})
|
||||
export type CacheAuto = Schema.Schema.Type<typeof CacheAuto>
|
||||
|
||||
export const CacheExplicit = Schema.Struct({
|
||||
mode: Schema.Literal("explicit"),
|
||||
...CacheKey,
|
||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||
// per-protocol body builders then translate those hints into wire markers as
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places
|
||||
// breakpoints at the last tool definition, the first and last distinct system
|
||||
// parts, and the conversation tail. The rolling message breakpoint keeps a
|
||||
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
|
||||
// tool loops.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
export const CachePolicyObject = Schema.Struct({
|
||||
tools: Schema.optional(Schema.Boolean),
|
||||
system: Schema.optional(Schema.Boolean),
|
||||
messages: Schema.optional(
|
||||
@@ -278,12 +279,7 @@ export const CacheExplicit = Schema.Struct({
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
})
|
||||
export type CacheExplicit = Schema.Schema.Type<typeof CacheExplicit>
|
||||
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
|
||||
|
||||
// Omitted configuration uses automatic provider behavior and OpenCode's
|
||||
// automatic breakpoint placement where required. `"none"` sends no cache key
|
||||
// or explicit controls; providers may still cache implicitly.
|
||||
export const CachePolicy = Schema.Union([Schema.Literal("none"), CacheAuto, CacheExplicit])
|
||||
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
|
||||
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
|
||||
|
||||
export const cacheKey = (cache: CachePolicy | undefined) => (cache && cache !== "none" ? cache.key : undefined)
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("applyCachePolicy", () => {
|
||||
Message.assistant("assistant reply"),
|
||||
Message.user("latest user message"),
|
||||
],
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("applyCachePolicy", () => {
|
||||
model: openaiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("applyCachePolicy", () => {
|
||||
model: geminiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("applyCachePolicy", () => {
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("applyCachePolicy", () => {
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: { mode: "explicit", tools: true },
|
||||
cache: { tools: true },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("applyCachePolicy", () => {
|
||||
{ type: "text", text: "last system" },
|
||||
],
|
||||
prompt: "hi",
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -233,7 +233,7 @@ describe("applyCachePolicy", () => {
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: { mode: "auto" },
|
||||
cache: "auto",
|
||||
})
|
||||
const applied = applyCachePolicy(request)
|
||||
expect(applied.tools[0]?.cache).toBeDefined()
|
||||
@@ -267,7 +267,7 @@ describe("applyCachePolicy", () => {
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: { mode: "explicit", system: true, ttlSeconds: 3600 },
|
||||
cache: { system: true, ttlSeconds: 3600 },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -283,7 +283,7 @@ describe("applyCachePolicy", () => {
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
|
||||
cache: { mode: "explicit", messages: { tail: 2 } },
|
||||
cache: { messages: { tail: 2 } },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("applyCachePolicy", () => {
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
|
||||
cache: { mode: "explicit", messages: "latest-assistant" },
|
||||
cache: { messages: "latest-assistant" },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
||||
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
expect(failures).toEqual(
|
||||
failures.map((failure) =>
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"ProviderInternal",
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
|
||||
|
||||
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", cache: { mode: "auto", key: "cache" } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Prompt cache keys must be strings.
|
||||
cache: { mode: "auto", key: 1 },
|
||||
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
|
||||
providerOptions: { openai: { promptCacheKey: 1 } },
|
||||
})
|
||||
|
||||
@@ -23,12 +23,16 @@ 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 () => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
cache: { mode: "auto", key: "recorded-cache-test" },
|
||||
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
|
||||
@@ -680,9 +680,9 @@ describe("OpenAI Responses route", () => {
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
cache: { mode: "auto", key: "session_123" },
|
||||
providerOptions: {
|
||||
openai: {
|
||||
promptCacheKey: "session_123",
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
@@ -801,16 +801,17 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key", () =>
|
||||
it.effect("request OpenAI provider options override route defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
apiKey: "test",
|
||||
providerOptions: { openai: { promptCacheKey: "model_cache" } },
|
||||
}).model("gpt-4.1-mini"),
|
||||
prompt: "no cache",
|
||||
cache: { mode: "auto", key: "request_cache" },
|
||||
providerOptions: { openai: { promptCacheKey: "request_cache" } },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("OpenRouter", () => {
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
cache: { mode: "explicit", tools: true, system: true, messages: { tail: 1 } },
|
||||
cache: { tools: true, system: true, messages: { tail: 1 } },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: { mode: "explicit", messages: "latest-assistant" },
|
||||
cache: { messages: "latest-assistant" },
|
||||
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
|
||||
}),
|
||||
)
|
||||
@@ -162,6 +162,7 @@ describe("OpenRouter", () => {
|
||||
openrouter: {
|
||||
usage: true,
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
@@ -173,7 +174,6 @@ describe("OpenRouter", () => {
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
prompt: "Think briefly.",
|
||||
cache: { mode: "auto", key: "session_123" },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
@@ -45,7 +45,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
|
||||
},
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
||||
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
onboarding: !kv.dismissed_getting_started,
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||
|
||||
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
|
||||
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -100,7 +99,6 @@
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -122,7 +120,6 @@
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -10,9 +10,15 @@ export interface Mapping {
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
export function map(
|
||||
packageName: string | undefined,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID?: string,
|
||||
): 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",
|
||||
@@ -36,6 +42,79 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
|
||||
}
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
@@ -108,7 +187,6 @@ function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
@@ -125,6 +203,7 @@ function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { xai: options } }
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Project } from "../project"
|
||||
import { Session } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
export const Destination = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "MoveSession.Destination" })
|
||||
export type Destination = typeof Destination.Type
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
destination: Destination,
|
||||
moveChanges: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "MoveSession.Input" })
|
||||
export type Input = typeof Input.Type
|
||||
|
||||
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
|
||||
"MoveSession.DestinationProjectMismatchError",
|
||||
{
|
||||
expected: Project.ID,
|
||||
actual: Project.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
||||
"MoveSession.DestinationNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
||||
"MoveSession.DestinationNotDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
||||
"MoveSession.CaptureChangesError",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
||||
"MoveSession.ResetSourceChangesError",
|
||||
{
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Session.NotFoundError
|
||||
| DestinationProjectMismatchError
|
||||
| DestinationNotFoundError
|
||||
| DestinationNotDirectoryError
|
||||
| Session.DestinationNotFoundError
|
||||
| Session.DestinationNotDirectoryError
|
||||
| CaptureChangesError
|
||||
| ApplyChangesError
|
||||
| ResetSourceChangesError
|
||||
|
||||
export interface Interface {
|
||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const project = yield* Project.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
|
||||
const value = input.destination.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
||||
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
|
||||
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (current.location.directory === directory) return
|
||||
|
||||
const source = yield* project.resolve(current.location.directory)
|
||||
const destination = yield* project.resolve(directory)
|
||||
if (input.moveChanges && current.projectID !== destination.id) {
|
||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
// A move must not race active execution: a mid-drain relocation would let
|
||||
// the source Location dispatch a request assembled under stale instructions
|
||||
// and history. Serialize like removal does — stop the drain, then move.
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
||||
const patch = sourceRepository
|
||||
? yield* git.change
|
||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: Git.ChangeSet.make("")
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
||||
yield* git.change
|
||||
.apply({ repository, path: directory, changes: patch })
|
||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
yield* session.move({
|
||||
sessionID: input.sessionID,
|
||||
directory,
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(current.location.directory)
|
||||
if (!repository)
|
||||
return yield* new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: "Source is not a Git repository",
|
||||
})
|
||||
yield* git.change
|
||||
.discard({
|
||||
repository,
|
||||
path: current.location.directory,
|
||||
index: "preserve",
|
||||
untracked: "remove",
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: error.message,
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ moveSession })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Git.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
Session.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
],
|
||||
})
|
||||
@@ -148,6 +148,3 @@ export function buildLocationServiceMap(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
|
||||
@@ -175,15 +175,22 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
|
||||
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 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, {
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
export * as LayerMapExample from "./layer-map.example"
|
||||
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
|
||||
/**
|
||||
* Tutorial: split global services from context-specific services.
|
||||
*
|
||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
||||
* while another part should be cached per request/project/workspace key.
|
||||
*
|
||||
* In this example:
|
||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
||||
* be provided once by the application runtime.
|
||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
||||
* cached by LayerMap for that key.
|
||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
||||
*
|
||||
* Lifetime model:
|
||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
||||
* ConfigService for exactly that context key.
|
||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
||||
* running on the old instance; the next lookup can create a fresh instance.
|
||||
*
|
||||
* Key model:
|
||||
* - Keys can be strings, structs, classes, arrays, etc.
|
||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
||||
*/
|
||||
|
||||
export type RequestContext = {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
}
|
||||
|
||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
||||
"@opencode/example/RequestContextRef",
|
||||
) {}
|
||||
|
||||
export interface ConfigServiceShape {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
readonly nextUse: () => Effect.Effect<number>
|
||||
readonly which: Npm.Interface["which"]
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
||||
"@opencode/example/ConfigService",
|
||||
) {}
|
||||
|
||||
const configServiceLayer = Layer.effect(
|
||||
ConfigService,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* RequestContextRef
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
let useCount = 0
|
||||
|
||||
return ConfigService.of({
|
||||
directory: context.directory,
|
||||
workspace: context.workspace,
|
||||
nextUse: () => Effect.succeed(++useCount),
|
||||
which: npm.which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
||||
lookup: (context: RequestContext) =>
|
||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
|
||||
export const appLayer = ConfigServiceMap.layer
|
||||
|
||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
||||
const config = yield* ConfigService
|
||||
|
||||
return {
|
||||
directory: config.directory,
|
||||
workspace: config.workspace,
|
||||
useCount: yield* config.nextUse(),
|
||||
}
|
||||
})
|
||||
|
||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
||||
})
|
||||
|
||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
||||
@@ -120,7 +120,6 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
)
|
||||
|
||||
export const layer = layerWithCell(defaultCell)
|
||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ 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")],
|
||||
|
||||
@@ -10,7 +10,6 @@ import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import type { SessionMessage } from "./message"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { App } from "../app"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
@@ -258,7 +257,6 @@ const make = (dependencies: Dependencies) => {
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
cache: { mode: "auto", key: SessionPromptCacheKey.make(plan.session.id) },
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
|
||||
@@ -11,7 +11,6 @@ import { SessionContext } from "./context"
|
||||
import { SessionGenerate } from "./generate"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
@@ -32,6 +31,9 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||
? selection.session.id.slice(4)
|
||||
: selection.session.id
|
||||
const tools = selection.tools
|
||||
const toolDefinitions = tools.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
@@ -69,7 +71,7 @@ export const layer = Layer.effect(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
cache: { mode: "auto", key: SessionPromptCacheKey.make(selection.session.id) },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -14,7 +14,6 @@ import { QuestionTool } from "../tool/plugin/question"
|
||||
import { Tool } from "../tool"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
@@ -184,6 +183,7 @@ export const layer = Layer.effect(
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const tools = input.context.tools
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
@@ -213,7 +213,7 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
cache: { mode: "auto", key: SessionPromptCacheKey.make(session.id) },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export * as SessionPromptCacheKey from "./prompt-cache-key"
|
||||
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export const make = (sessionID: SessionSchema.ID) =>
|
||||
/^ses_[0-9a-f]{64}$/.test(sessionID) ? sessionID.slice(4) : sessionID
|
||||
@@ -12,7 +12,6 @@ import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionUsage } from "./usage"
|
||||
@@ -81,7 +80,6 @@ const make = (dependencies: Dependencies) => {
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
cache: { mode: "auto", key: SessionPromptCacheKey.make(session.id) },
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
|
||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
return providerError("provider.rate-limit", cause.reason)
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
return providerError("provider.auth", cause.reason)
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
return providerError("provider.quota", cause.reason)
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
return providerError("provider.content-filter", cause.reason)
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
return providerError("provider.transport", cause.reason)
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
return providerError("provider.internal", cause.reason)
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
return providerError("provider.invalid-output", cause.reason)
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
return providerError("provider.invalid-request", cause.reason)
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
return providerError("provider.no-route", cause.reason)
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
return providerError("provider.unknown", cause.reason)
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export * as ConfigConsoleStateV1 from "./console-state"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
||||
consoleManagedProviders: [],
|
||||
activeOrgName: undefined,
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
@@ -2,6 +2,87 @@ 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", {
|
||||
@@ -13,6 +94,7 @@ describe("AISDKNative", () => {
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -23,6 +105,7 @@ describe("AISDKNative", () => {
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
@@ -103,6 +186,7 @@ describe("AISDKNative", () => {
|
||||
baseURL: "https://xai.example/v1",
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
@@ -113,6 +197,7 @@ describe("AISDKNative", () => {
|
||||
xai: {
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,6 +42,35 @@ 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"), {
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
// Records the execution serialization a move must perform before relocating.
|
||||
const executionCalls: string[] = []
|
||||
const recordingExecution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
|
||||
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
MoveSession.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
ProjectDirectories.node,
|
||||
Project.node,
|
||||
Session.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
[[SessionExecution.node, recordingExecution]],
|
||||
),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.autocrlf false`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
|
||||
await $`git add tracked.txt`.cwd(directory).quiet()
|
||||
await $`git commit -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("MoveSession", () => {
|
||||
it.live("moves session changes to another project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(`${root.path}-move-destination`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
|
||||
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move",
|
||||
directory: source,
|
||||
title: "move",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
executionCalls.length = 0
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
||||
)
|
||||
|
||||
// The move stops active execution before any relocation side effect.
|
||||
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ directory: moved, path: "" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves within a checkout without transferring existing changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(path.join(source, "packages"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-nested",
|
||||
directory: source,
|
||||
title: "move nested",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const missing = yield* Session.Service.use((service) =>
|
||||
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
|
||||
)
|
||||
expect(missing._tag).toBe("Session.DestinationNotFoundError")
|
||||
yield* Effect.promise(() => fs.mkdir(destination))
|
||||
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ directory: destination, path: "packages" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves a session to another project", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(`${root.path}-other-project`)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
|
||||
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
|
||||
)
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
|
||||
const sessionID = Session.ID.make("ses_move_project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-project",
|
||||
directory: source,
|
||||
title: "move project",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* Session.Service.use((service) =>
|
||||
service.move({ sessionID, directory: destination }),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ projectID: destinationProjectID, directory: destination })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves nested session changes without cleaning unrelated files", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const sourceDirectory = abs(path.join(source, "packages"))
|
||||
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
|
||||
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
|
||||
const destination = abs(`${root.path}-move-nested-destination`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
|
||||
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
|
||||
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested_checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-nested-checkout",
|
||||
directory: sourceDirectory,
|
||||
title: "move nested checkout",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
|
||||
"initial\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
|
||||
"staged\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
|
||||
"M packages/staged.txt\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -235,7 +235,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.cache).toEqual({ mode: "auto", key: sessionID })
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
const http = new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||
})
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
})
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||
type: "provider.internal",
|
||||
message: "bad gateway",
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -287,7 +287,7 @@ it.effect("generates from fresh settled Session context without durable mutation
|
||||
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
|
||||
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
|
||||
expect(requests[0]?.cache).toEqual({ mode: "auto", key: sessionID })
|
||||
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
|
||||
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
|
||||
|
||||
@@ -3210,7 +3210,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* stream.started
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map((request) => (request.cache !== "none" ? request.cache?.key : undefined))).toEqual([
|
||||
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
|
||||
sessionID,
|
||||
otherSessionID,
|
||||
])
|
||||
@@ -3241,7 +3241,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(longSessionID)
|
||||
yield* session.resume(otherLongSessionID)
|
||||
|
||||
const keys = requests.map((request) => (request.cache !== "none" ? request.cache?.key : undefined))
|
||||
const keys = requests.map((request) => request.providerOptions?.openai?.promptCacheKey)
|
||||
expect(keys).toEqual([longSessionID.slice(4), otherLongSessionID.slice(4)])
|
||||
expect(keys.every((key) => typeof key === "string" && key.length === 64)).toBe(true)
|
||||
expect(keys[0]).not.toBe(keys[1])
|
||||
|
||||
@@ -153,7 +153,6 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.cache).toEqual({ mode: "auto", key: sessionID })
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||
export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
|
||||
+23
-14
@@ -68,13 +68,14 @@ import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { sessionTabsFitVertically } from "./ui/layout"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { PromptHistoryProvider } from "./prompt/history"
|
||||
import { FrecencyProvider } from "./prompt/frecency"
|
||||
import { PromptStashProvider } from "./prompt/stash"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import * as Model from "./util/model"
|
||||
@@ -83,6 +84,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -208,9 +210,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
|
||||
)
|
||||
const directory = location.directory
|
||||
const pluginDirectories = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config),
|
||||
)
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const managed = input.server.service
|
||||
@@ -378,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
@@ -519,6 +528,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
||||
const tabsVisible = () =>
|
||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
@@ -1214,16 +1226,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<Show when={tabsVisible() && tabsVertical()}>
|
||||
<SessionTabs orientation="vertical" />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<Show when={tabsVisible() && !tabsVertical()}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiKV,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
@@ -115,8 +114,6 @@ export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
update?: Config.Interface["update"]
|
||||
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
let audio: Audio | null | undefined
|
||||
@@ -42,10 +42,6 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
||||
return current.play(sound, options)
|
||||
}
|
||||
|
||||
export function stopVoice(voice: AudioVoice) {
|
||||
return audio?.stopVoice(voice) ?? false
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
audio?.dispose()
|
||||
audio = undefined
|
||||
|
||||
@@ -43,15 +43,6 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["motion", "effects"],
|
||||
},
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["hints", "getting started", "guidance"],
|
||||
},
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
@@ -109,6 +100,15 @@ export const settings: Setting[] = [
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
{
|
||||
title: "Vertical",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "vertical"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => {
|
||||
const provider = providers().get(model.providerID)
|
||||
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
return {
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
providerID: model.providerID,
|
||||
providerName: provider?.name ?? model.providerID,
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
favorite,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
if (needle) {
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
return prioritizeFavorites(
|
||||
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||
)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||
}
|
||||
|
||||
export function sortModelOptions<
|
||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||
>(options: T[]) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -18,6 +17,7 @@ import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
|
||||
@@ -81,7 +81,7 @@ export function DialogOpen() {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const name = projectName(project)
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
@@ -109,7 +109,7 @@ export function DialogOpen() {
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -123,8 +124,7 @@ export function DialogSessionList() {
|
||||
const current = data.location.info()
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
if (!project) return ""
|
||||
return project.name || path.basename(project.canonical)
|
||||
return projectName(project) ?? ""
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -141,9 +141,7 @@ export function DialogSessionList() {
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: undefined
|
||||
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
import { usePromptStash, type StashEntry } from "../prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useDialog } from "../ui/dialog"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
|
||||
@@ -23,6 +23,7 @@ import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
import { moveSelection, revealSelectionOffset } from "../../ui/select-controller"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
@@ -501,22 +502,19 @@ export function Autocomplete(props: {
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
moveTo(next)
|
||||
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const viewportHeight = Math.min(height(), options().length)
|
||||
const scrollBottom = scroll.scrollTop + viewportHeight
|
||||
if (next < scroll.scrollTop) {
|
||||
scroll.scrollBy(next - scroll.scrollTop)
|
||||
} else if (next + 1 > scrollBottom) {
|
||||
scroll.scrollBy(next + 1 - scrollBottom)
|
||||
}
|
||||
const offset = revealSelectionOffset(scroll.scrollTop, {
|
||||
count: options().length,
|
||||
limit: Math.min(height(), options().length),
|
||||
selected: next,
|
||||
})
|
||||
if (offset === scroll.scrollTop) return
|
||||
scroll.scrollBy(offset - scroll.scrollTop)
|
||||
}
|
||||
|
||||
function select() {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../prompt/frecency"
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../prompt/history"
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import { registerOpencodeSpinner } from "../register-spinner"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
@@ -55,8 +54,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../prompt/stash"
|
||||
@@ -1,11 +1,13 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useData } from "../context/data"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
seedSessionTabMotion,
|
||||
@@ -18,6 +20,8 @@ import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -38,8 +42,338 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
|
||||
}
|
||||
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
if (props.orientation === "vertical")
|
||||
return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
}
|
||||
|
||||
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const width = () => SESSION_SIDEBAR_WIDTH
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
items().map((tab) => {
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
runs: status.busy && !status.attention,
|
||||
glows:
|
||||
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!scroll) return
|
||||
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
||||
if (index === -1) return
|
||||
const top = index * 3
|
||||
if (top < scroll.scrollTop) return scroll.scrollTo(top)
|
||||
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
|
||||
scroll.scrollTo(top + 2 - scroll.viewport.height)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
width={width()}
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
<For each={items()}>
|
||||
{(tab, index) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = createMemo(() => itemStatus(tab))
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => String(index() + 1).length + 1
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
})
|
||||
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), Number(selected()))
|
||||
const color = tint(base, accent(), Number(complete()))
|
||||
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
|
||||
}
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return selected() ? theme.text.default : theme.text.subdued
|
||||
}
|
||||
const complete = () => status().complete
|
||||
const glowHue = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
const tab = previous()
|
||||
return tab
|
||||
? itemStatus(tab)
|
||||
: { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
|
||||
})
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const runs = () => status().runs
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return theme.text.feedback.warning.default
|
||||
if (previousStatus().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const titleColor = (index: number) => {
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
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()}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
<Show when={index() === items().length - 1}>
|
||||
<TabPulse
|
||||
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}
|
||||
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()}
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{index() + 1}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<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()}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
@@ -48,6 +382,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
// so the strip never flashes the pre-drag order while the write is in flight.
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
let strip: { screenX: number } | undefined
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
@@ -55,7 +393,18 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
@@ -253,9 +602,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows()
|
||||
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
|
||||
: base
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
|
||||
const position = index - (displayedParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
@@ -279,6 +626,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
@@ -295,7 +644,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
@@ -341,6 +691,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
fg={closeColor()}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
|
||||
@@ -2,16 +2,28 @@ import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderC
|
||||
import { extend } from "@opentui/solid"
|
||||
|
||||
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
|
||||
backgroundColor?: RGBA
|
||||
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
|
||||
onLevel?: (level: number) => void
|
||||
@@ -20,6 +32,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_ATTACK = 450
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
@@ -58,9 +71,10 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
|
||||
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
|
||||
export const glowIgnitionLevel = (progress: number) =>
|
||||
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
|
||||
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
|
||||
const tail = Math.min(maximumTail, Math.max(1, width - 2))
|
||||
return glowIntensityAt(index, tail)
|
||||
}
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
@@ -131,45 +145,236 @@ class Envelope {
|
||||
// Hoisted so the per-frame liveness check allocates no closure.
|
||||
const envelopeActive = (envelope: Envelope) => envelope.active
|
||||
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private _active: boolean
|
||||
private _promptPulse: number
|
||||
private _complete: boolean
|
||||
private _glow: boolean
|
||||
private _breathe: boolean
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _flashColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
type PulseStateOptions = {
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
promptPulse: number
|
||||
complete: boolean
|
||||
glow: boolean
|
||||
breathe: boolean
|
||||
}
|
||||
|
||||
class PulseState {
|
||||
private enabled: boolean
|
||||
private active: boolean
|
||||
private promptPulse: number
|
||||
private complete: boolean
|
||||
private glow: boolean
|
||||
private breathe: boolean
|
||||
private clock = 0
|
||||
private breatheClock = 0
|
||||
private completionPending = false
|
||||
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
this.active = options.active
|
||||
this.promptPulse = options.promptPulse
|
||||
this.complete = options.complete
|
||||
this.glow = options.glow
|
||||
this.breathe = options.breathe
|
||||
if (this.enabled && this.active) this.runAttack.start()
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this.enabled && this.glow && this.breathe
|
||||
}
|
||||
|
||||
get live() {
|
||||
return this.active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
}
|
||||
|
||||
get running() {
|
||||
if (!this.enabled) return 0
|
||||
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
|
||||
}
|
||||
|
||||
get completion() {
|
||||
return this.completionPulse.level() * COMPLETION_OPACITY
|
||||
}
|
||||
|
||||
get flash() {
|
||||
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
}
|
||||
|
||||
get glowLevel() {
|
||||
if (!this.glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
} else if (this.active) {
|
||||
this.runAttack.restart()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setActive(value: boolean) {
|
||||
if (value === this.active) return false
|
||||
this.active = value
|
||||
if (!this.enabled) return true
|
||||
if (value) {
|
||||
this.clock = 0
|
||||
this.runAttack.restart()
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
const level = this.runAttack.active ? this.runAttack.level() : 1
|
||||
this.runAttack.stop()
|
||||
this.runFade.start(level)
|
||||
this.completionPending = true
|
||||
}
|
||||
this.edgeFlash.start()
|
||||
return true
|
||||
}
|
||||
|
||||
setPromptPulse(value: number) {
|
||||
if (value === this.promptPulse) return false
|
||||
this.promptPulse = value
|
||||
if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
return true
|
||||
}
|
||||
|
||||
setComplete(value: boolean) {
|
||||
if (value === this.complete) return false
|
||||
this.complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this.enabled) this.completionPulse.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setBreathe(value: boolean) {
|
||||
if (value === this.breathe) return false
|
||||
this.breathe = value
|
||||
this.breatheClock = 0
|
||||
return true
|
||||
}
|
||||
|
||||
advance(deltaTime: number) {
|
||||
if (!this.enabled) return
|
||||
if (this.active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (!this.completionPending) return
|
||||
if (this.complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
return
|
||||
}
|
||||
if (!this.runFade.active) this.completionPending = false
|
||||
}
|
||||
|
||||
fronts(width: number) {
|
||||
const cycles = this.clock / RUN_DURATION
|
||||
const progress = cycles % 1
|
||||
const start = -RUN_HEAD
|
||||
const end = width - 1 + RUN_TAIL
|
||||
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
|
||||
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
|
||||
}
|
||||
}
|
||||
|
||||
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 _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 _onLevel: ((level: number) => void) | undefined
|
||||
private lastLevel = 0
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
const active = options.active ?? false
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
const glow = options.glow ?? false
|
||||
const breathe = options.breathe ?? false
|
||||
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)))),
|
||||
})
|
||||
this._enabled = enabled
|
||||
this._active = active
|
||||
this._promptPulse = options.promptPulse ?? 0
|
||||
this._complete = options.complete ?? false
|
||||
this._glow = options.glow ?? false
|
||||
this._breathe = options.breathe ?? false
|
||||
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._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
|
||||
}
|
||||
@@ -179,103 +384,64 @@ class TabPulseRenderable extends Renderable {
|
||||
}
|
||||
|
||||
private emitLevel(value: number) {
|
||||
if (!this._onLevel) return
|
||||
const quantized = Math.round(value * 32) / 32
|
||||
if (quantized === this.lastLevel) return
|
||||
this.lastLevel = quantized
|
||||
this._onLevel?.(quantized)
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this._enabled && this._glow && this._breathe
|
||||
}
|
||||
|
||||
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
|
||||
private glowLevel() {
|
||||
if (!this._glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
this.live = false
|
||||
} else if (this._active || this.breathing) {
|
||||
this.live = true
|
||||
}
|
||||
this.inner.setEnabled(value)
|
||||
this.outer.setEnabled(value && this._edge !== undefined)
|
||||
this.live = this.inner.live || this.outer.live
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set active(value: boolean) {
|
||||
if (value === this._active) return
|
||||
this._active = value
|
||||
if (!this._enabled) return
|
||||
if (value) {
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
this.runFade.start()
|
||||
this.completionPending = true
|
||||
}
|
||||
// The same neutral edge flash marks both the start and the finish of a run.
|
||||
this.edgeFlash.start()
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
if (this.inner.setActive(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerActive(value: boolean) {
|
||||
if (this.outer.setActive(value)) this.changed()
|
||||
}
|
||||
|
||||
set promptPulse(value: number) {
|
||||
if (value === this._promptPulse) return
|
||||
this._promptPulse = value
|
||||
if (!this._enabled) return
|
||||
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
if (this.inner.setPromptPulse(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerPromptPulse(value: number) {
|
||||
if (this.outer.setPromptPulse(value)) this.changed()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this._enabled) {
|
||||
this.completionPulse.start()
|
||||
this.live = true
|
||||
}
|
||||
}
|
||||
this.requestRender()
|
||||
if (this.inner.setComplete(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerComplete(value: boolean) {
|
||||
if (this.outer.setComplete(value)) this.changed()
|
||||
}
|
||||
|
||||
set glow(value: boolean) {
|
||||
if (value === this._glow) return
|
||||
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
|
||||
this._glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this._enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
if (this.inner.setGlow(value)) this.changed()
|
||||
}
|
||||
|
||||
set outerGlow(value: boolean) {
|
||||
if (this.outer.setGlow(value)) this.changed()
|
||||
}
|
||||
|
||||
set breathe(value: boolean) {
|
||||
if (value === this._breathe) return
|
||||
this._breathe = value
|
||||
this.breatheClock = 0
|
||||
if (this.breathing) this.live = true
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -291,18 +457,62 @@ class TabPulseRenderable extends Renderable {
|
||||
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 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()
|
||||
}
|
||||
|
||||
set backgroundColor(value: RGBA) {
|
||||
if (value.equals(this._backgroundColor)) return
|
||||
this._backgroundColor = value
|
||||
@@ -311,42 +521,45 @@ class TabPulseRenderable extends Renderable {
|
||||
|
||||
protected override onUpdate(deltaTime: number): void {
|
||||
if (!this._enabled) return
|
||||
if (this._active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (this.completionPending) {
|
||||
if (this._complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
} else if (!this.runFade.active) {
|
||||
this.completionPending = false
|
||||
}
|
||||
}
|
||||
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
this.inner.advance(deltaTime)
|
||||
this.outer.advance(deltaTime)
|
||||
this.live = this.inner.live || this.outer.live
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
|
||||
const completion = this.completionPulse.level() * COMPLETION_OPACITY
|
||||
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
|
||||
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
const glowLevel = this.glowLevel()
|
||||
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
|
||||
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
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
outerGlowLevel === 0 &&
|
||||
outerRunning === 0 &&
|
||||
outerCompletion === 0 &&
|
||||
outerFlash === 0
|
||||
) {
|
||||
this.emitLevel(0)
|
||||
return
|
||||
}
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
const [front, secondFront] = this.inner.fronts(this.width)
|
||||
const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
|
||||
if (this._onLevel)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: 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))
|
||||
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 =
|
||||
@@ -358,6 +571,15 @@ class TabPulseRenderable extends Renderable {
|
||||
) *
|
||||
0.14 *
|
||||
running
|
||||
const outerSweep =
|
||||
outerRunning === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
|
||||
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
|
||||
) *
|
||||
0.14 *
|
||||
outerRunning
|
||||
blendTabPulseColor(
|
||||
this.renderColor,
|
||||
this._backgroundColor,
|
||||
@@ -365,12 +587,34 @@ class TabPulseRenderable extends Renderable {
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
sweep,
|
||||
flash,
|
||||
completion,
|
||||
)
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
if (!this._edge) {
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
continue
|
||||
}
|
||||
blendTabPulseColor(
|
||||
this.outerRenderColor,
|
||||
this._backgroundColor,
|
||||
this._outerGlowColor,
|
||||
this._outerColor,
|
||||
this._outerFlashColor,
|
||||
this._outerCompletionColor,
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerSweep,
|
||||
outerFlash,
|
||||
outerCompletion,
|
||||
)
|
||||
buffer.setCell(
|
||||
this.screenX + index,
|
||||
this.screenY,
|
||||
this._edge === "above" ? "▄" : "▀",
|
||||
this.renderColor,
|
||||
this.outerRenderColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,34 +628,61 @@ declare module "@opentui/solid" {
|
||||
extend({ tab_pulse: TabPulseRenderable })
|
||||
|
||||
export function TabPulse(props: {
|
||||
top?: number
|
||||
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
|
||||
backgroundColor: RGBA
|
||||
onLevel?: (level: number) => void
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
position="absolute"
|
||||
top={props.top}
|
||||
edge={props.edge}
|
||||
zIndex={0}
|
||||
width="100%"
|
||||
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}
|
||||
backgroundColor={props.backgroundColor}
|
||||
onLevel={props.onLevel}
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
vertical: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
@@ -162,11 +165,6 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Mini transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
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 debug bar" }),
|
||||
@@ -267,7 +265,3 @@ export function useConfig() {
|
||||
if (!value) throw new Error("ConfigProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useConfigOptional() {
|
||||
return useContext(ConfigContext)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,6 @@ export const Definitions = {
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
@@ -308,8 +306,6 @@ export const CommandMap = {
|
||||
session_compact: "session.compact",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
session_child_first: "session.child.first",
|
||||
session_child_cycle: "session.child.next",
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
session_quick_switch_1: "session.quick_switch.1",
|
||||
|
||||
@@ -67,8 +67,6 @@ function initialRoute(value: unknown): Route | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export type RouteContext = ReturnType<typeof useRoute>
|
||||
|
||||
export function useRouteData<T extends Route["type"]>(type: T) {
|
||||
const route = useRoute()
|
||||
return route.data as Extract<Route, { type: typeof type }>
|
||||
|
||||
@@ -29,9 +29,11 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
|
||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||
}
|
||||
|
||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
|
||||
// detect it by identity.
|
||||
if (index === -1) return { tabs, next: undefined }
|
||||
return {
|
||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||
@@ -87,9 +89,13 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
|
||||
// session switch forever; the oldest entries fall off first.
|
||||
const SESSION_TAB_HISTORY_LIMIT = 100
|
||||
|
||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||
if (history.entries[history.index] === sessionID) return history
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
|
||||
return { entries, index: entries.length - 1 }
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function update(mutation: (draft: TabsState) => void) {
|
||||
const scope = config.tabs?.scope ?? "global"
|
||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||
() => {},
|
||||
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
|
||||
(error) => console.error("Failed to persist session tabs", error),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,7 +223,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
if (closed.tabs.length === state().tabs.length) return
|
||||
if (closed.tabs === state().tabs) return
|
||||
const selected = navigate && current() === target
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
@@ -30,6 +30,7 @@ import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||
export type ThemeError = { name: string; error: Error }
|
||||
@@ -70,7 +71,7 @@ export type ThemeSource = Readonly<{
|
||||
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
|
||||
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
@@ -194,10 +194,6 @@ export function resolveZedDbPath() {
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
export function isZedTerminal() {
|
||||
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return statSync(item).isFile()
|
||||
@@ -220,11 +216,6 @@ function zedWorkspacePaths(value: string | null) {
|
||||
return value.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||
if (byteOffset <= 0) return 0
|
||||
|
||||
|
||||
@@ -10,24 +10,18 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
|
||||
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const activeSubagents = createMemo(() => {
|
||||
const subagents = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return props.context.data.session
|
||||
const count = props.context.data.session
|
||||
.family(props.sessionID)
|
||||
.filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length
|
||||
})
|
||||
const runningShells = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return props.context.data.shell
|
||||
.list(props.context.location)
|
||||
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
|
||||
})
|
||||
const subagents = createMemo(() => {
|
||||
const count = activeSubagents()
|
||||
return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined
|
||||
})
|
||||
const shells = createMemo(() => {
|
||||
const count = runningShells()
|
||||
if (!props.sessionID) return 0
|
||||
const count = props.context.data.shell
|
||||
.list(props.context.location)
|
||||
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
|
||||
return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
@@ -40,8 +34,10 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
session.revert?.messageID,
|
||||
)
|
||||
const cost = props.context.data.session.cost(props.sessionID)
|
||||
return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
return [
|
||||
usage ? formatContextUsage(usage.tokens, usage.percent) : undefined,
|
||||
cost > 0 ? money.format(cost) : undefined,
|
||||
].filter((item): item is string => Boolean(item))
|
||||
})
|
||||
const live = createMemo(() => Boolean(subagents() || shells()))
|
||||
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
|
||||
|
||||
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const theme = props.context.theme
|
||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
@@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
<Show when={state() || cost() > 0}>
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={cost() > 0}>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function View() {
|
||||
const context = usePlugin()
|
||||
const theme = context.theme
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-lsp",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", () => <View />)
|
||||
},
|
||||
})
|
||||
@@ -141,22 +141,6 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
|
||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFile(
|
||||
rows: readonly FileTreeRow[],
|
||||
selected: number | undefined,
|
||||
offset: number,
|
||||
) {
|
||||
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
|
||||
if (fileRows.length === 0) return undefined
|
||||
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
|
||||
const next =
|
||||
offset < 0
|
||||
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
|
||||
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
|
||||
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
|
||||
}
|
||||
|
||||
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
|
||||
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
|
||||
if (!node) return undefined
|
||||
|
||||
@@ -7,5 +7,3 @@ export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
|
||||
@@ -90,10 +90,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
draining = (async () => {
|
||||
try {
|
||||
while (!state.closed && state.queue.length > 0) {
|
||||
const prompt = state.queue.shift()
|
||||
if (!prompt) {
|
||||
continue
|
||||
}
|
||||
const prompt = state.queue.shift()!
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
if (!input.onNewSession) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import HomeFooter from "../feature-plugins/home/footer"
|
||||
import PromptFooter from "../feature-plugins/prompt/footer"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
@@ -14,7 +13,6 @@ export const builtins = [
|
||||
PromptFooter,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
@@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
@@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
void enqueue(reconcile).catch(() => undefined)
|
||||
}, 100)
|
||||
})
|
||||
onCleanup(() => {
|
||||
const stopWatching = () => {
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
})
|
||||
}
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
// core plugin registry: fold the ordered entries into a desired end state
|
||||
@@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
// every watch event; remember them until the configuration changes.
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
watcher.add(tuiPluginDirectory(host.paths.cwd))
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const options = typeof entry === "string" ? undefined : entry.options
|
||||
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
|
||||
const local = localSource(target, directory)
|
||||
if (local) watcher.add(fileURLToPath(local))
|
||||
if (local) await watcher.add(fileURLToPath(local))
|
||||
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
@@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
if (disposing) return disposing
|
||||
stopWatching()
|
||||
disposing = loading
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import {
|
||||
isMissingPath,
|
||||
localProjectDirectory,
|
||||
projectConfigDirectories,
|
||||
} from "../util/config-directories"
|
||||
|
||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
||||
|
||||
export function tuiPluginDirectory(cwd: string) {
|
||||
return path.join(cwd, ".opencode", "plugins", "tui")
|
||||
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
|
||||
const projectDirectory = await localProjectDirectory(cwd)
|
||||
const projectConfig = path.join(projectDirectory, ".opencode")
|
||||
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
|
||||
const exists = await Promise.all(
|
||||
directories.map((directory) => {
|
||||
if (directory === configDirectory || directory === projectConfig) return true
|
||||
return stat(directory).then(
|
||||
(info) => info.isDirectory(),
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return directories
|
||||
.filter((_, index) => exists[index])
|
||||
.map((directory) => path.join(directory, "plugins", "tui"))
|
||||
}
|
||||
|
||||
export async function discoverTuiPlugins(cwd: string) {
|
||||
const directory = tuiPluginDirectory(cwd)
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
export async function discoverTuiPlugins(directories: string[]) {
|
||||
return (
|
||||
await Promise.all(
|
||||
directories.map(async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (isMissingPath(error)) return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
|
||||
@@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Failed or vanished watches are
|
||||
// forgotten so a later add() can re-arm once the path exists.
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
export function createSourceWatcher(onChange: () => void) {
|
||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||
const watched = new Map<string, Set<string> | null>()
|
||||
const missing = new Set<string>()
|
||||
const arming = new Map<string, Promise<void>>()
|
||||
let disposed = false
|
||||
const notify = () => {
|
||||
if (!disposed) onChange()
|
||||
}
|
||||
const forget = (dir: string) => {
|
||||
watchers.get(dir)?.close()
|
||||
watchers.delete(dir)
|
||||
watched.delete(dir)
|
||||
}
|
||||
const arm = (target: string) => {
|
||||
stat(target)
|
||||
const arm = (target: string, retry: boolean) => {
|
||||
const active = arming.get(target)
|
||||
if (active) return active
|
||||
const result = stat(target)
|
||||
.then((info) => {
|
||||
if (disposed) return
|
||||
const appeared = missing.delete(target)
|
||||
const dir = info.isDirectory() ? target : path.dirname(target)
|
||||
// Directories accept every filename (null); files accept their basename.
|
||||
const name = info.isDirectory() ? null : path.basename(target)
|
||||
@@ -32,44 +41,69 @@ export function createSourceWatcher(onChange: () => void) {
|
||||
if (existing !== undefined) {
|
||||
if (name === null) watched.set(dir, null)
|
||||
else existing?.add(name)
|
||||
if (appeared) notify()
|
||||
return
|
||||
}
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
const watcher = watch(dir, (_event, filename) => {
|
||||
// A replaced directory keeps this watcher on the dead inode (Linux
|
||||
// emits rename, not error); forget it so a later add() re-arms on
|
||||
// the recreated path, and still schedule so reconcile runs now.
|
||||
if (!existsSync(dir)) {
|
||||
forget(dir)
|
||||
onChange()
|
||||
notify()
|
||||
return
|
||||
}
|
||||
// A null filename (platform-dependent) always schedules.
|
||||
const accept = watched.get(dir)
|
||||
if (filename && accept && !accept.has(filename.toString())) return
|
||||
onChange()
|
||||
notify()
|
||||
})
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
// Reconcile after watcher errors so every source is re-added and any
|
||||
// temporarily unavailable target moves into the polling set.
|
||||
watcher.on("error", () => {
|
||||
forget(dir)
|
||||
notify()
|
||||
})
|
||||
// A watched directory can disappear out from under us; without a
|
||||
// listener the error event would crash the process. Forget the path
|
||||
// so a later add can re-arm once it exists again.
|
||||
watcher.on("error", () => forget(dir))
|
||||
watchers.set(dir, watcher)
|
||||
if (appeared) notify()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (!disposed && retry && isMissing(error)) missing.add(target)
|
||||
})
|
||||
.finally(() => arming.delete(target))
|
||||
arming.set(target, result)
|
||||
return result
|
||||
}
|
||||
const add = (target: string) => {
|
||||
arm(target)
|
||||
const add = async (target: string, retry: boolean) => {
|
||||
await arm(target, retry)
|
||||
// A symlinked source receives edits at its resolved target.
|
||||
lstat(target)
|
||||
await lstat(target)
|
||||
.then((info) => {
|
||||
if (!info.isSymbolicLink()) return
|
||||
return realpath(target).then(arm)
|
||||
return realpath(target).then((target) => arm(target, retry))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
clearInterval(poll)
|
||||
for (const watcher of watchers.values()) watcher.close()
|
||||
watchers.clear()
|
||||
watched.clear()
|
||||
missing.clear()
|
||||
}
|
||||
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
|
||||
poll.unref()
|
||||
return {
|
||||
add: (target: string) => add(target, false),
|
||||
wait: (target: string) => add(target, true),
|
||||
dispose,
|
||||
}
|
||||
return { add, dispose }
|
||||
}
|
||||
|
||||
function isMissing(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const code = Reflect.get(error, "code")
|
||||
return code === "ENOENT" || code === "ENOTDIR"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { errorMessage } from "../../util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
@@ -199,14 +200,21 @@ export function Session() {
|
||||
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
|
||||
const groupExploration = createMemo(() => config.session?.grouping !== "none")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const availableWidth = createMemo(
|
||||
() =>
|
||||
dimensions().width -
|
||||
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
||||
? SESSION_SIDEBAR_WIDTH
|
||||
: 0),
|
||||
)
|
||||
const wide = createMemo(() => availableWidth() > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const models = createMemo(() => data.location.model.list(location()) ?? [])
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
@@ -887,22 +895,6 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Next subagent",
|
||||
id: "session.child.next",
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
enabled: !!session()?.parentID,
|
||||
run: () => unavailable("Subagent navigation"),
|
||||
},
|
||||
{
|
||||
title: "Previous subagent",
|
||||
id: "session.child.previous",
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
enabled: !!session()?.parentID,
|
||||
run: () => unavailable("Subagent navigation"),
|
||||
},
|
||||
])
|
||||
|
||||
const commands = createMemo(() =>
|
||||
@@ -1925,122 +1917,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
.models()
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
|
||||
const final = createMemo(() => {
|
||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
if (!final()) return 0
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
|
||||
const exploration = createMemo(() => {
|
||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
||||
if (!ctx.groupExploration()) return grouped
|
||||
const runs = props.message.content
|
||||
.map((part) =>
|
||||
part.type === "tool" &&
|
||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: undefined,
|
||||
)
|
||||
.reduce<SessionMessageAssistantTool[][]>(
|
||||
(runs, part) => {
|
||||
if (part) runs[runs.length - 1].push(part)
|
||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
||||
return runs
|
||||
},
|
||||
[[]],
|
||||
)
|
||||
.filter((run) => run.length > 0)
|
||||
for (const run of runs) {
|
||||
const summary = {
|
||||
parts: run,
|
||||
active: false,
|
||||
}
|
||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={props.message.content}>
|
||||
{(content, index) => (
|
||||
<Switch>
|
||||
<Match when={content.type === "text"}>
|
||||
<TextPart
|
||||
part={content as SessionMessageAssistantText}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "reasoning"}>
|
||||
<ReasoningPart
|
||||
part={content as SessionMessageAssistantReasoning}
|
||||
message={props.message}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "tool"}>
|
||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
||||
<Show
|
||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
||||
>
|
||||
{(summary) => <ExplorationSummary {...summary()} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.text.feedback.error.default}
|
||||
>
|
||||
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
@@ -2056,40 +1932,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
||||
const theme = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const label = (part: SessionMessageAssistantTool) => {
|
||||
const input = typeof part.state.input === "string" ? {} : part.state.input
|
||||
const tool = toolDisplay(part.name)
|
||||
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
|
||||
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
|
||||
return `Grep "${stringValue(input.pattern)}"`
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<InlineToolRow
|
||||
icon="✱"
|
||||
color={theme.text.subdued}
|
||||
complete={!props.active}
|
||||
pending="Exploring"
|
||||
spinner={props.active}
|
||||
>
|
||||
{props.active ? "Exploring" : "Explored"}
|
||||
</InlineToolRow>
|
||||
<For each={props.parts}>
|
||||
{(part, index) => (
|
||||
<box paddingLeft={5}>
|
||||
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
function ReasoningPart(props: {
|
||||
@@ -2954,10 +2796,6 @@ export function isBackgroundSubagent(
|
||||
return status === "completed" && metadata.status === "running"
|
||||
}
|
||||
|
||||
export function formatSubagentRetry(attempt: number, message: string) {
|
||||
return `Retrying (attempt ${attempt}) · ${message}`
|
||||
}
|
||||
|
||||
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
function executeCalls(value: unknown): ExecuteCall[] {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PluginSlot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const data = useData()
|
||||
@@ -18,7 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
width={42}
|
||||
width={SESSION_SIDEBAR_WIDTH}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
@@ -27,9 +28,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(scroll) =>
|
||||
queueMicrotask(() => {
|
||||
if (!scroll.isDestroyed) scroll.verticalScrollBar.resetVisibilityControl()
|
||||
})
|
||||
}
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
visible: false,
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user