mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb9e9b2b44 | |||
| 02b434d755 | |||
| f92e490bb6 | |||
| 0481dba88e | |||
| 1fda757fc6 | |||
| dd0e41c633 | |||
| b69e51d835 | |||
| ce23d8ae65 | |||
| e60e8d9387 | |||
| 3bfce3fd2d | |||
| b498a5c6c4 | |||
| db9e942398 | |||
| 33275995d4 | |||
| e79364fe00 | |||
| 93a915c18f | |||
| 0e26116f68 | |||
| 80532b9c76 | |||
| 49256c8851 | |||
| 8add0d6226 | |||
| 8fe47cefbd | |||
| dbd29054ed | |||
| 53632dfcc1 | |||
| 3f9945b98d | |||
| 7ca3fc44b8 | |||
| 7a9cc15296 |
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
@@ -133,6 +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 === "auto")) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -38,6 +39,11 @@ export const PATH = "/chat/completions"
|
||||
// The body schema is the provider-native JSON body. `fromRequest` below builds
|
||||
// this shape from the common `LLMRequest`, then `Route.make` validates and
|
||||
// JSON-encodes it before transport.
|
||||
const OpenAIChatCacheControl = Schema.Struct({
|
||||
type: Schema.Literal("ephemeral"),
|
||||
ttl: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
@@ -47,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: OpenAIChatFunction,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
|
||||
@@ -61,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("image_url"),
|
||||
image_url: Schema.Struct({ url: Schema.String }),
|
||||
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
])
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
@@ -83,10 +97,16 @@ const OpenAIChatMessage = Schema.Union([
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Unknown),
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
content: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
|
||||
@@ -210,13 +230,20 @@ export interface ParserState {
|
||||
// Lowering is the only place that knows how common LLM messages map onto the
|
||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||
// fields into `LLMRequest`.
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -258,11 +285,14 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -271,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
|
||||
}
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -316,29 +350,44 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_details: details,
|
||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
})
|
||||
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: text.join("\n"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
@@ -352,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||
return (yield* lowerToolMessages(message)).messages
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const system: OpenAIChatMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
request.system.length === 0
|
||||
? []
|
||||
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: request.system.map((part) => ({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -371,28 +434,53 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...pendingImages.splice(0),
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && typeof previous.content === "string")
|
||||
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: previous.content },
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
|
||||
],
|
||||
}
|
||||
: { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
else if (previous?.role === "user" && Array.isArray(previous.content))
|
||||
messages[messages.length - 1] = {
|
||||
role: "user",
|
||||
content: [...previous.content, { type: "text", text: part.text }],
|
||||
content: [
|
||||
...previous.content,
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
}
|
||||
else messages.push({ role: "user", content: part.text })
|
||||
else
|
||||
messages.push(
|
||||
options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
|
||||
}
|
||||
: { role: "user", content: part.text },
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message)
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
messages.push(...lowered.messages)
|
||||
pendingImages.push(...lowered.images)
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
@@ -406,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
request: LLMRequest,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
@@ -419,12 +510,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
options,
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
|
||||
@@ -4,21 +4,72 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { ProviderID, 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"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
|
||||
import { isRecord } from "../protocols/shared"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
const ADAPTER = "openrouter"
|
||||
|
||||
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||
|
||||
export interface OpenRouterProviderRouting {
|
||||
readonly [key: string]: unknown
|
||||
readonly order?: ReadonlyArray<string>
|
||||
readonly allow_fallbacks?: boolean
|
||||
readonly require_parameters?: boolean
|
||||
readonly data_collection?: OpenRouterString<"allow" | "deny">
|
||||
readonly only?: ReadonlyArray<string>
|
||||
readonly ignore?: ReadonlyArray<string>
|
||||
readonly quantizations?: ReadonlyArray<string>
|
||||
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
|
||||
readonly max_price?: Readonly<{
|
||||
prompt?: number | string
|
||||
completion?: number | string
|
||||
image?: number | string
|
||||
audio?: number | string
|
||||
request?: number | string
|
||||
}>
|
||||
readonly zdr?: boolean
|
||||
}
|
||||
|
||||
export type OpenRouterPlugin =
|
||||
| Readonly<{
|
||||
id: "web"
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
|
||||
| Readonly<{ id: "moderation" }>
|
||||
| Readonly<{ id: "response-healing" }>
|
||||
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
|
||||
| Readonly<{ id: string & {}; [key: string]: unknown }>
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
readonly [key: string]: unknown
|
||||
readonly usage?: boolean | Record<string, unknown>
|
||||
readonly reasoning?: Record<string, unknown>
|
||||
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
|
||||
exclude?: boolean
|
||||
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
|
||||
max_tokens?: number
|
||||
}>
|
||||
readonly usage?: boolean | Readonly<{ include: boolean }>
|
||||
readonly user?: string
|
||||
readonly web_search_options?: Readonly<{
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
}
|
||||
|
||||
export type OpenRouterProviderOptionsInput = ProviderOptions & {
|
||||
@@ -47,7 +98,7 @@ export const protocol = Protocol.make({
|
||||
body: {
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
OpenAIChat.protocol.body.from(request).pipe(
|
||||
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
|
||||
Effect.map((body) => {
|
||||
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||
let assistantIndex = 0
|
||||
@@ -78,16 +129,39 @@ export const protocol = Protocol.make({
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
const cacheControl = () => {
|
||||
const breakpoints = newBreakpoints(4)
|
||||
return (cache: CacheHint | undefined) => {
|
||||
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||
breakpoints.remaining -= 1
|
||||
return {
|
||||
type: "ephemeral" as const,
|
||||
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyOptions = (input: unknown) => {
|
||||
const openrouter = isRecord(input) ? input : {}
|
||||
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
|
||||
openrouter
|
||||
return {
|
||||
...(openrouter.usage === true
|
||||
...options,
|
||||
...(usage === undefined || usage === true
|
||||
? { usage: { include: true } }
|
||||
: isRecord(openrouter.usage)
|
||||
? { usage: openrouter.usage }
|
||||
: {}),
|
||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
||||
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
|
||||
: usage === false
|
||||
? { usage: { include: false } }
|
||||
: isRecord(usage)
|
||||
? { usage }
|
||||
: {}),
|
||||
...(Array.isArray(models) ? { models } : {}),
|
||||
...(isRecord(provider) ? { provider } : {}),
|
||||
...(Array.isArray(plugins) ? { plugins } : {}),
|
||||
...(isRecord(web_search_options) ? { web_search_options } : {}),
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.ToolCall" }),
|
||||
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
|
||||
@@ -5,6 +5,28 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
models: ["google/gemini-3.1-pro"],
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
require_parameters: true,
|
||||
data_collection: "future-policy",
|
||||
sort: "future-sort",
|
||||
max_price: { prompt: "0.50" },
|
||||
},
|
||||
reasoning: { effort: "future-effort", exclude: false },
|
||||
plugins: [{ id: "future-plugin", enabled: true }],
|
||||
web_search_options: { engine: "future-engine" },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { CacheHint, LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
usage: { include: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
cache: { tools: true, system: true, messages: { tail: 1 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers manual assistant and tool-result cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user("Call the tool"),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "Done",
|
||||
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Call the tool" },
|
||||
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
|
||||
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("caps manual cache controls at four breakpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = prepared.body.messages[0]
|
||||
expect(system?.role).toBe("system")
|
||||
expect(
|
||||
system && Array.isArray(system.content)
|
||||
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
|
||||
: [],
|
||||
).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: { messages: "latest-assistant" },
|
||||
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Think" },
|
||||
{ role: "assistant", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows usage accounting to be disabled explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: { usage: false } },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
cache: "none",
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.usage).toEqual({ include: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -42,6 +163,13 @@ describe("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" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
@@ -53,10 +181,54 @@ describe("OpenRouter", () => {
|
||||
usage: { include: true },
|
||||
reasoning: { effort: "high" },
|
||||
prompt_cache_key: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const invalid: Record<string, unknown> = {
|
||||
usage: "yes",
|
||||
models: "anthropic/claude-sonnet-4.6",
|
||||
provider: [],
|
||||
plugins: {},
|
||||
web_search_options: [],
|
||||
debug: [],
|
||||
user: 123,
|
||||
reasoning: [],
|
||||
promptCacheKey: 123,
|
||||
future_option: { enabled: true },
|
||||
}
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: invalid },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
|
||||
expect(prepared.body).not.toHaveProperty("usage")
|
||||
expect(prepared.body).not.toHaveProperty("models")
|
||||
expect(prepared.body).not.toHaveProperty("provider")
|
||||
expect(prepared.body).not.toHaveProperty("plugins")
|
||||
expect(prepared.body).not.toHaveProperty("web_search_options")
|
||||
expect(prepared.body).not.toHaveProperty("debug")
|
||||
expect(prepared.body).not.toHaveProperty("user")
|
||||
expect(prepared.body).not.toHaveProperty("reasoning")
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export * as AISDKNative from "./aisdk-native"
|
||||
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
@@ -20,14 +23,7 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapProviderOptions("openrouter", settings),
|
||||
},
|
||||
}
|
||||
return mapOpenRouter(settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
@@ -69,6 +65,61 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return { providerOptions: { gemini: options } }
|
||||
}
|
||||
|
||||
function mapOpenRouter(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping {
|
||||
const headers =
|
||||
Provider.mergeHeaders(
|
||||
{
|
||||
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
|
||||
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
|
||||
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
|
||||
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
|
||||
: {}),
|
||||
},
|
||||
isStringRecord(settings.headers) ? settings.headers : undefined,
|
||||
) ?? {}
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapOpenRouterOptions(settings),
|
||||
},
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { openrouter: options } }
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
@@ -78,13 +129,3 @@ function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { xai: options } }
|
||||
}
|
||||
|
||||
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(values).length === 0) return {}
|
||||
return { providerOptions: { [namespace]: values } }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
||||
description: "Attachment processing configuration",
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigAttachments from "./attachments"
|
||||
export * as ConfigMedia from "./media"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -61,7 +61,7 @@ const layer = Layer.effect(
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
|
||||
@@ -96,9 +96,7 @@ const providerHeaders = (model: Info) => {
|
||||
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
@@ -202,8 +200,8 @@ export const fromCatalogModel = (
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
@@ -266,10 +264,7 @@ export const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (
|
||||
selected: Info,
|
||||
variant?: VariantID,
|
||||
) {
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
|
||||
@@ -938,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
: resolved.bytes
|
||||
const normalized = yield* normalizeImageAttachment(
|
||||
input,
|
||||
Base64.make(Buffer.from(content).toString("base64")),
|
||||
Buffer.from(content).toString("base64"),
|
||||
mime,
|
||||
image,
|
||||
)
|
||||
@@ -954,11 +954,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
|
||||
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
data: Base64,
|
||||
data: string,
|
||||
mime: string,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data, mime }
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const service = yield* image
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
|
||||
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -158,7 +158,7 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
attachments: info.attachment,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
|
||||
@@ -2,6 +2,42 @@ import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps OpenRouter settings to native destinations", () => {
|
||||
expect(
|
||||
AISDKNative.map("@openrouter/ai-sdk-provider", {
|
||||
appName: "OpenCode",
|
||||
appUrl: "https://opencode.ai",
|
||||
headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" },
|
||||
api_keys: { anthropic: "provider-key" },
|
||||
extraBody: { transforms: ["middle-out"] },
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
"x-openrouter-title": "Configured",
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"x-provider-api-keys": "Configured BYOK",
|
||||
},
|
||||
body: { transforms: ["middle-out"] },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps every Google thinking setting", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/google", {
|
||||
|
||||
@@ -803,7 +803,7 @@ describe("Config", () => {
|
||||
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
|
||||
},
|
||||
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
|
||||
attachments: {
|
||||
media: {
|
||||
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
|
||||
},
|
||||
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
||||
@@ -890,7 +890,7 @@ describe("Config", () => {
|
||||
typescript: { disabled: true },
|
||||
custom: { command: ["custom-lsp"], extensions: [".foo"] },
|
||||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({
|
||||
expect(documents[0]?.info.media).toEqual({
|
||||
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
|
||||
})
|
||||
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
||||
@@ -1097,7 +1097,7 @@ describe("Config", () => {
|
||||
expect(documents[0]?.info.references).toEqual({
|
||||
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
|
||||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.providers?.custom).toMatchObject({
|
||||
settings: { apiKey: "secret" },
|
||||
models: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, Model } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
@@ -546,6 +547,37 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
settings: {
|
||||
appName: "OpenCode",
|
||||
appUrl: "https://opencode.ai",
|
||||
extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
|
||||
},
|
||||
headers: { "X-OpenRouter-Title": "Custom" },
|
||||
body: { provider: { only: ["anthropic"] } },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"X-OpenRouter-Title": "Custom",
|
||||
})
|
||||
expect(settings.body).toEqual({
|
||||
transforms: ["middle-out"],
|
||||
provider: { sort: "price", only: ["anthropic"] },
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads supported AISDK catalog packages as native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const google = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -26,8 +26,8 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencod
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
@@ -58,7 +58,10 @@ const locations = Layer.effect(
|
||||
() =>
|
||||
// Attachment admission only needs the location-scoped Image service.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
imagePassthrough as unknown as Layer.Layer<LocationServices>,
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
|
||||
}) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -386,6 +389,35 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes large image content before validating persisted Base64", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const pixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
|
||||
const data = bytes.toString("base64")
|
||||
expect(data).toHaveLength(5_764_040)
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Inspect this image",
|
||||
files: [{ uri: `data:image/png;base64,${data}` }],
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.data.files).toEqual([
|
||||
{
|
||||
data: "AA==",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
import { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -432,8 +432,8 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -26,12 +26,27 @@ import type { JSX } from "@opentui/solid"
|
||||
import type { Store } from "solid-js/store"
|
||||
|
||||
export interface Storage {
|
||||
/**
|
||||
* Durable JSON state: persisted to disk, survives hot reloads and TUI
|
||||
* restarts, and stays live-synced across running TUI instances.
|
||||
*/
|
||||
store<Value extends object>(
|
||||
key: string,
|
||||
options: {
|
||||
readonly initial: Value
|
||||
},
|
||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
/**
|
||||
* Ephemeral in-memory state: survives plugin hot reloads (old and new
|
||||
* generations share the same live store) and is gone when the TUI exits.
|
||||
* Updates are synchronous and values need not be JSON-serializable.
|
||||
*/
|
||||
memory<Value extends object>(
|
||||
key: string,
|
||||
options: {
|
||||
readonly initial: Value
|
||||
},
|
||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
|
||||
}
|
||||
|
||||
interface LocationCollection<Value> {
|
||||
@@ -136,6 +151,9 @@ export interface SlotMap {
|
||||
readonly sessionID?: string
|
||||
readonly mode: "normal" | "shell"
|
||||
}
|
||||
readonly "session.composer.top": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "sidebar.content": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ export function DialogOpen() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(() => {})
|
||||
|
||||
// One background fetch fills in recent sessions from other projects; the menu renders
|
||||
@@ -52,8 +52,12 @@ export function DialogOpen() {
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const openTabs = createMemo(
|
||||
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
|
||||
)
|
||||
const currentSessionID = createMemo(() =>
|
||||
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
|
||||
)
|
||||
const sessions = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return [...data.session.list(), ...fetched()]
|
||||
@@ -69,7 +73,7 @@ export function DialogOpen() {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a tab by name still switches to it.
|
||||
// matching a loaded tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
@@ -78,7 +82,9 @@ export function DialogOpen() {
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
@@ -98,7 +104,7 @@ export function DialogOpen() {
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
@@ -113,6 +119,10 @@ export function DialogOpen() {
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,6 +138,8 @@ export function DialogOpen() {
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
|
||||
@@ -89,7 +89,8 @@ export function DialogSessionList() {
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
const local = localSessions()
|
||||
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
|
||||
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
|
||||
if (searchResults.loading) return searchResults.latest?.sessions ?? []
|
||||
const result = searchResults()
|
||||
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
|
||||
return result.sessions
|
||||
@@ -154,11 +155,13 @@ export function DialogSessionList() {
|
||||
footer,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
gutter:
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { logo } from "../logo"
|
||||
import { go, logo } from "../logo"
|
||||
|
||||
export function Logo() {
|
||||
const theme = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(theme.background.default, fg, 0.25)
|
||||
@@ -49,14 +51,29 @@ export function Logo() {
|
||||
|
||||
return (
|
||||
<box>
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
|
||||
<For each={go.right.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
) : dimensions().width < 44 ? (
|
||||
<>
|
||||
<For each={logo.left.slice(1)}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>}
|
||||
</For>
|
||||
<For each={logo.right}>
|
||||
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
|
||||
</For>
|
||||
</>
|
||||
) : (
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, theme.text.subdued, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text.default, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export interface PatchDiffRef {
|
||||
readonly hunks: () => readonly DiffRenderable[]
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
|
||||
diff: string
|
||||
hunkFg: ColorInput
|
||||
lineNumberBg: ColorInput
|
||||
ref?: (value: PatchDiffRef) => void
|
||||
}
|
||||
|
||||
export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const nodes = new Map<number, DiffRenderable>()
|
||||
local.ref?.({
|
||||
hunks: () =>
|
||||
[...nodes.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, node]) => node)
|
||||
.filter((node) => !node.isDestroyed),
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
const sides = [...nodes.values()]
|
||||
.filter((item) => !item.isDestroyed)
|
||||
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
|
||||
const lineNumbers = sides.map((side) => new Map([...side.getLineNumbers()].filter(([line]) => line >= 0)))
|
||||
const digits = lineNumbers.map((numbers) => Math.max(0, ...numbers.values()).toString().length)
|
||||
const after = sides.map((side) =>
|
||||
Math.max(
|
||||
0,
|
||||
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
|
||||
),
|
||||
)
|
||||
const maxDigits = Math.max(...digits)
|
||||
const maxAfter = Math.max(...after)
|
||||
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
|
||||
if (!maxDigits) return
|
||||
sides.forEach((side) => {
|
||||
const index = sides.indexOf(side)
|
||||
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
|
||||
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
|
||||
side.setLineNumbers(lineNumbers[index])
|
||||
side.setLineSigns(signs)
|
||||
})
|
||||
})
|
||||
}
|
||||
const register = (index: number, node: DiffRenderable) => {
|
||||
nodes.set(index, node)
|
||||
syncGutters()
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -1168,6 +1168,19 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function expandPastedText(extmarkId: number) {
|
||||
const extmark = input.extmarks.get(extmarkId)
|
||||
const ref = store.extmarkToPart.get(extmarkId)
|
||||
if (!extmark || ref?.type !== "pasted") return false
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return false
|
||||
|
||||
input.extmarks.delete(extmarkId)
|
||||
input.setSelection(extmark.start, extmark.end)
|
||||
input.insertText(part.text)
|
||||
return true
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
@@ -1191,6 +1204,15 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
return (
|
||||
(extmark.end === input.cursorOffset || extmark.end + 1 === input.cursorOffset) &&
|
||||
ref?.type === "pasted" &&
|
||||
store.prompt.pasted[ref.index]?.text === pastedContent
|
||||
)
|
||||
})
|
||||
if (extmark && expandPastedText(extmark.id)) return
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
@@ -1292,13 +1314,20 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
if (store.mode === "shell") {
|
||||
if (!shell().length) return undefined
|
||||
const example = shell()[store.placeholder % shell().length]
|
||||
return `Run a command... "${example}"`
|
||||
}
|
||||
if (!list().length) return undefined
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
const value = (() => {
|
||||
if (store.mode === "shell") {
|
||||
if (!shell().length) return undefined
|
||||
return `Run a command... "${shell()[store.placeholder % shell().length]}"`
|
||||
}
|
||||
if (!list().length) return undefined
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
})()
|
||||
if (!value) return undefined
|
||||
const width =
|
||||
dimensions().width < 44
|
||||
? dimensions().width - 5
|
||||
: Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
@@ -1348,8 +1377,8 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={promptBg()}
|
||||
@@ -1425,33 +1454,48 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (props.disabled) return
|
||||
if (props.disabled || r.button !== 0) return
|
||||
r.target?.focus()
|
||||
const extmark = input.extmarks
|
||||
.getAtOffset(input.cursorOffset)
|
||||
.find((item) => store.extmarkToPart.get(item.id)?.type === "pasted")
|
||||
if (!extmark || !expandPastedText(extmark.id)) return
|
||||
r.preventDefault()
|
||||
r.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Show when={agentLabel()} fallback={<box height={1} />}>
|
||||
{(label) => (
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<Show
|
||||
when={store.mode === "normal" && local.permission.mode === "auto" && dimensions().width >= 44}
|
||||
>
|
||||
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={store.mode === "normal" && dimensions().width >= 28}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<Show when={dimensions().width >= 50}>
|
||||
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
|
||||
{currentProviderLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={showVariant() && dimensions().width >= 70}>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
|
||||
import { createStore, reconcile, type Store } from "solid-js/store"
|
||||
import { createStore, produce, reconcile, type Store } from "solid-js/store"
|
||||
import path from "path"
|
||||
import { mkdirSync, readFileSync, watch } from "fs"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
@@ -13,12 +13,20 @@ type Options<Value extends object> = {
|
||||
}
|
||||
|
||||
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
|
||||
|
||||
export interface Storage {
|
||||
store<Value extends object>(
|
||||
key: string,
|
||||
options: Options<Value>,
|
||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
/**
|
||||
* Ephemeral in-process state. Entries are memoized here, above consumer
|
||||
* lifecycles, so the same live store survives plugin hot reloads; it is
|
||||
* gone when the TUI exits. Updates are synchronous and values need not be
|
||||
* JSON-serializable.
|
||||
*/
|
||||
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
|
||||
}
|
||||
|
||||
function clone<Value extends object>(value: Value) {
|
||||
@@ -37,6 +45,7 @@ function segment(value: string) {
|
||||
|
||||
function createStorage(root: string, channel: string) {
|
||||
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
||||
const memories = new Map<string, MemoryEntry<object>>()
|
||||
const directory = path.join(root, segment(channel), "tui")
|
||||
const locks = path.join(root, segment(channel), "locks")
|
||||
mkdirSync(directory, { recursive: true })
|
||||
@@ -73,6 +82,14 @@ function createStorage(root: string, channel: string) {
|
||||
entries.set(file, { value: entry as Entry<object>, reload })
|
||||
return entry
|
||||
},
|
||||
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
|
||||
const existing = memories.get(key)
|
||||
if (existing) return existing as MemoryEntry<Value>
|
||||
const [store, setStore] = createStore(options.initial)
|
||||
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
|
||||
memories.set(key, entry as MemoryEntry<object>)
|
||||
return entry
|
||||
},
|
||||
}
|
||||
|
||||
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { FadeFilePath } from "../../ui/fade-file-path"
|
||||
|
||||
function Directory(props: { context: Plugin.Context; maxWidth: number }) {
|
||||
const directory = createMemo(() =>
|
||||
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<FadeFilePath
|
||||
value={directory()}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={props.context.theme.text.subdued}
|
||||
bg={props.context.theme.background.default}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Mcp(props: { context: Plugin.Context }) {
|
||||
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
|
||||
@@ -53,34 +36,26 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
|
||||
if (list.length === 0) return 0
|
||||
const count = list.filter((item) => item.status.status === "connected").length
|
||||
return stringWidth(`⊙ ${count} MCP /status`) + 2
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory
|
||||
context={props.context}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
|
||||
/>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
|
||||
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={dimensions().height < 16 ? 0 : 1}
|
||||
paddingBottom={dimensions().height < 16 ? 0 : 1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
@@ -8,6 +9,7 @@ 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(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return props.context.data.session
|
||||
@@ -60,19 +62,24 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
<Show when={status().length > 0}>{status().join(" · ")}</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Match when={dimensions().width >= 44}>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
|
||||
</text>
|
||||
<Show when={dimensions().width >= 44}>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.mode === "shell"}>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
esc <span style={{ fg: props.context.theme.text.subdued }}>exit shell mode</span>
|
||||
esc{" "}
|
||||
<span style={{ fg: props.context.theme.text.subdued }}>
|
||||
{dimensions().width < 44 ? "shell" : "exit shell mode"}
|
||||
</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
TextAttributes,
|
||||
type BorderSides,
|
||||
type BoxRenderable,
|
||||
type DiffRenderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
@@ -19,6 +13,7 @@ import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
@@ -154,7 +149,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const helpShortcut = shortcut("diff.help")
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
|
||||
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
|
||||
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
|
||||
@@ -270,17 +265,16 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!patchScroll) return
|
||||
const hunks = visiblePatchFiles()
|
||||
.flatMap((entry) => {
|
||||
const node = diffNodeByFileIndex.get(entry.fileIndex)
|
||||
if (!node || node.isDestroyed) return []
|
||||
const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
|
||||
return node.diff
|
||||
.split("\n")
|
||||
.flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
|
||||
.map((row, hunkIndex) => ({
|
||||
fileIndex: entry.fileIndex,
|
||||
hunkIndex,
|
||||
contentY: contentY + row,
|
||||
}))
|
||||
return (
|
||||
patchDiffByFileIndex
|
||||
.get(entry.fileIndex)
|
||||
?.hunks()
|
||||
.map((node, hunkIndex) => ({
|
||||
fileIndex: entry.fileIndex,
|
||||
hunkIndex,
|
||||
contentY: patchScroll.scrollTop + node.y - patchScroll.viewport.y - (hunkIndex > 0 ? 1 : 0),
|
||||
})) ?? []
|
||||
)
|
||||
})
|
||||
.sort((left, right) => left.contentY - right.contentY)
|
||||
const selected = selectedHunk()
|
||||
@@ -831,9 +825,10 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={theme.border.default}>
|
||||
<diff
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
<PatchDiff
|
||||
ref={(component) => patchDiffByFileIndex.set(entry.fileIndex, component)}
|
||||
diff={patch()}
|
||||
hunkFg={reviewed() ? theme.text.subdued : theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={currentSyntax()}
|
||||
@@ -847,9 +842,15 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
removedBg={
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
|
||||
}
|
||||
contextBg={
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
|
||||
}
|
||||
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
|
||||
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
|
||||
lineNumberFg={theme.diff.lineNumber.text}
|
||||
lineNumberBg={
|
||||
reviewed() ? theme.background.surface.overlay : theme.diff.background.context
|
||||
}
|
||||
addedLineNumberBg={
|
||||
reviewed()
|
||||
? theme.background.surface.overlay
|
||||
|
||||
@@ -32,6 +32,7 @@ import { footerWidthPolicy } from "./footer.width"
|
||||
import { toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
import { PatchDiff } from "../component/patch-diff"
|
||||
|
||||
function buttons(
|
||||
list: PermissionOption[],
|
||||
@@ -405,8 +406,9 @@ export function RunPermissionBody(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
|
||||
import { PatchDiff } from "../component/patch-diff"
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
@@ -178,8 +179,9 @@ export function RunEntryContent(props: {
|
||||
</text>
|
||||
{item.diff.trim() ? (
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={item.diff}
|
||||
hunkFg={theme().block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiApp, useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
|
||||
// The provider's registration store, narrowed to what a plugin context needs:
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, slot: Slot): void
|
||||
remove(kind: "routes" | "slots", name: string): void
|
||||
active(): boolean
|
||||
}
|
||||
|
||||
// The host services a plugin context adapts. Collected once by the provider
|
||||
// (hooks must run during component setup) and shared by every activation.
|
||||
export function usePluginHost() {
|
||||
return {
|
||||
renderer: useRenderer(),
|
||||
client: useClient(),
|
||||
data: useData(),
|
||||
route: useRoute(),
|
||||
keymap: Keymap.use(),
|
||||
shortcuts: Keymap.useShortcuts(),
|
||||
keymapState: Keymap.useState(),
|
||||
app: useTuiApp(),
|
||||
paths: useTuiPaths(),
|
||||
location: useLocation(),
|
||||
themes: useThemes(),
|
||||
dialog: useDialog(),
|
||||
toast: useToast(),
|
||||
attention: useAttention(),
|
||||
storage: useStorage(),
|
||||
sessionTabs: useSessionTabs(),
|
||||
}
|
||||
}
|
||||
|
||||
// Build the API surface handed to one plugin activation: host services
|
||||
// adapted to the plugin contract, with everything registered through it
|
||||
// unwinding via `owned` when the activation is disposed.
|
||||
export function createPluginContext(input: {
|
||||
host: ReturnType<typeof usePluginHost>
|
||||
id: string
|
||||
options: Readonly<Record<string, any>> | undefined
|
||||
owned: Dispose[]
|
||||
registry: Registry
|
||||
}): Context {
|
||||
const host = input.host
|
||||
let context: Context
|
||||
// Every dialog and registered render is wrapped so plugin components can
|
||||
// reach their own context through usePlugin().
|
||||
const provide = (render: () => JSX.Element) => (
|
||||
<PluginContextProvider value={context}>{render()}</PluginContextProvider>
|
||||
)
|
||||
const dialogApi = createDialogApi(host.dialog, provide)
|
||||
const toastApi: Toast = {
|
||||
show(options) {
|
||||
host.toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
// Unregistering after deactivation is a no-op: deactivate already resets
|
||||
// the registration's routes and slots wholesale.
|
||||
const registration = (kind: "routes" | "slots", name: string) => {
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
if (!input.registry.active()) return
|
||||
input.registry.remove(kind, name)
|
||||
}
|
||||
input.owned.push(async () => unregister())
|
||||
return unregister
|
||||
}
|
||||
context = {
|
||||
options: input.options ?? {},
|
||||
get location() {
|
||||
return host.location.current
|
||||
},
|
||||
app: { version: host.app.version, channel: host.app.channel },
|
||||
renderer: host.renderer,
|
||||
client: host.client.api,
|
||||
data: host.data,
|
||||
attention: host.attention,
|
||||
get theme() {
|
||||
return host.themes.currentTokens()
|
||||
},
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: host.keymap.dispatch,
|
||||
shortcuts: host.shortcuts.list,
|
||||
commands: host.keymapState.commands,
|
||||
pending: host.keymapState.pending,
|
||||
active: host.keymapState.active,
|
||||
mode: host.keymap.mode,
|
||||
},
|
||||
storage: {
|
||||
store: (key, options) => host.storage.store(`plugin.${input.id}.${key}`, options),
|
||||
memory: (key, options) => host.storage.memory(`plugin.${input.id}.${key}`, options),
|
||||
},
|
||||
ui: {
|
||||
dialog: dialogApi,
|
||||
toast: toastApi,
|
||||
format: {
|
||||
path: (value) => abbreviateHome(value, host.paths.home),
|
||||
},
|
||||
router: {
|
||||
register(page) {
|
||||
if (input.registry.has("routes", page.name)) throw new Error(`Route already registered: ${page.name}`)
|
||||
input.registry.set("routes", page.name, {
|
||||
...page,
|
||||
render: (data) => provide(() => page.render(data)),
|
||||
})
|
||||
return registration("routes", page.name)
|
||||
},
|
||||
navigate(destination) {
|
||||
if (destination.type === "plugin") {
|
||||
host.route.navigate({ ...destination, id: "id" in destination ? destination.id : input.id })
|
||||
return
|
||||
}
|
||||
host.route.navigate(destination)
|
||||
},
|
||||
current() {
|
||||
return host.route.data
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
enabled: host.sessionTabs.enabled,
|
||||
list: () =>
|
||||
host.sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: host.sessionTabs.current() === tab.sessionID,
|
||||
...host.sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
open(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
host.sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
focus(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
if (!host.sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
|
||||
host.sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
close(sessionID) {
|
||||
if (!host.sessionTabs.enabled()) return false
|
||||
const target = sessionID ?? host.sessionTabs.current()
|
||||
if (!target || !host.sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
|
||||
host.sessionTabs.close(target)
|
||||
return true
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
|
||||
// The registration map erases the slot-specific input type.
|
||||
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
|
||||
provide(() => render(slotInput))) as Slot)
|
||||
return registration("slots", name)
|
||||
},
|
||||
},
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// A dialog promise must settle exactly once even when confirm and close
|
||||
// callbacks both fire.
|
||||
function settle<T>(resolve: (value: T) => void) {
|
||||
let settled = false
|
||||
return (value: T) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
|
||||
const api: Dialog = {
|
||||
show(render, onClose) {
|
||||
dialog.replace(() => provide(render), onClose)
|
||||
},
|
||||
set(options) {
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
alert(options) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = settle(resolve)
|
||||
api.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
|
||||
})
|
||||
},
|
||||
confirm(options) {
|
||||
return new Promise<boolean | undefined>((resolve) => {
|
||||
const done = settle(resolve)
|
||||
api.show(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={options.title}
|
||||
message={options.message}
|
||||
label={options.label}
|
||||
onConfirm={() => done(true)}
|
||||
onCancel={() => done(false)}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
prompt(options) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
const done = settle(resolve)
|
||||
api.show(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={options.title}
|
||||
description={options.description ? () => <text>{options.description}</text> : undefined}
|
||||
placeholder={options.placeholder}
|
||||
value={options.value}
|
||||
onConfirm={(value) => {
|
||||
done(value)
|
||||
api.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
select(options) {
|
||||
return new Promise((resolve) => {
|
||||
const done = settle<(typeof options.options)[number]["value"] | undefined>(resolve)
|
||||
api.show(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={options.title}
|
||||
placeholder={options.placeholder}
|
||||
options={options.options.map((option) => ({ ...option }))}
|
||||
current={options.current}
|
||||
onSelect={(option) => {
|
||||
done(option.value)
|
||||
api.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
return api
|
||||
}
|
||||
+277
-373
@@ -1,51 +1,25 @@
|
||||
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { discoverTuiPlugins } from "./discovery"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly target: string; readonly status: "loading" }
|
||||
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
@@ -61,15 +35,16 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type Dispose = () => Promise<void>
|
||||
type Registration = {
|
||||
plugin: Plugin.Definition
|
||||
source: RegisteredPlugin["source"]
|
||||
target?: string
|
||||
version: string
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
@@ -77,27 +52,15 @@ type Registration = {
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
|
||||
// One entry of the desired plugin generation produced by the resolve phase.
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
const renderer = useRenderer()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const keymapState = Keymap.useState()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const app = useTuiApp()
|
||||
const paths = useTuiPaths()
|
||||
const location = useLocation()
|
||||
const themes = useThemes()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
const storage = useStorage()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -115,223 +78,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
let context: Context
|
||||
const dialogApi: Dialog = {
|
||||
show(render, onClose) {
|
||||
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
||||
const context = createPluginContext({
|
||||
host,
|
||||
id,
|
||||
options: item.options,
|
||||
owned,
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
|
||||
setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
"registrations",
|
||||
produce((registrations) => {
|
||||
if (!registrations[id]) return
|
||||
delete registrations[id][kind][name]
|
||||
}),
|
||||
),
|
||||
active: () => Boolean(store.registrations[id]?.active),
|
||||
},
|
||||
set(options) {
|
||||
dialog.setSize(options.size ?? "medium")
|
||||
dialog.setCentered(options.centered ?? false)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
alert(options) {
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const done = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
|
||||
})
|
||||
},
|
||||
confirm(options) {
|
||||
return new Promise<boolean | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: boolean | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={options.title}
|
||||
message={options.message}
|
||||
label={options.label}
|
||||
onConfirm={() => done(true)}
|
||||
onCancel={() => done(false)}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
prompt(options) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: string | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={options.title}
|
||||
description={options.description ? () => <text>{options.description}</text> : undefined}
|
||||
placeholder={options.placeholder}
|
||||
value={options.value}
|
||||
onConfirm={(value) => {
|
||||
done(value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
select(options) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
dialogApi.show(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={options.title}
|
||||
placeholder={options.placeholder}
|
||||
options={options.options.map((option) => ({ ...option }))}
|
||||
current={options.current}
|
||||
onSelect={(option) => {
|
||||
done(option.value)
|
||||
dialogApi.clear()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => done(undefined),
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
const toastApi: Toast = {
|
||||
show(options) {
|
||||
toast.show({ ...options, variant: options.variant ?? "info" })
|
||||
},
|
||||
}
|
||||
context = {
|
||||
options: item.options ?? {},
|
||||
get location() {
|
||||
return location.current
|
||||
},
|
||||
app: { version: app.version, channel: app.channel },
|
||||
renderer,
|
||||
client: client.api,
|
||||
data,
|
||||
attention,
|
||||
get theme() {
|
||||
return themes.currentTokens()
|
||||
},
|
||||
keymap: {
|
||||
layer: Keymap.createLayer,
|
||||
dispatch: keymap.dispatch,
|
||||
shortcuts: shortcuts.list,
|
||||
commands: keymapState.commands,
|
||||
pending: keymapState.pending,
|
||||
active: keymapState.active,
|
||||
mode: keymap.mode,
|
||||
},
|
||||
storage: {
|
||||
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
|
||||
},
|
||||
ui: {
|
||||
dialog: dialogApi,
|
||||
toast: toastApi,
|
||||
format: {
|
||||
path: (value) => abbreviateHome(value, paths.home),
|
||||
},
|
||||
router: {
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
throw new Error(`Route already registered: ${page.name}`)
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, {
|
||||
...page,
|
||||
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
|
||||
})
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
if (!store.registrations[item.plugin.id]?.active) return
|
||||
setStore(
|
||||
"registrations",
|
||||
produce((registrations) => {
|
||||
if (!registrations[item.plugin.id]) return
|
||||
delete registrations[item.plugin.id].routes[page.name]
|
||||
}),
|
||||
)
|
||||
}
|
||||
owned.push(async () => unregister())
|
||||
return unregister
|
||||
},
|
||||
navigate(destination) {
|
||||
if (destination.type === "plugin") {
|
||||
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
|
||||
return
|
||||
}
|
||||
route.navigate(destination)
|
||||
},
|
||||
current() {
|
||||
return route.data
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
enabled: sessionTabs.enabled,
|
||||
list: () =>
|
||||
sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: sessionTabs.current() === tab.sessionID,
|
||||
...sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
open(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
focus(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
|
||||
sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
close(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
const target = sessionID ?? sessionTabs.current()
|
||||
if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
|
||||
sessionTabs.close(target)
|
||||
return true
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
|
||||
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
|
||||
))
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
if (!store.registrations[item.plugin.id]?.active) return
|
||||
setStore(
|
||||
"registrations",
|
||||
produce((registrations) => {
|
||||
if (!registrations[item.plugin.id]) return
|
||||
delete registrations[item.plugin.id].slots[name]
|
||||
}),
|
||||
)
|
||||
}
|
||||
owned.push(async () => unregister())
|
||||
return unregister
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
@@ -374,94 +140,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
return true
|
||||
}
|
||||
|
||||
const reconcile = async (configured = config.data.plugins ?? []) => {
|
||||
await Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
// Cleanup failures must not stop a swap or teardown, but they should not
|
||||
// vanish either: the old generation may still own listeners or intervals.
|
||||
const deactivateNoisily = (id: string) =>
|
||||
deactivate(id).catch((error) =>
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${id}: cleanup failed: ${errorMessage(error)}` }),
|
||||
)
|
||||
const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
|
||||
batch(() => {
|
||||
setStore("registrations", reconcileStore({}))
|
||||
setStore("states", [])
|
||||
})
|
||||
|
||||
for (const plugin of builtins) {
|
||||
setStore("registrations", plugin.id, {
|
||||
plugin,
|
||||
source: "builtin",
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
})
|
||||
await activate(plugin.id)
|
||||
}
|
||||
// Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
|
||||
// is serialized through one chain so generations can never interleave.
|
||||
let loading = Promise.resolve()
|
||||
const enqueue = <T,>(task: () => Promise<T>) => {
|
||||
const result = loading.catch(() => undefined).then(task)
|
||||
loading = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
// Hot-reload local plugin sources: watch the discovery directory and any
|
||||
// local entrypoints (see watch.ts for the mechanics), debounced into a
|
||||
// serialized reconcile so bursts of events rebuild the generation once.
|
||||
let pending: ReturnType<typeof setTimeout> | undefined
|
||||
const watcher = createSourceWatcher(() => {
|
||||
clearTimeout(pending)
|
||||
pending = setTimeout(() => {
|
||||
// Observe failures immediately: a plugin cleanup that throws would
|
||||
// otherwise surface as an unhandled rejection until the next trigger.
|
||||
void enqueue(reconcile).catch(() => undefined)
|
||||
}, 100)
|
||||
})
|
||||
onCleanup(() => {
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
})
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
// core plugin registry: fold the ordered entries into a desired end state
|
||||
// (importing only new or changed sources, before anything running is
|
||||
// touched), no-op when the generation is unchanged, and restart only the
|
||||
// plugins that differ. Membership or order changes rebuild the whole
|
||||
// generation to preserve slot-order semantics.
|
||||
// Package resolution failures would otherwise retry a full npm install on
|
||||
// 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))
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
const desired = new Map<string, Desired>()
|
||||
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
for (const entry of entries) {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
|
||||
await deactivate(id)
|
||||
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
||||
continue
|
||||
}
|
||||
|
||||
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
|
||||
const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
|
||||
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
|
||||
for (const id of selected) await activate(id)
|
||||
for (const item of selected) item.enabled = true
|
||||
continue
|
||||
}
|
||||
|
||||
const options = typeof entry === "string" ? undefined : entry.options
|
||||
setStore("states", (items) => [...items, { target, status: "loading" }])
|
||||
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
state.target === target
|
||||
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
: state,
|
||||
),
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
if (!plugin) {
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
|
||||
),
|
||||
)
|
||||
// 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))
|
||||
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
if (resolved.status === "unsupported") {
|
||||
failures.push({ target, status: "unsupported" })
|
||||
continue
|
||||
}
|
||||
|
||||
setStore("registrations", plugin.id, {
|
||||
plugin,
|
||||
if (resolved.status === "failed") {
|
||||
if (!local && !previous) npmFailures.set(target, resolved.error)
|
||||
failures.push({
|
||||
target,
|
||||
status: "failed",
|
||||
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
|
||||
})
|
||||
if (previous)
|
||||
desired.set(previous.plugin.id, {
|
||||
plugin: previous.plugin,
|
||||
source: previous.source,
|
||||
target,
|
||||
version: previous.version,
|
||||
options: previous.options,
|
||||
enabled: previous.active,
|
||||
})
|
||||
continue
|
||||
}
|
||||
desired.set(resolved.plugin.id, {
|
||||
plugin: resolved.plugin,
|
||||
source: "external",
|
||||
target,
|
||||
version: resolved.version,
|
||||
options,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
enabled: true,
|
||||
})
|
||||
const error = await activate(plugin.id).then(
|
||||
() => undefined,
|
||||
(error) => (error instanceof Error ? error.message : String(error)),
|
||||
)
|
||||
setStore("states", (items) => [
|
||||
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
|
||||
error
|
||||
? { target, status: "failed", error }
|
||||
: { target, id: plugin.id, status: "active" },
|
||||
])
|
||||
}
|
||||
|
||||
// Compare: unchanged plugins are never touched, and a fully unchanged
|
||||
// generation is a no-op, so spurious watch events cost nothing.
|
||||
const currentIds = Object.keys(store.registrations)
|
||||
const desiredIds = [...desired.keys()]
|
||||
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
|
||||
if (structural) {
|
||||
await Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivateNoisily(id)),
|
||||
)
|
||||
setStore("registrations", reconcileStore({}))
|
||||
}
|
||||
const changed = structural
|
||||
? desiredIds
|
||||
: desiredIds.filter((id) => {
|
||||
const registration = store.registrations[id]!
|
||||
const item = desired.get(id)!
|
||||
// enabled derives from config directives alone, so config wins over
|
||||
// manual dialog toggles on every reconcile — the same semantics
|
||||
// config saves had before hot reload existed, just more frequent.
|
||||
return (
|
||||
registration.version !== item.version ||
|
||||
!sameOptions(registration.options, item.options) ||
|
||||
registration.active !== item.enabled
|
||||
)
|
||||
})
|
||||
|
||||
// Swap: cleanup failures surface as a toast, never propagate, so one
|
||||
// broken plugin cannot take the rest of the generation down.
|
||||
const errors = new Map<string, string>()
|
||||
for (const id of changed) {
|
||||
const item = desired.get(id)!
|
||||
const registration = store.registrations[id]
|
||||
const replaced =
|
||||
!registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
|
||||
// Snapshot the running version before it is overwritten: an import
|
||||
// failure keeps last-good in the resolve phase, and a setup failure
|
||||
// must not cost the previous version either.
|
||||
const fallback: Desired | undefined =
|
||||
replaced && registration
|
||||
? {
|
||||
plugin: registration.plugin,
|
||||
source: registration.source,
|
||||
target: registration.target,
|
||||
version: registration.version,
|
||||
options: registration.options,
|
||||
enabled: registration.active,
|
||||
}
|
||||
: undefined
|
||||
if (replaced) {
|
||||
if (registration) await deactivateNoisily(id)
|
||||
// In-place replacement keeps the registration's key position, which
|
||||
// slot ordering (mode "replace" takes the last one) depends on.
|
||||
setStore("registrations", id, toRegistration(item))
|
||||
}
|
||||
if (!item.enabled) {
|
||||
await deactivateNoisily(id)
|
||||
continue
|
||||
}
|
||||
const error = await activate(id).then(() => undefined, errorMessage)
|
||||
if (!error) continue
|
||||
errors.set(id, error)
|
||||
if (!fallback) continue
|
||||
setStore("registrations", id, toRegistration(fallback))
|
||||
if (!fallback.enabled) continue
|
||||
const restored = await activate(id).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (restored) errors.set(id, `${error} (previous version still active)`)
|
||||
}
|
||||
|
||||
const failedTargets = new Set(failures.map((failure) => failure.target))
|
||||
const states: State[] = [
|
||||
...[...desired.values()].flatMap((item): State[] => {
|
||||
if (item.target === undefined) return []
|
||||
// A failed reload keeps this item running; the failure entry covers it.
|
||||
if (failedTargets.has(item.target)) return []
|
||||
const error = errors.get(item.plugin.id)
|
||||
if (error) return [{ target: item.target, status: "failed", error }]
|
||||
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
|
||||
return [{ target: item.target, id: item.plugin.id, status }]
|
||||
}),
|
||||
...failures,
|
||||
]
|
||||
// Surface newly failing plugins; repeated reconciles stay silent.
|
||||
for (const state of states)
|
||||
if (
|
||||
state.status === "failed" &&
|
||||
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
|
||||
)
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
||||
setStore("states", reconcileStore(states))
|
||||
}
|
||||
let loading = Promise.resolve()
|
||||
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
() => {
|
||||
const configured = config.data.plugins ?? []
|
||||
loading = loading.catch(() => undefined).then(() => reconcile(configured))
|
||||
void loading.then(
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
() => setStore("ready", true),
|
||||
() => setStore("ready", true),
|
||||
)
|
||||
@@ -478,7 +369,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
.map(([id]) => deactivate(id).catch(() => undefined)),
|
||||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
@@ -500,11 +391,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
|
||||
),
|
||||
activate,
|
||||
deactivate,
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) => {
|
||||
const render = registration.active ? registration.slots[name] : undefined
|
||||
if (!render) return []
|
||||
// <For> diffs rows by reference; a stable wrapper per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(render)
|
||||
if (cached) return [cached]
|
||||
const item = { id, render }
|
||||
slotItems.set(render, item)
|
||||
return [item]
|
||||
}),
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
// toggle mid-reload cannot mix registrations across generations.
|
||||
activate: (id) => enqueue(() => activate(id)),
|
||||
deactivate: (id) => enqueue(() => deactivate(id)),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
@@ -531,17 +433,45 @@ function matches(selector: string, id: string) {
|
||||
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
||||
}
|
||||
|
||||
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
|
||||
const local = spec.startsWith("file://")
|
||||
? new URL(spec)
|
||||
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
|
||||
? pathToFileURL(path.resolve(directory, spec))
|
||||
: undefined
|
||||
async function resolvePlugin(
|
||||
spec: string,
|
||||
local: URL | undefined,
|
||||
options: Readonly<Record<string, any>> | undefined,
|
||||
previous: Registration | undefined,
|
||||
packages: PackageResolver,
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
if (!local && previous && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||
if (!entrypoint) return
|
||||
const mod: { readonly default?: unknown } = await import(entrypoint)
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// The cache-busted specifier doubles as the version: unique per entrypoint
|
||||
// and mtime, so equal versions mean an identical module.
|
||||
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod: { readonly default?: unknown } = await import(version)
|
||||
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||
return mod.default
|
||||
return { status: "loaded" as const, plugin: mod.default, version }
|
||||
}
|
||||
|
||||
function toRegistration(item: Desired): Registration {
|
||||
return {
|
||||
plugin: item.plugin,
|
||||
source: item.source,
|
||||
target: item.target,
|
||||
version: item.version,
|
||||
options: item.options,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
}
|
||||
}
|
||||
|
||||
function sameOptions(a: Registration["options"], b: Registration["options"]) {
|
||||
return isDeepEqual(a ?? null, b ?? null)
|
||||
}
|
||||
|
||||
async function resolveLocal(url: URL) {
|
||||
@@ -576,29 +506,3 @@ export function usePlugin() {
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
|
||||
const plugins = usePlugin()
|
||||
const route = useRoute()
|
||||
const content = createMemo(() => {
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = plugins.route(route.data.id, route.data.name)
|
||||
if (!render) return props.fallback(route.data.id, route.data.name)
|
||||
return render({ data: route.data.data })
|
||||
})
|
||||
return <>{content()}</>
|
||||
}
|
||||
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
const plugins = usePlugin()
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
return <For each={renderers()}>{(render) => render(props.input)}</For>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
|
||||
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 discoverTuiPlugins(cwd: string) {
|
||||
const directory = path.join(cwd, ".opencode", "plugins", "tui")
|
||||
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)
|
||||
@@ -14,3 +19,19 @@ export async function discoverTuiPlugins(cwd: string) {
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
if (spec.startsWith("file://")) return new URL(spec)
|
||||
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
|
||||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Key local plugin imports by mtime so edited sources re-import fresh instead
|
||||
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
|
||||
// imports, so bust with a plain path there; Node keys its cache on the full
|
||||
// URL. Mirrors the core plugin supervisor's loader.
|
||||
export function freshSpecifier(entrypoint: string, mtime: number) {
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
|
||||
return `${entrypoint}?mtime=${mtime}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createComponent, createMemo, ErrorBoundary, For, mergeProps, onMount, Show, type JSX, type ParentProps } from "solid-js"
|
||||
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { usePlugin } from "./context"
|
||||
|
||||
// Contain render-time plugin crashes: a throwing slot or route must not take
|
||||
// down the app or the other plugins. The crash surfaces as one error toast.
|
||||
function PluginBoundary(props: ParentProps<{ id: string; where: string }>) {
|
||||
const toast = useToast()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
// One toast per crash: onMount is untracked, so prop updates while
|
||||
// the boundary is latched cannot re-toast.
|
||||
onMount(() =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Plugin",
|
||||
message: `${props.id} crashed in ${props.where}: ${errorMessage(error)}`,
|
||||
}),
|
||||
)
|
||||
return null
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
|
||||
const plugins = usePlugin()
|
||||
const route = useRoute()
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type !== "plugin") return
|
||||
return {
|
||||
id: route.data.id,
|
||||
name: route.data.name,
|
||||
render: plugins.route(route.data.id, route.data.name),
|
||||
data: route.data.data,
|
||||
}
|
||||
})
|
||||
return (
|
||||
// Keyed so navigation or a hot reload recreates the boundary; otherwise
|
||||
// one crash would latch every future plugin route into the fallback.
|
||||
<Show keyed when={current()}>
|
||||
{(item) => (
|
||||
<PluginBoundary id={item.id} where="route">
|
||||
{item.render ? createComponent(item.render, { data: item.data }) : props.fallback(item.id, item.name)}
|
||||
</PluginBoundary>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
const plugins = usePlugin()
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
return (
|
||||
<For each={renderers()}>
|
||||
{(item) => (
|
||||
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
|
||||
{
|
||||
// Component semantics: the render body runs once and untracked, so
|
||||
// signals and intervals created inside are stable, while props stay
|
||||
// reactive through the merged getter. A bare item.render(props.input)
|
||||
// call would run inside the host's tracked scope and re-execute the
|
||||
// whole body (resetting plugin state) on every tracked read.
|
||||
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
|
||||
}
|
||||
</PluginBoundary>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import path from "path"
|
||||
import { existsSync, watch } from "fs"
|
||||
import { lstat, realpath, stat } from "fs/promises"
|
||||
|
||||
// Watch plugin sources for changes. Files are watched through their parent
|
||||
// directory (editors that save by rename replace the inode, which silently
|
||||
// kills a direct file watch) and filtered by basename so bursts in busy
|
||||
// directories stay quiet. Symlinked files are additionally watched at their
|
||||
// resolved target, since edits there emit nothing at the link's location.
|
||||
// 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.
|
||||
export function createSourceWatcher(onChange: () => void) {
|
||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||
const watched = new Map<string, Set<string> | null>()
|
||||
let disposed = false
|
||||
const forget = (dir: string) => {
|
||||
watchers.get(dir)?.close()
|
||||
watchers.delete(dir)
|
||||
watched.delete(dir)
|
||||
}
|
||||
const arm = (target: string) => {
|
||||
stat(target)
|
||||
.then((info) => {
|
||||
if (disposed) return
|
||||
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)
|
||||
const existing = watched.get(dir)
|
||||
if (existing !== undefined) {
|
||||
if (name === null) watched.set(dir, null)
|
||||
else existing?.add(name)
|
||||
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()
|
||||
return
|
||||
}
|
||||
// A null filename (platform-dependent) always schedules.
|
||||
const accept = watched.get(dir)
|
||||
if (filename && accept && !accept.has(filename.toString())) return
|
||||
onChange()
|
||||
})
|
||||
// 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)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const add = (target: string) => {
|
||||
arm(target)
|
||||
// A symlinked source receives edits at its resolved target.
|
||||
lstat(target)
|
||||
.then((info) => {
|
||||
if (!info.isSymbolicLink()) return
|
||||
return realpath(target).then(arm)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
for (const watcher of watchers.values()) watcher.close()
|
||||
}
|
||||
return { add, dispose }
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import { useEditorContext } from "../context/editor"
|
||||
import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { PluginSlot } from "../plugin/context"
|
||||
import { PluginSlot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -26,6 +27,7 @@ export function Home() {
|
||||
const editor = useEditorContext()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const dimensions = useTerminalDimensions()
|
||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||
const currentLocation = () => route.location ?? data.location.default()
|
||||
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
|
||||
@@ -71,7 +73,12 @@ export function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useData } from "../../context/data"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
@@ -79,6 +80,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
@@ -958,7 +960,14 @@ export function Session() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
paddingBottom={1}
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
gap={1}
|
||||
>
|
||||
<Show when={session()}>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
@@ -1003,6 +1012,7 @@ export function Session() {
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
@@ -1540,6 +1550,7 @@ function SessionGroupView(props: {
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
@@ -1573,8 +1584,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
<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()}>
|
||||
<Show when={dimensions().width >= 28}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
||||
</Show>
|
||||
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
@@ -3028,8 +3041,9 @@ function Edit(props: ToolProps) {
|
||||
{(item) => (
|
||||
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={item().patch}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={filetype(path())}
|
||||
syntaxStyle={syntax()}
|
||||
@@ -3117,8 +3131,9 @@ function ApplyPatch(props: ToolProps) {
|
||||
}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={file.patch}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={filetype(file.relativePath)}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { SimulationSemantics } from "../../simulation/semantics"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -50,8 +51,9 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<diff
|
||||
<PatchDiff
|
||||
diff={diff()}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
@@ -245,7 +245,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection && props.current === undefined) {
|
||||
if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) {
|
||||
const count = flat().length
|
||||
if (count === 0) return
|
||||
const next = reconcileSelection(store.selected, count)
|
||||
@@ -281,7 +281,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("selected", index)
|
||||
selection = option
|
||||
if (!moved) return
|
||||
if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return
|
||||
if (
|
||||
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
|
||||
store.filter.length > 0
|
||||
)
|
||||
return
|
||||
scrollAfterLayout(false, option.value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface PatchHunk {
|
||||
readonly patch: string
|
||||
readonly header?: string
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [
|
||||
...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm),
|
||||
].map((match) => match.index)
|
||||
if (starts.length <= 1) return [{ patch }]
|
||||
|
||||
const prefix = patch.slice(0, starts[0])
|
||||
return starts.map((start, index) => {
|
||||
const end = starts[index + 1] ?? patch.length
|
||||
const lineEnd = patch.indexOf("\n", start)
|
||||
return {
|
||||
header: patch.slice(start, lineEnd === -1 ? end : lineEnd),
|
||||
patch: prefix + patch.slice(start, end),
|
||||
rows: splitRows(patch.slice(start, end)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function splitRows(hunk: string) {
|
||||
const lines = hunk.replace(/\n$/, "").split("\n").slice(1)
|
||||
let rows = 0
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const prefix = lines[index][0]
|
||||
if (prefix === " " || !prefix) {
|
||||
rows++
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (prefix === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
while (
|
||||
index < lines.length &&
|
||||
(lines[index][0] === "+" || lines[index][0] === "-")
|
||||
) {
|
||||
if (lines[index][0] === "+") additions++
|
||||
if (lines[index][0] === "-") deletions++
|
||||
index++
|
||||
}
|
||||
rows += Math.max(additions, deletions)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -106,11 +106,18 @@ test("does not preload session summaries into the data context", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("proactively syncs project metadata", async () => {
|
||||
test("proactively syncs project metadata newest first", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/project") return
|
||||
return json([
|
||||
{
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
@@ -149,6 +156,13 @@ test("proactively syncs project metadata", async () => {
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -18,16 +18,14 @@ import { StorageProvider } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("selecting an unhydrated session preserves its location", async () => {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
|
||||
const events = createEventStream()
|
||||
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/session") return
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
@@ -42,7 +40,129 @@ test("selecting an unhydrated session preserves its location", async () => {
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Remote session"))
|
||||
expect(fixture.data.session.get("ses_remote")).toBeUndefined()
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
||||
expect(fixture.location.ref).toEqual(remote)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows the current project and opens its root", async () => {
|
||||
const root = "/tmp/opencode/project"
|
||||
const subfolder = `${root}/packages/tui`
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({
|
||||
directory: subfolder,
|
||||
project: { id: "proj_current", directory: root, canonical: root },
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: subfolder })
|
||||
location.set({ directory: subfolder })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode") && value.includes("●"))
|
||||
expect(frame).toContain(root)
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
|
||||
expect(fixture.location.ref).toEqual({ directory: root })
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves a moved project when sessions arrive", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_first",
|
||||
canonical: "/tmp/opencode/first",
|
||||
name: "First project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_second",
|
||||
canonical: "/tmp/opencode/second",
|
||||
name: "Second project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_recent",
|
||||
projectID: "proj_first",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 2, updated: 3 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/tmp/opencode/first" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
data: ReturnType<typeof useData>
|
||||
location: ReturnType<typeof useLocation>
|
||||
}) => void | Promise<void>,
|
||||
) {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(handler, events)
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
@@ -52,7 +172,9 @@ test("selecting an unhydrated session preserves its location", async () => {
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
onMount(() => dialog.replace(() => <DialogOpen />))
|
||||
onMount(
|
||||
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -90,17 +212,20 @@ test("selecting an unhydrated session preserves its location", async () => {
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("Remote session"))
|
||||
expect(data.session.get("ses_remote")).toBeUndefined()
|
||||
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitFor(() => route.data.type === "session")
|
||||
|
||||
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
||||
expect(location.ref).toEqual(remote)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
return {
|
||||
app,
|
||||
get route() {
|
||||
return route
|
||||
},
|
||||
get location() {
|
||||
return location
|
||||
},
|
||||
get data() {
|
||||
return data
|
||||
},
|
||||
dispose() {
|
||||
app.renderer.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,7 +82,12 @@ async function renderSelect(
|
||||
return app
|
||||
}
|
||||
|
||||
async function mountSelect(root: string, initial: DialogSelectOption<string>[], current?: string) {
|
||||
async function mountSelect(
|
||||
root: string,
|
||||
initial: DialogSelectOption<string>[],
|
||||
current?: string,
|
||||
focusCurrent?: boolean,
|
||||
) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const config = createTuiResolvedConfig()
|
||||
@@ -118,6 +123,7 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
|
||||
title="Mutable options"
|
||||
options={options()}
|
||||
current={current}
|
||||
focusCurrent={focusCurrent}
|
||||
onMove={(option) => moved.push(option.value)}
|
||||
onSelect={(option) => selected.push(option.value)}
|
||||
/>
|
||||
@@ -352,3 +358,24 @@ test("keeps the current option selected when options reorder", async () => {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the first row selected when current is only a marker", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = { title: "project", value: "project" }
|
||||
const select = await mountSelect(tmp.path, [project], "current", false)
|
||||
|
||||
try {
|
||||
select.replaceOptions([
|
||||
{ title: "recent session", value: "recent" },
|
||||
project,
|
||||
{ title: "current session", value: "current" },
|
||||
])
|
||||
await select.app.waitForFrame((frame) => frame.includes("recent session"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["recent"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,6 +103,8 @@ test("brackets navigate diff hunks", async () => {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@")
|
||||
expect(countDiffs(viewer.app.renderer.root)).toBe(3)
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
const initial = scroll.scrollTop
|
||||
|
||||
@@ -256,6 +258,12 @@ function containsDiff(root: Renderable): boolean {
|
||||
return root.getChildren().some(containsDiff)
|
||||
}
|
||||
|
||||
function countDiffs(root: Renderable): number {
|
||||
return (
|
||||
(root instanceof DiffRenderable ? 1 : 0) + root.getChildren().reduce((total, child) => total + countDiffs(child), 0)
|
||||
)
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "session-1",
|
||||
projectID: "project-1",
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
function lifecycleSource(marker: string, id: string, version: string) {
|
||||
return `
|
||||
import { appendFile } from "node:fs/promises"
|
||||
export default {
|
||||
id: ${JSON.stringify(id)},
|
||||
setup: async () => {
|
||||
await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n")
|
||||
return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n")
|
||||
},
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
|
||||
let value: string | undefined
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
value = await read().catch(() => undefined)
|
||||
if (expected(value)) return value
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function bootApp(directory: string) {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const cwd = process.cwd()
|
||||
process.chdir(directory)
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
return {
|
||||
task,
|
||||
async [Symbol.asyncDispose]() {
|
||||
process.chdir(cwd)
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const source = path.join(directory, "hot.ts")
|
||||
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const read = () => readFile(marker, "utf8")
|
||||
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
|
||||
|
||||
await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
|
||||
expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("a plugin whose slot render throws does not take down the TUI", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const markerA = path.join(tmp.path, "a.txt")
|
||||
const markerCrash = path.join(tmp.path, "crash.txt")
|
||||
const sourceA = path.join(directory, "a.ts")
|
||||
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
|
||||
await writeFile(
|
||||
path.join(directory, "crash.ts"),
|
||||
`
|
||||
import { appendFile } from "node:fs/promises"
|
||||
export default {
|
||||
id: "test.crash",
|
||||
setup: async (context: any) => {
|
||||
context.ui.slot("home.footer", () => {
|
||||
throw new Error("boom")
|
||||
})
|
||||
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const readA = () => readFile(markerA, "utf8")
|
||||
expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
|
||||
// The crashing plugin genuinely loaded and registered its slot; without
|
||||
// this the rest of the test would pass even if it never imported.
|
||||
expect(await until(() => readFile(markerCrash, "utf8"), (value) => value === "setup\n")).toBe("setup\n")
|
||||
|
||||
// The app survives the crashing slot: hot reload still works for others.
|
||||
// The render-time boundary itself (fallback + toast) is not exercisable
|
||||
// here: the test renderer never executes slot render bodies, so render
|
||||
// containment is verified in the real TUI (see PluginBoundary in
|
||||
// src/plugin/render.tsx and the demo runs on the PR).
|
||||
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
|
||||
expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const markerA = path.join(tmp.path, "a.txt")
|
||||
const markerB = path.join(tmp.path, "b.txt")
|
||||
const sourceA = path.join(directory, "a.ts")
|
||||
const sourceB = path.join(directory, "b.ts")
|
||||
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
|
||||
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const readA = () => readFile(markerA, "utf8")
|
||||
const readB = () => readFile(markerB, "utf8")
|
||||
await until(readA, (value) => value === "a1:setup\n")
|
||||
await until(readB, (value) => value === "b1:setup\n")
|
||||
|
||||
// Editing B restarts only B: A sees no cleanup and no second setup.
|
||||
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
|
||||
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
|
||||
expect(await readA()).toBe("a1:setup\n")
|
||||
|
||||
// A broken save keeps the last good version running: b2 is never cleaned
|
||||
// up. Editing A afterwards provides a positive completion signal — once
|
||||
// A's swap lands, the serialized reconcile has processed the broken save.
|
||||
await writeFile(sourceB, "export default {")
|
||||
await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
|
||||
expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
|
||||
expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
|
||||
|
||||
// Fixing the file replaces the kept version and leaves A alone.
|
||||
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
|
||||
expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
|
||||
"b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
|
||||
)
|
||||
expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("a save whose setup throws restores the previous version", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const marker = path.join(tmp.path, "a.txt")
|
||||
const source = path.join(directory, "a.ts")
|
||||
await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const read = () => readFile(marker, "utf8")
|
||||
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
|
||||
|
||||
// The module imports fine but its setup throws — unlike an import failure,
|
||||
// the swap has already torn down a1, so keep-last-good means restoring it.
|
||||
await writeFile(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
id: "test.a",
|
||||
setup: async () => {
|
||||
throw new Error("setup boom")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
|
||||
"a1:setup\na1:cleanup\na1:setup\n",
|
||||
)
|
||||
|
||||
// Fixing the file swaps out the restored version normally.
|
||||
await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
|
||||
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
|
||||
"a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n",
|
||||
)
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("editing a symlinked plugin's target hot-reloads it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const marker = path.join(tmp.path, "a.txt")
|
||||
// The real source lives outside the discovery directory; only a symlink
|
||||
// is discovered. Edits land at the target, which emits no event in the
|
||||
// plugin directory itself.
|
||||
const target = path.join(tmp.path, "elsewhere", "a.ts")
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
|
||||
await symlink(target, path.join(directory, "a.ts"))
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const read = () => readFile(marker, "utf8")
|
||||
expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
|
||||
|
||||
await writeFile(target, lifecycleSource(marker, "test.a", "a2"))
|
||||
expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("memory storage survives hot reload while disk storage persists", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
const marker = path.join(tmp.path, "counter.txt")
|
||||
const source = path.join(directory, "counter.ts")
|
||||
const counterSource = (note: string) => `
|
||||
import { appendFile } from "node:fs/promises"
|
||||
// ${note}
|
||||
export default {
|
||||
id: "test.counter",
|
||||
setup: async (context: any) => {
|
||||
const [state, update] = context.storage.memory("counter", { initial: { count: 0 } })
|
||||
update((draft: any) => {
|
||||
draft.count += 1
|
||||
})
|
||||
await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n")
|
||||
},
|
||||
}
|
||||
`
|
||||
await writeFile(source, counterSource("v1"))
|
||||
|
||||
await using app = await bootApp(tmp.path)
|
||||
const read = () => readFile(marker, "utf8")
|
||||
expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n")
|
||||
|
||||
// The reloaded generation shares the same live store: the count continues.
|
||||
await writeFile(source, counterSource("v2"))
|
||||
expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { expect, test } from "bun:test"
|
||||
import { freshSpecifier, localSource } from "../src/plugin/discovery"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("localSource resolves file URLs and local paths but not package specs", () => {
|
||||
const base = process.cwd()
|
||||
const absolute = path.resolve(base, "abs", "plugin.ts")
|
||||
expect(localSource("file:///tmp/plugin.ts", base)?.href).toBe("file:///tmp/plugin.ts")
|
||||
expect(localSource("./plugin.ts", base)?.href).toBe(pathToFileURL(path.join(base, "plugin.ts")).href)
|
||||
expect(localSource("../plugin.ts", path.join(base, "nested"))?.href).toBe(
|
||||
pathToFileURL(path.join(base, "plugin.ts")).href,
|
||||
)
|
||||
expect(localSource(absolute, base)?.href).toBe(pathToFileURL(absolute).href)
|
||||
expect(localSource("some-package", base)).toBeUndefined()
|
||||
expect(localSource("@scope/some-package", base)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("freshSpecifier re-imports a plugin source after it changes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "plugin.ts")
|
||||
await writeFile(file, "export default 1")
|
||||
const first: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 1))
|
||||
await writeFile(file, "export default 2")
|
||||
const second: { readonly default?: unknown } = await import(freshSpecifier(pathToFileURL(file).href, 2))
|
||||
expect(first.default).toBe(1)
|
||||
expect(second.default).toBe(2)
|
||||
})
|
||||
@@ -111,7 +111,7 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"attachments": {
|
||||
"media": {
|
||||
"image": {
|
||||
"auto_resize": true,
|
||||
"max_width": 2000,
|
||||
|
||||
@@ -267,14 +267,14 @@ this field, but it does not start language servers yet.
|
||||
|
||||
See the [LSP guide](/lsp) for accepted fields and current limitations.
|
||||
|
||||
### Attachments
|
||||
### Media
|
||||
|
||||
Control how oversized images loaded by the `read` tool are resized or rejected
|
||||
before they are sent to a model.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"attachments": {
|
||||
"media": {
|
||||
"image": {
|
||||
"auto_resize": true,
|
||||
"max_width": 2000,
|
||||
|
||||
@@ -175,16 +175,16 @@ Rename the singular `snapshot` field to `snapshots`. Its boolean value does not
|
||||
{ "snapshots": false }
|
||||
```
|
||||
|
||||
### Attachments
|
||||
### Media
|
||||
|
||||
Rename the singular `attachment` object to `attachments`. Nested image settings keep the same names:
|
||||
Rename the singular `attachment` object to `media`. Nested image settings keep the same names:
|
||||
|
||||
```jsonc
|
||||
// V1
|
||||
{ "attachment": { "image": { "auto_resize": true } } }
|
||||
|
||||
// V2
|
||||
{ "attachments": { "image": { "auto_resize": true } } }
|
||||
{ "media": { "image": { "auto_resize": true } } }
|
||||
```
|
||||
|
||||
### MCP servers
|
||||
|
||||
Reference in New Issue
Block a user