mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 07:53:33 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cac6d60ed | |||
| 903acdf0d4 | |||
| 2cfe8883ea | |||
| 102086c50f | |||
| 06e26d89ad | |||
| 84dd56ed34 | |||
| f92e490bb6 | |||
| 0481dba88e | |||
| dd0e41c633 | |||
| b69e51d835 | |||
| e60e8d9387 | |||
| 3bfce3fd2d | |||
| b498a5c6c4 | |||
| db9e942398 |
@@ -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" })],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -118,6 +118,7 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
|
||||
|
||||
export type Endpoint5_1Input = {
|
||||
readonly id?: Session.ID | undefined
|
||||
readonly title?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
|
||||
@@ -299,7 +299,13 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
||||
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
|
||||
preserveEffect<Endpoint5_1Output>()(
|
||||
raw["session.create"]({
|
||||
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
|
||||
payload: {
|
||||
id: input?.["id"],
|
||||
title: input?.["title"],
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
||||
@@ -462,6 +462,7 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session`,
|
||||
body: {
|
||||
id: input?.["id"],
|
||||
title: input?.["title"],
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
|
||||
@@ -2662,24 +2662,35 @@ export type SessionListOutput = SessionsResponse
|
||||
export type SessionCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
@@ -341,17 +343,54 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
get: (input) => runtime.session.get(input.sessionID),
|
||||
list: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const query =
|
||||
input?.cursor !== undefined
|
||||
? yield* SessionsQuery.Cursor.parse(input.cursor).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
: (input ?? {})
|
||||
const page = yield* runtime.session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: input?.limit ?? SessionsQuery.DefaultLimit,
|
||||
})
|
||||
return { data: page.data, cursor: SessionsQuery.Cursor.page(query, page.data) }
|
||||
}),
|
||||
messages: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.cursor !== undefined && input.order !== undefined)
|
||||
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
|
||||
const decoded =
|
||||
input.cursor !== undefined
|
||||
? yield* SessionMessagesCursor.parse(input.cursor).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
: undefined
|
||||
const order = decoded?.order ?? input.order ?? "desc"
|
||||
const data = yield* runtime.session.messages({
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
return { data, cursor: SessionMessagesCursor.page(data, order) }
|
||||
}),
|
||||
prompt: runtime.session.prompt,
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
synthetic: runtime.session.synthetic,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
wait: (input) => runtime.session.wait(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
@@ -11,7 +11,18 @@ import { Session } from "../session"
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
Session.Interface,
|
||||
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
|
||||
| "get"
|
||||
| "create"
|
||||
| "list"
|
||||
| "messages"
|
||||
| "prompt"
|
||||
| "generate"
|
||||
| "command"
|
||||
| "rename"
|
||||
| "resume"
|
||||
| "interrupt"
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
@@ -48,13 +59,16 @@ export const layerWithCell = (cell: Cell) =>
|
||||
session: {
|
||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,11 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
const IMAGE_REMOVED =
|
||||
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||
|
||||
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
|
||||
@@ -94,6 +99,56 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
|
||||
const size = (data: string | Uint8Array) =>
|
||||
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
|
||||
const imageBytes = messages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
message.content.reduce((sum, part) => {
|
||||
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return sum
|
||||
return (
|
||||
sum +
|
||||
part.result.value.reduce(
|
||||
(bytes: number, item: Content) =>
|
||||
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
|
||||
0,
|
||||
)
|
||||
)
|
||||
}, 0),
|
||||
0,
|
||||
)
|
||||
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
|
||||
|
||||
let removed = 0
|
||||
return messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
|
||||
removed += size(part.data)
|
||||
return Message.text(IMAGE_REMOVED)
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: Content) => {
|
||||
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
|
||||
return item
|
||||
removed += Buffer.byteLength(item.uri)
|
||||
return { type: "text" as const, text: IMAGE_REMOVED }
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an outbound model request and captures the tool-call capability that
|
||||
* must remain paired with it. It does not execute the request or mutate
|
||||
@@ -158,9 +213,9 @@ 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),
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -103,11 +103,15 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
|
||||
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
|
||||
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
|
||||
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
|
||||
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
@@ -62,3 +62,51 @@ describe("SessionModelRequest.unsupportedParts", () => {
|
||||
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.boundImages", () => {
|
||||
test("preserves images below the trigger", () => {
|
||||
const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
|
||||
expect(boundImages(messages)).toBe(messages)
|
||||
})
|
||||
|
||||
test("replaces oldest images until the retained payload reaches the target", () => {
|
||||
const image = "a".repeat(9 * 1024 * 1024)
|
||||
const messages = [
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
|
||||
]
|
||||
const result = boundImages(messages)
|
||||
|
||||
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
|
||||
})
|
||||
|
||||
test("replaces images nested in tool results", () => {
|
||||
const image = "a".repeat(13 * 1024 * 1024)
|
||||
const result = boundImages([
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
|
||||
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(result[0]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "text" }, { type: "file", name: "second.png" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
@@ -29,7 +29,9 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
|
||||
"create" | "get" | "list" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
/** Read a session's projected message history, paginated like the HTTP message list endpoint. */
|
||||
readonly messages: MessageApi<unknown>["list"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
@@ -29,76 +30,6 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const SessionsProjectQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
||||
|
||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: Session.ListAnchor,
|
||||
}))
|
||||
|
||||
const SessionsCursorInput = Schema.Union([
|
||||
withCursor(SessionsDirectoryQuery),
|
||||
withCursor(SessionsProjectQuery),
|
||||
withCursor(SessionsAllQuery),
|
||||
])
|
||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const SessionsCursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
return {
|
||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
@@ -111,28 +42,17 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: SessionsQueryCursor.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
|
||||
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
|
||||
HttpApiGroup.make("server.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.list", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
query: SessionsQuery.Query,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(Session.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
previous: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
next: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "SessionsResponse" }),
|
||||
error: [InvalidCursorError, InvalidRequestError],
|
||||
@@ -149,6 +69,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
HttpApiEndpoint.post("session.create", "/api/session", {
|
||||
payload: Schema.Struct({
|
||||
id: Session.ID.pipe(Schema.optional),
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
location: Location.Ref.pipe(Schema.optional),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsCursor } from "../src/groups/session.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
describe("SessionsCursor", () => {
|
||||
test("round trips without Node globals", async () => {
|
||||
const input = {
|
||||
workspace: undefined,
|
||||
search: "protocol",
|
||||
order: "desc" as const,
|
||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||
}
|
||||
const cursor = SessionsCursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export * as SessionMessagesCursor from "./session-messages-cursor.js"
|
||||
|
||||
import { Effect, Encoding, Result, Schema } from "effect"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
|
||||
/**
|
||||
* Shared session message pagination cursor. Lives in schema so the protocol
|
||||
* message handler and the core plugin host paginate identically. The encoded
|
||||
* cursor carries the page order, so a cursor cannot be combined with an
|
||||
* explicit order.
|
||||
*/
|
||||
|
||||
export const DefaultLimit = 50
|
||||
|
||||
export const Order = Schema.Literals(["asc", "desc"])
|
||||
export type Order = typeof Order.Type
|
||||
|
||||
const Payload = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Order,
|
||||
direction: Schema.Literals(["previous", "next"]),
|
||||
})
|
||||
export type Payload = typeof Payload.Type
|
||||
|
||||
const PayloadJson = Schema.fromJsonString(Payload)
|
||||
const encodePayload = Schema.encodeSync(PayloadJson)
|
||||
const decodePayload = Schema.decodeUnknownEffect(PayloadJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const make = (input: Payload) => Encoding.encodeBase64Url(encodePayload(input))
|
||||
|
||||
export const parse = (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodePayload(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
})
|
||||
|
||||
/** previous/next cursors for one returned page of messages. */
|
||||
export const page = (data: ReadonlyArray<SessionMessage.Info>, order: Order) => {
|
||||
const first = data[0]
|
||||
const last = data.at(-1)
|
||||
return {
|
||||
previous: first ? make({ id: first.id, order, direction: "previous" }) : undefined,
|
||||
next: last ? make({ id: last.id, order, direction: "next" }) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export * as SessionsQuery from "./sessions-query.js"
|
||||
|
||||
import { DateTime, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||
import { Session } from "./session.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
|
||||
/**
|
||||
* Shared session list query and cursor codec. Lives in schema so both the
|
||||
* protocol session group and the core plugin host paginate identically.
|
||||
*/
|
||||
|
||||
export const DefaultLimit = 50
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
export const Fields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const DirectoryQuery = Schema.Struct({
|
||||
...Fields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ProjectQuery = Schema.Struct({
|
||||
...Fields,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const AllQuery = Schema.Struct(Fields)
|
||||
|
||||
const withAnchor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: Session.ListAnchor,
|
||||
}))
|
||||
|
||||
const CursorInput = Schema.Union([withAnchor(DirectoryQuery), withAnchor(ProjectQuery), withAnchor(AllQuery)])
|
||||
const CursorJson = Schema.fromJsonString(CursorInput)
|
||||
const encodeCursor = Schema.encodeSync(CursorJson)
|
||||
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
type PageQuery = Omit<typeof Query.Type, "limit" | "cursor">
|
||||
|
||||
export const Cursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
const makeCursor = (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input)))
|
||||
return {
|
||||
make: makeCursor,
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
/**
|
||||
* previous/next cursors for one returned page, re-anchoring the
|
||||
* originating query at the page's first and last items.
|
||||
*/
|
||||
page: (query: PageQuery, data: ReadonlyArray<Session.Info>) => {
|
||||
const first = data[0]
|
||||
const last = data.at(-1)
|
||||
return {
|
||||
previous: first
|
||||
? makeCursor({
|
||||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.updated),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? makeCursor({
|
||||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.updated),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export const Query = Schema.Struct({
|
||||
...Fields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: Cursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
}).pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
export type Query = typeof Query.Type
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsQuery } from "../src/sessions-query.js"
|
||||
import { SessionMessagesCursor } from "../src/session-messages-cursor.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { Session } from "../src/session.js"
|
||||
|
||||
describe("SessionsQuery.Cursor", () => {
|
||||
test("round trips without Node globals", async () => {
|
||||
const input = {
|
||||
workspace: undefined,
|
||||
search: "protocol",
|
||||
order: "desc" as const,
|
||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||
}
|
||||
const cursor = SessionsQuery.Cursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionsQuery.Cursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionMessagesCursor", () => {
|
||||
test("round trips", async () => {
|
||||
const input = {
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
order: "desc" as const,
|
||||
direction: "next" as const,
|
||||
}
|
||||
const cursor = SessionMessagesCursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionMessagesCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,10 @@
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
||||
|
||||
const cursor = {
|
||||
encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") {
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
},
|
||||
}
|
||||
|
||||
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -33,15 +14,16 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined)
|
||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
||||
})
|
||||
const decoded = ctx.query.cursor
|
||||
? yield* SessionMessagesCursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: undefined
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const messages = yield* session
|
||||
.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
limit: ctx.query.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
@@ -66,14 +48,9 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
)
|
||||
}),
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
data: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
cursor: SessionMessagesCursor.page(messages, order),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import {
|
||||
ConflictError,
|
||||
CommandEvaluationError,
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -31,42 +29,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
const query =
|
||||
ctx.query.cursor !== undefined
|
||||
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
||||
? yield* SessionsQuery.Cursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: ctx.query
|
||||
const page = yield* session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||
limit: ctx.query.limit ?? SessionsQuery.DefaultLimit,
|
||||
})
|
||||
const sessions = page.data
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
data: sessions,
|
||||
cursor: {
|
||||
previous: first
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.updated),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.updated),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
data: page.data,
|
||||
cursor: SessionsQuery.Cursor.page(query, page.data),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -77,6 +51,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
data: yield* session
|
||||
.create({
|
||||
id: ctx.payload.id,
|
||||
title: ctx.payload.title,
|
||||
agent: ctx.payload.agent,
|
||||
model: ctx.payload.model,
|
||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
|
||||
| `ctx.session` | `create`, `get`, `list`, `messages`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
|
||||
@@ -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