mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc7ccd3c1 |
@@ -67,7 +67,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
@@ -521,7 +520,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -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", "openrouter"])
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
@@ -133,7 +133,6 @@ 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,7 +11,6 @@ import {
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -39,11 +38,6 @@ 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,
|
||||
@@ -53,7 +47,6 @@ 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>
|
||||
|
||||
@@ -68,11 +61,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("image_url"),
|
||||
image_url: Schema.Struct({ url: Schema.String }),
|
||||
@@ -80,10 +69,7 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
])
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
@@ -97,16 +83,10 @@ 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,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
|
||||
@@ -127,7 +107,6 @@ export const bodyFields = {
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
max_completion_tokens: Schema.optional(Schema.Number),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
@@ -230,20 +209,13 @@ 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`.
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): 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"]>) =>
|
||||
@@ -285,14 +257,11 @@ 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,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
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, cache_control: options.cacheControl?.(part.cache) })
|
||||
content.push({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -301,18 +270,14 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||
}
|
||||
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(""),
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => 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[] = []
|
||||
@@ -350,44 +315,29 @@ 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,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||
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),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
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"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
@@ -401,29 +351,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
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
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||
return (yield* lowerToolMessages(message)).messages
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const system: OpenAIChatMessage[] =
|
||||
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) }]
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -434,53 +370,28 @@ 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, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
})
|
||||
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
||||
continue
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && typeof previous.content === "string")
|
||||
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}` }
|
||||
messages[messages.length - 1] = { 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, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
content: [...previous.content, { type: "text", text: 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 },
|
||||
)
|
||||
else messages.push({ role: "user", content: part.text })
|
||||
continue
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
const lowered = yield* lowerToolMessages(message)
|
||||
messages.push(...lowered.messages)
|
||||
pendingImages.push(...lowered.images)
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
@@ -494,10 +405,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
request: LLMRequest,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
@@ -507,26 +415,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
messages: yield* lowerMessages(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
options,
|
||||
),
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
...(maxTokensField === "max_completion_tokens"
|
||||
? { max_completion_tokens: generation?.maxTokens }
|
||||
: { max_tokens: generation?.maxTokens }),
|
||||
max_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly input?: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,72 +4,21 @@ 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 CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, 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 debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||
readonly models?: ReadonlyArray<string>
|
||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||
readonly usage?: boolean | Record<string, unknown>
|
||||
readonly reasoning?: Record<string, unknown>
|
||||
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 & {
|
||||
@@ -98,7 +47,7 @@ export const protocol = Protocol.make({
|
||||
body: {
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
|
||||
OpenAIChat.protocol.body.from(request).pipe(
|
||||
Effect.map((body) => {
|
||||
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||
let assistantIndex = 0
|
||||
@@ -129,39 +78,16 @@ 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 {
|
||||
...options,
|
||||
...(usage === undefined || usage === true
|
||||
...(openrouter.usage === true
|
||||
? { usage: { include: true } }
|
||||
: 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 } : {}),
|
||||
: isRecord(openrouter.usage)
|
||||
? { usage: openrouter.usage }
|
||||
: {}),
|
||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
||||
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||
"anthropic_version",
|
||||
"content",
|
||||
"contents",
|
||||
"frequencyPenalty",
|
||||
"frequency_penalty",
|
||||
"generationConfig",
|
||||
"inferenceConfig",
|
||||
"input",
|
||||
"maxTokens",
|
||||
"max_tokens",
|
||||
"messages",
|
||||
"model",
|
||||
"presencePenalty",
|
||||
"presence_penalty",
|
||||
"responseFormat",
|
||||
"response_format",
|
||||
"seed",
|
||||
"stop",
|
||||
"stopSequences",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"streamOptions",
|
||||
"stream_options",
|
||||
"system",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"temperature",
|
||||
"thinking",
|
||||
"toolChoice",
|
||||
"toolConfig",
|
||||
"tool_choice",
|
||||
"tool_config",
|
||||
"tools",
|
||||
"topK",
|
||||
"topP",
|
||||
"top_k",
|
||||
"top_p",
|
||||
])
|
||||
|
||||
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
||||
|
||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||
Effect.gen(function* () {
|
||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
||||
if (forbiddenKeys.length > 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
||||
)
|
||||
if (ProviderShared.isRecord(body)) {
|
||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||
|
||||
@@ -124,7 +124,6 @@ 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" }),
|
||||
@@ -169,7 +168,6 @@ 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" })
|
||||
|
||||
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
|
||||
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
|
||||
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
|
||||
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
|
||||
|
||||
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
|
||||
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
|
||||
|
||||
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
||||
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
|
||||
}) {}
|
||||
|
||||
export namespace ModelCompatibility {
|
||||
|
||||
@@ -171,27 +171,24 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||
|
||||
@@ -5,28 +5,6 @@ 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",
|
||||
|
||||
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
http: { body: { anthropic_version: "wrong" } },
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
|
||||
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("configures the max tokens request field", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
|
||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches AI SDK compatible tool request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../../src"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
@@ -27,131 +27,10 @@ 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(
|
||||
@@ -163,13 +42,6 @@ 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"),
|
||||
@@ -181,54 +53,10 @@ 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")
|
||||
@@ -276,7 +104,6 @@ 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([
|
||||
{
|
||||
@@ -310,7 +137,6 @@ 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",
|
||||
@@ -336,7 +162,6 @@ 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",
|
||||
@@ -358,7 +183,6 @@ 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" })],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
@@ -12,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
return session ? displayLabel(session) : props.fallbackTitle
|
||||
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => displayLabel(props.session)
|
||||
const title = () => sessionTitle(props.session.title, props.session.parentID)
|
||||
|
||||
return (
|
||||
<A
|
||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={displayLabel(props.session)}
|
||||
value={sessionTitle(props.session.title, props.session.parentID)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
@@ -296,10 +296,11 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => {
|
||||
const session = info()
|
||||
if (!session) return
|
||||
return displayLabel(session)
|
||||
return sessionTitle(titleValue(), session.parentID)
|
||||
})
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
@@ -316,7 +317,7 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
const parentTitle = createMemo(() => {
|
||||
const session = parent()
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
|
||||
})
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
@@ -920,7 +921,7 @@ export function MessageTimeline(props: {
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => {
|
||||
const session = sync().session.get(props.sessionID)
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
|
||||
})
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
|
||||
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 0
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionTitle } from "./session-title"
|
||||
|
||||
describe("sessionTitle", () => {
|
||||
test("uses a display fallback without persisting it", () => {
|
||||
expect(sessionTitle(undefined)).toBe("New session")
|
||||
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
|
||||
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
|
||||
expect(sessionTitle("Generated title")).toBe("Generated title")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string, parentID?: string) {
|
||||
if (!title) return parentID ? "Child session" : "New session"
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: withTimestampedFallback(input),
|
||||
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
|
||||
@@ -58,9 +58,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
source: path.join(ptyRoot, relative),
|
||||
})),
|
||||
]
|
||||
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
|
||||
await Promise.all(unique.map((asset) => stat(asset.source)))
|
||||
return unique
|
||||
await Promise.all(assets.map((asset) => stat(asset.source)))
|
||||
return assets
|
||||
}
|
||||
|
||||
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
@@ -214,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
title: withTimestampedFallback(session),
|
||||
title:
|
||||
session.title ??
|
||||
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
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 {
|
||||
@@ -23,7 +20,14 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(settings, baseSettings)
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapProviderOptions("openrouter", settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/xai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
@@ -65,61 +69,6 @@ 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 } : {}),
|
||||
@@ -129,3 +78,13 @@ 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 } }
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||
headers: info.headers,
|
||||
},
|
||||
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||
limits: { context: info.limit.context, output: info.limit.output },
|
||||
providerOptions,
|
||||
},
|
||||
body: {
|
||||
|
||||
@@ -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 { ConfigMedia } from "./config/media"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
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",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
||||
description: "Attachment processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigMedia from "./media"
|
||||
export * as ConfigAttachments from "./attachments"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
export class Image extends Schema.Class<Image>("Config.Attachments.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.Media")({
|
||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
||||
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.media?.image ? [entry.info.media.image] : [],
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
|
||||
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: Info) => {
|
||||
@@ -96,7 +96,9 @@ 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
|
||||
@@ -200,9 +202,9 @@ export const fromCatalogModel = (
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
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 },
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
@@ -264,7 +266,10 @@ 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),
|
||||
|
||||
@@ -220,8 +220,12 @@ export const OpenAIPlugin = define({
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
if (draft.id.includes("gpt-5.5")) {
|
||||
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
}
|
||||
if (draft.id.includes("gpt-5.6")) {
|
||||
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -938,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
: resolved.bytes
|
||||
const normalized = yield* normalizeImageAttachment(
|
||||
input,
|
||||
Buffer.from(content).toString("base64"),
|
||||
Base64.make(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: string,
|
||||
data: Base64,
|
||||
mime: string,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
if (!mime.startsWith("image/")) return { 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 }
|
||||
|
||||
@@ -152,11 +152,14 @@ const settings = (documents: readonly Config.Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
|
||||
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
|
||||
}
|
||||
return configured.reduce<Settings>(
|
||||
(result, current) => ({
|
||||
auto: current.auto ?? result.auto,
|
||||
buffer: current.buffer ?? result.buffer,
|
||||
tokens: current.keep?.tokens ?? result.tokens,
|
||||
}),
|
||||
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
|
||||
)
|
||||
}
|
||||
|
||||
const select = (
|
||||
@@ -347,16 +350,11 @@ const make = (dependencies: Dependencies) => {
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const limits = input.model.route.defaults.limits
|
||||
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
return used >= context - (output || config.buffer)
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, config.tokens)
|
||||
|
||||
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -19,11 +19,6 @@ 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
|
||||
|
||||
@@ -99,56 +94,6 @@ 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
|
||||
@@ -213,9 +158,9 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Agent } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
@@ -40,10 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
|
||||
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
||||
const isUntitled = (session: SessionSchema.Info) =>
|
||||
isExactRootFallback({
|
||||
title: session.title,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
})
|
||||
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
attachments: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
|
||||
@@ -2,42 +2,6 @@ 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"] } },
|
||||
media: {
|
||||
attachments: {
|
||||
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.media).toEqual({
|
||||
expect(documents[0]?.info.attachments).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.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.providers?.custom).toMatchObject({
|
||||
settings: { apiKey: "secret" },
|
||||
models: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -18,7 +17,6 @@ interface ModelOptions {
|
||||
readonly headers?: Info["headers"]
|
||||
readonly body?: Info["body"]
|
||||
readonly variants?: Info["variants"]
|
||||
readonly limit?: Info["limit"]
|
||||
}
|
||||
|
||||
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
@@ -38,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: options.limit ?? { context: 100, output: 20 },
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
@@ -46,7 +44,6 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
limit: { context: 100, input: 80, output: 20 },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
@@ -58,7 +55,7 @@ describe("ModelResolver", () => {
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
limits: { context: 100, input: 80, output: 20 },
|
||||
limits: { context: 100, output: 20 },
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
@@ -547,37 +544,6 @@ 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(
|
||||
|
||||
@@ -74,9 +74,6 @@ describe("OpenAIPlugin", () => {
|
||||
]
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
|
||||
model.modelID = Model.ID.make("gpt-5.4")
|
||||
model.body = { reasoning: { mode: "pro" } }
|
||||
@@ -140,7 +137,7 @@ describe("OpenAIPlugin", () => {
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -148,15 +145,10 @@ describe("OpenAIPlugin", () => {
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 272_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,11 +19,9 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -132,52 +130,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const session = Session.Info.make({
|
||||
id: Session.ID.make("ses_input_limit"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
|
||||
})
|
||||
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
|
||||
session,
|
||||
model: Model.make({
|
||||
id: "test-model",
|
||||
provider: "test-provider",
|
||||
route: OpenAIChat.route.with({ limits }),
|
||||
}),
|
||||
cost: [],
|
||||
messages: [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_assistant"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: { id: "test-model", providerID: "test-provider" },
|
||||
content: [],
|
||||
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, completed: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
|
||||
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
|
||||
|
||||
const contextLimited = { context: 100_000, output: 10_000 }
|
||||
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
|
||||
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
|
||||
|
||||
const outputLimited = { context: 100_000, output: 30_000 }
|
||||
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
|
||||
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
@@ -62,51 +62,3 @@ 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,10 +58,7 @@ const locations = Layer.effect(
|
||||
() =>
|
||||
// Attachment admission only needs the location-scoped Image service.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
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>,
|
||||
imagePassthrough as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -389,35 +386,6 @@ 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 { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
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({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
@@ -159,7 +158,11 @@ export default function () {
|
||||
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
|
||||
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
|
||||
const info = createMemo(() => data().session[match().index])
|
||||
const title = createMemo(() => withTimestampedFallback(info()))
|
||||
const title = createMemo(
|
||||
() =>
|
||||
info().title ??
|
||||
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
|
||||
)
|
||||
const ogImage = createMemo(() => {
|
||||
const models = new Set<string>()
|
||||
const messages = data().message[data().sessionID] ?? []
|
||||
|
||||
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
@@ -537,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
|
||||
if (route.data.type === "session") {
|
||||
const title = session?.title
|
||||
if (!title || isFallbackTitle(title)) {
|
||||
if (!title || isDefaultTitle(title)) {
|
||||
renderer.setTerminalTitle("OpenCode")
|
||||
return
|
||||
}
|
||||
@@ -646,10 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
@@ -159,10 +159,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (b.location === b.root.directory) return 1
|
||||
return a.location.localeCompare(b.location)
|
||||
})
|
||||
const titleWidth = Math.max(
|
||||
1,
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
|
||||
)
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
|
||||
@@ -2,8 +2,8 @@ import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -16,7 +16,7 @@ import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
@@ -37,8 +37,8 @@ export function DialogOpen() {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
|
||||
data.project.invalidate()
|
||||
void data.project.sync().catch(() => {})
|
||||
|
||||
// One background fetch fills in recent sessions from other projects; the menu renders
|
||||
@@ -52,12 +52,8 @@ export function DialogOpen() {
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const openTabs = createMemo(
|
||||
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
|
||||
)
|
||||
const currentSessionID = createMemo(() =>
|
||||
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
|
||||
)
|
||||
const openTabs = createMemo(() => new Set(sessionTabs.tabs().map((tab) => tab.sessionID)))
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
const sessions = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return [...data.session.list(), ...fetched()]
|
||||
@@ -73,7 +69,7 @@ export function DialogOpen() {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a loaded tab by name still switches to it.
|
||||
// matching a tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
@@ -82,15 +78,12 @@ export function DialogOpen() {
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
title: sessionTitle(session),
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
? () => <Spinner />
|
||||
: tabs.has(session.id)
|
||||
@@ -104,25 +97,21 @@ export function DialogOpen() {
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
if (project.canonical === "/" || project.id === current?.id || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
|
||||
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,8 +127,6 @@ export function DialogOpen() {
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
|
||||
@@ -18,8 +18,7 @@ import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -31,15 +30,12 @@ export function DialogSessionList() {
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
|
||||
const allProjects = () => prefs.allProjects
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
@@ -82,15 +78,12 @@ export function DialogSessionList() {
|
||||
(session.projectID === current?.project.id && session.location.directory === current.directory),
|
||||
)
|
||||
if (!query) return sessions
|
||||
return sessions.filter(
|
||||
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
|
||||
)
|
||||
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
|
||||
})
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
const local = localSessions()
|
||||
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
|
||||
if (searchResults.loading) return searchResults.latest?.sessions ?? []
|
||||
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
|
||||
const result = searchResults()
|
||||
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
|
||||
return result.sessions
|
||||
@@ -147,21 +140,17 @@ export function DialogSessionList() {
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting
|
||||
? `Press ${shortcuts.get("session.delete")} again to confirm`
|
||||
: withTimestampedFallback(session),
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
gutter:
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,9 +236,7 @@ export function DialogSessionList() {
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result
|
||||
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
|
||||
: result,
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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 { go, logo } from "../logo"
|
||||
import { 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)
|
||||
@@ -51,29 +49,14 @@ export function Logo() {
|
||||
|
||||
return (
|
||||
<box>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/** @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>
|
||||
)
|
||||
}
|
||||
@@ -1168,19 +1168,6 @@ 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()
|
||||
@@ -1204,15 +1191,6 @@ 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
|
||||
}
|
||||
@@ -1314,20 +1292,13 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
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()
|
||||
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 locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
@@ -1377,8 +1348,8 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
>
|
||||
<box
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={promptBg()}
|
||||
@@ -1454,48 +1425,33 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (props.disabled || r.button !== 0) return
|
||||
if (props.disabled) 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} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={agentLabel()} fallback={<box height={1} />}>
|
||||
{(label) => (
|
||||
<>
|
||||
<text fg={fadeColor(highlight(), agentMetaAlpha())}>{label()}</text>
|
||||
<Show
|
||||
when={store.mode === "normal" && local.permission.mode === "auto" && dimensions().width >= 44}
|
||||
>
|
||||
<Show when={store.mode === "normal" && local.permission.mode === "auto"}>
|
||||
<text fg={fadeColor(theme.text.subdued, agentMetaAlpha())}>auto</text>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal" && dimensions().width >= 28}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>·</text>
|
||||
<text
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())}
|
||||
>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<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, modelMetaAlpha())}>{currentProviderLabel()}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
|
||||
<text>
|
||||
<span
|
||||
|
||||
@@ -45,7 +45,6 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
// until MCP elicitations carry session ownership.
|
||||
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
|
||||
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
|
||||
|
||||
type LocationData = {
|
||||
info?: LocationGetOutput
|
||||
@@ -62,7 +61,7 @@ type LocationData = {
|
||||
websearch?: WebSearchProvider[]
|
||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||
shell?: Record<string, ShellWithLocation>
|
||||
shell?: Record<string, ShellInfo>
|
||||
skill?: SkillInfo[]
|
||||
}
|
||||
|
||||
@@ -375,11 +374,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
.catch((error) => console.error("Failed to load projected model switch message", error))
|
||||
break
|
||||
case "session.renamed":
|
||||
// Preserve the live title when it races the session's initial read.
|
||||
void result.session.sync(event.data.sessionID).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
})
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.moved":
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
@@ -855,10 +851,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "shell.created":
|
||||
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
|
||||
...data,
|
||||
shell: {
|
||||
...data?.shell,
|
||||
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
|
||||
},
|
||||
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
|
||||
}))
|
||||
break
|
||||
case "shell.exited":
|
||||
@@ -1113,18 +1106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../util/session"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useConfig } from "../config"
|
||||
@@ -78,21 +78,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
}
|
||||
|
||||
const root = (sessionID: string) => data.session.root(sessionID)
|
||||
const title = (sessionID: string, persisted?: string, fallback?: string) => {
|
||||
const session = data.session.get(sessionID)
|
||||
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
|
||||
}
|
||||
const normalize = (value: TabsState) => ({
|
||||
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
|
||||
}, []),
|
||||
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {}),
|
||||
})
|
||||
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
||||
const newTab = createMemo((open = false) => {
|
||||
if (route.data.type === "home") return true
|
||||
@@ -130,29 +115,32 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
||||
const sessionID = root(route.data.sessionID)
|
||||
history = recordSessionTabHistory(history, sessionID)
|
||||
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
|
||||
const tabs = openSessionTab(state().tabs, {
|
||||
sessionID,
|
||||
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
const session = data.session.get(sessionID)
|
||||
const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined)
|
||||
const tabs = openSessionTab(state().tabs, { sessionID, title })
|
||||
if (tabs === state().tabs && !state().unread[sessionID]) return
|
||||
update((draft) => {
|
||||
draft.tabs = openSessionTab(draft.tabs, {
|
||||
sessionID,
|
||||
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
|
||||
})
|
||||
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
|
||||
delete draft.unread[sessionID]
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
const next = normalize(state())
|
||||
if (isDeepEqual(next, state())) return
|
||||
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
|
||||
const sessionID = root(tab.sessionID)
|
||||
const session = data.session.get(sessionID)
|
||||
return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title })
|
||||
}, [])
|
||||
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
||||
return result
|
||||
}, {})
|
||||
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
|
||||
update((draft) => {
|
||||
const next = normalize(draft)
|
||||
draft.tabs = next.tabs
|
||||
draft.unread = next.unread
|
||||
draft.tabs = next
|
||||
draft.unread = unread
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
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) ?? [])
|
||||
@@ -36,26 +53,34 @@ 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 (
|
||||
<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
|
||||
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>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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",
|
||||
@@ -9,7 +8,6 @@ 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
|
||||
@@ -62,24 +60,19 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
<Show when={status().length > 0}>{status().join(" · ")}</Show>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={dimensions().width >= 44}>
|
||||
<Match when={true}>
|
||||
<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>
|
||||
<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>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={props.mode === "shell"}>
|
||||
<text fg={props.context.theme.text.default} flexShrink={0}>
|
||||
esc{" "}
|
||||
<span style={{ fg: props.context.theme.text.subdued }}>
|
||||
{dimensions().width < 44 ? "shell" : "exit shell mode"}
|
||||
</span>
|
||||
esc <span style={{ fg: props.context.theme.text.subdued }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
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 ScrollBoxRenderable } from "@opentui/core"
|
||||
import {
|
||||
TextAttributes,
|
||||
type BorderSides,
|
||||
type BoxRenderable,
|
||||
type DiffRenderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
@@ -13,7 +19,6 @@ 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,
|
||||
@@ -149,7 +154,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const helpShortcut = shortcut("diff.help")
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
|
||||
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
|
||||
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
|
||||
@@ -265,16 +270,17 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!patchScroll) return
|
||||
const hunks = visiblePatchFiles()
|
||||
.flatMap((entry) => {
|
||||
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),
|
||||
})) ?? []
|
||||
)
|
||||
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,
|
||||
}))
|
||||
})
|
||||
.sort((left, right) => left.contentY - right.contentY)
|
||||
const selected = selectedHunk()
|
||||
@@ -825,10 +831,9 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={theme.border.default}>
|
||||
<PatchDiff
|
||||
ref={(component) => patchDiffByFileIndex.set(entry.fileIndex, component)}
|
||||
<diff
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
diff={patch()}
|
||||
hunkFg={reviewed() ? theme.text.subdued : theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={currentSyntax()}
|
||||
@@ -842,15 +847,9 @@ 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,7 +32,6 @@ 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[],
|
||||
@@ -406,9 +405,8 @@ export function RunPermissionBody(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<PatchDiff
|
||||
<diff
|
||||
diff={info().diff!}
|
||||
hunkFg={props.block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={ft()}
|
||||
syntaxStyle={props.block.syntax}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import path from "path"
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import { isDefaultTitle } from "../util/session"
|
||||
import { monoSnapshot } from "./mono"
|
||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||
import { resolveRunTheme } from "./theme"
|
||||
@@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
|
||||
}
|
||||
|
||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
||||
if (title && !isFallbackTitle(title)) {
|
||||
if (title && !isDefaultTitle(title)) {
|
||||
return {
|
||||
title,
|
||||
showSession: true,
|
||||
@@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||
const setTitle = (title?: string) => {
|
||||
if (input.host.platform !== "linux") return
|
||||
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
|
||||
}
|
||||
setTitle(input.sessionTitle)
|
||||
|
||||
@@ -13,7 +13,6 @@ 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) {
|
||||
@@ -179,9 +178,8 @@ export function RunEntryContent(props: {
|
||||
</text>
|
||||
{item.diff.trim() ? (
|
||||
<box width="100%" paddingLeft={1}>
|
||||
<PatchDiff
|
||||
<diff
|
||||
diff={item.diff}
|
||||
hunkFg={theme().block.diffLineNumber}
|
||||
view="unified"
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
||||
import {
|
||||
batch,
|
||||
createComponent,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
mergeProps,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
@@ -586,7 +584,7 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
||||
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 createComponent(render, { data: route.data.data })
|
||||
return render({ data: route.data.data })
|
||||
})
|
||||
return <>{content()}</>
|
||||
}
|
||||
@@ -602,16 +600,5 @@ export function PluginSlot<Name extends SlotName>(props: {
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
return (
|
||||
<For each={renderers()}>
|
||||
{(render) =>
|
||||
// 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 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(render, mergeProps(() => props.input) as SlotMap[Name])
|
||||
}
|
||||
</For>
|
||||
)
|
||||
return <For each={renderers()}>{(render) => render(props.input)}</For>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { PluginSlot } from "../plugin/context"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -27,7 +26,6 @@ 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()) ?? [])
|
||||
@@ -73,12 +71,7 @@ export function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
>
|
||||
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useData } from "../../../context/data"
|
||||
import { useLocation } from "../../../context/location"
|
||||
import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
@@ -9,14 +10,13 @@ import { useComposerTab } from "./index"
|
||||
|
||||
export function ShellTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const client = useClient()
|
||||
const theme = useTheme()
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const entries = createMemo(() =>
|
||||
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
|
||||
)
|
||||
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
|
||||
|
||||
const [store, setStore] = createStore({ selected: 0 })
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -83,9 +83,10 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry) return
|
||||
const ref = location.current
|
||||
void client.api.shell.remove({
|
||||
id: entry.id,
|
||||
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
|
||||
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useTheme } from "../../../context/theme"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../../util/session"
|
||||
|
||||
interface SubagentEntry {
|
||||
sessionID: string
|
||||
@@ -39,7 +39,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (current.parentID) {
|
||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||
for (const sibling of siblings) {
|
||||
const title = withTimestampedFallback(sibling)
|
||||
const title = sessionTitle(sibling)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = sibling.agent
|
||||
? Locale.titlecase(sibling.agent)
|
||||
@@ -58,7 +58,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
} else {
|
||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||
for (const child of children) {
|
||||
const title = withTimestampedFallback(child)
|
||||
const title = sessionTitle(child)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = child.agent
|
||||
? Locale.titlecase(child.agent)
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -93,7 +92,7 @@ import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../util/session"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -144,8 +143,8 @@ export function Session() {
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
const messages = () => data.session.message.list(route.sessionID)
|
||||
const location = createMemo(() => session()?.location)
|
||||
const currentLocation = useLocation()
|
||||
const location = createMemo(() => session()?.location ?? currentLocation.ref)
|
||||
|
||||
createEffect(() => currentLocation.set(location()))
|
||||
|
||||
@@ -296,6 +295,7 @@ export function Session() {
|
||||
let seeded = false
|
||||
let sent = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
const [showJumpToBottom, setShowJumpToBottom] = createSignal(false)
|
||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
setPrompt(r)
|
||||
@@ -315,6 +315,11 @@ export function Session() {
|
||||
})
|
||||
}
|
||||
|
||||
const updateJumpToBottom = () => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
setShowJumpToBottom(scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - 1)
|
||||
}
|
||||
|
||||
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
|
||||
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
|
||||
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
|
||||
@@ -323,6 +328,10 @@ export function Session() {
|
||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||
createEffect(() => {
|
||||
visibleRows().length
|
||||
afterLayout(updateJumpToBottom)
|
||||
})
|
||||
createEffect(() => {
|
||||
const current = hidden()
|
||||
if (current === 0) return
|
||||
@@ -410,6 +419,7 @@ export function Session() {
|
||||
setTimeout(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
updateJumpToBottom()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
@@ -422,6 +432,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -433,6 +444,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 2)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -444,6 +456,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-1)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -455,6 +468,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(1)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -466,6 +480,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -477,6 +492,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 4)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -491,6 +507,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollTo(0)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -502,6 +519,7 @@ export function Session() {
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
updateJumpToBottom()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -959,57 +977,69 @@ export function Session() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
paddingBottom={1}
|
||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
gap={1}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<scrollbox
|
||||
ref={(r) => (scroll = r)}
|
||||
viewportOptions={{
|
||||
paddingRight: showScrollbar() ? 1 : 0,
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.raise(theme.background.surface.offset),
|
||||
foregroundColor: theme.border.default,
|
||||
},
|
||||
}}
|
||||
stickyScroll={!navigationMessage()}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={visibleRows()}>
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index() + hidden()]}
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
<scrollbox
|
||||
id="session-transcript"
|
||||
ref={(r) => (scroll = r)}
|
||||
onMouseScroll={() => setTimeout(updateJumpToBottom, 0)}
|
||||
viewportOptions={{
|
||||
paddingRight: showScrollbar() ? 1 : 0,
|
||||
}}
|
||||
verticalScrollbarOptions={{
|
||||
paddingLeft: 1,
|
||||
visible: showScrollbar(),
|
||||
trackOptions: {
|
||||
backgroundColor: theme.raise(theme.background.surface.offset),
|
||||
foregroundColor: theme.border.default,
|
||||
},
|
||||
}}
|
||||
stickyScroll={!navigationMessage()}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={visibleRows()}>
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index() + hidden()]}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={
|
||||
messages().filter(
|
||||
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
|
||||
).length
|
||||
}
|
||||
files={session()!.revert!.files ?? []}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={
|
||||
messages().filter(
|
||||
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
|
||||
).length
|
||||
}
|
||||
files={session()!.revert!.files ?? []}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={navigationSlack()}>
|
||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<Show when={showJumpToBottom()}>
|
||||
<box
|
||||
id="session-jump-to-bottom"
|
||||
position="absolute"
|
||||
zIndex={1000}
|
||||
right={showScrollbar() ? 2 : 0}
|
||||
bottom={0}
|
||||
backgroundColor={theme.raise(theme.background.default)}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
onMouseUp={toBottom}
|
||||
>
|
||||
<text fg={theme.text.default}>↓ jump to bottom</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={navigationSlack()}>
|
||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
@@ -1042,9 +1072,6 @@ export function Session() {
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
@@ -1548,7 +1575,6 @@ function SessionGroupView(props: {
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
@@ -1582,10 +1608,8 @@ 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>
|
||||
<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 }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
@@ -3039,9 +3063,8 @@ function Edit(props: ToolProps) {
|
||||
{(item) => (
|
||||
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<PatchDiff
|
||||
<diff
|
||||
diff={item().patch}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={filetype(path())}
|
||||
syntaxStyle={syntax()}
|
||||
@@ -3129,9 +3152,8 @@ function ApplyPatch(props: ToolProps) {
|
||||
}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
<PatchDiff
|
||||
<diff
|
||||
diff={file.patch}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={filetype(file.relativePath)}
|
||||
syntaxStyle={syntax()}
|
||||
@@ -3321,7 +3343,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
|
||||
})
|
||||
return [`## Assistant\n\n${content.join("\n\n")}`]
|
||||
})
|
||||
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
|
||||
return `# ${sessionTitle(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
|
||||
}
|
||||
|
||||
export function parseApplyPatchFiles(value: unknown) {
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
|
||||
@@ -51,9 +50,8 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PatchDiff
|
||||
<diff
|
||||
diff={diff()}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={view()}
|
||||
filetype={ft()}
|
||||
syntaxStyle={syntax()}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { PluginSlot } from "../../plugin/context"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "../../util/session"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{withTimestampedFallback(session()!)}</b>
|
||||
<b>{sessionTitle(session()!)}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
|
||||
@@ -83,11 +83,6 @@ export interface DialogSelectOption<T = any> {
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
|
||||
export function dialogSelectContentWidth(dialogWidth: number) {
|
||||
// Scroll padding, row padding, the gutter, title padding, and the separating gap.
|
||||
return dialogWidth - 12
|
||||
}
|
||||
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
@@ -245,7 +240,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) {
|
||||
if (!props.preserveSelection && props.current === undefined) {
|
||||
const count = flat().length
|
||||
if (count === 0) return
|
||||
const next = reconcileSelection(store.selected, count)
|
||||
@@ -281,11 +276,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("selected", index)
|
||||
selection = option
|
||||
if (!moved) return
|
||||
if (
|
||||
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
|
||||
store.filter.length > 0
|
||||
)
|
||||
return
|
||||
if ((!props.preserveSelection && props.current === undefined) || store.filter.length > 0) return
|
||||
scrollAfterLayout(false, option.value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,17 +8,9 @@ import { useToast } from "./toast"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useConfig } from "../config"
|
||||
|
||||
export type DialogSize = "medium" | "large" | "xlarge"
|
||||
|
||||
export function dialogWidth(size: DialogSize) {
|
||||
if (size === "xlarge") return 116
|
||||
if (size === "large") return 88
|
||||
return 60
|
||||
}
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: DialogSize
|
||||
size?: "medium" | "large" | "xlarge"
|
||||
centered?: boolean
|
||||
onClose: () => void
|
||||
}>,
|
||||
@@ -28,6 +20,12 @@ export function Dialog(
|
||||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
const width = () => {
|
||||
if (props.size === "xlarge") return 116
|
||||
if (props.size === "large") return 88
|
||||
return 60
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseDown={() => {
|
||||
@@ -59,7 +57,7 @@ export function Dialog(
|
||||
dismiss = false
|
||||
e.stopPropagation()
|
||||
}}
|
||||
width={dialogWidth(props.size ?? "medium")}
|
||||
width={width()}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.background.default}
|
||||
paddingTop={1}
|
||||
@@ -76,7 +74,7 @@ function init() {
|
||||
element: JSX.Element
|
||||
onClose?: () => void
|
||||
}[],
|
||||
size: "medium" as DialogSize,
|
||||
size: "medium" as "medium" | "large" | "xlarge",
|
||||
centered: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Locale } from "./locale"
|
||||
|
||||
export function isDefaultTitle(title?: string) {
|
||||
return (
|
||||
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
|
||||
return (
|
||||
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
|
||||
)
|
||||
}
|
||||
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
|
||||
@@ -138,94 +138,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
}
|
||||
})
|
||||
|
||||
test("session title generated while an untitled session is loading remains visible", async () => {
|
||||
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 titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
const generatedTitle = Promise.withResolvers<void>()
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
titles.push(title)
|
||||
if (title === "OC | Generated title") generatedTitle.resolve()
|
||||
setTitle(title)
|
||||
}
|
||||
const sessionRequested = Promise.withResolvers<void>()
|
||||
const renameSyncRequested = Promise.withResolvers<void>()
|
||||
const releaseSession = Promise.withResolvers<void>()
|
||||
let sessionRequests = 0
|
||||
const session = {
|
||||
id: "dummy",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") {
|
||||
sessionRequests++
|
||||
sessionRequested.resolve()
|
||||
if (sessionRequests === 2) renameSyncRequested.resolve()
|
||||
await releaseSession.promise
|
||||
return json({ data: session })
|
||||
}
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
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: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await sessionRequested.promise
|
||||
events.emit({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
||||
data: { sessionID: "dummy", title: "Generated title" },
|
||||
})
|
||||
await Promise.race([
|
||||
renameSyncRequested.promise,
|
||||
Bun.sleep(2_000).then(() => {
|
||||
throw new Error("rename sync did not start")
|
||||
}),
|
||||
])
|
||||
releaseSession.resolve()
|
||||
await Promise.race([
|
||||
generatedTitle.promise,
|
||||
Bun.sleep(2_000).then(() => {
|
||||
throw new Error("generated title was not shown")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(20)
|
||||
|
||||
const generated = titles.lastIndexOf("OC | Generated title")
|
||||
expect(generated).toBeGreaterThan(-1)
|
||||
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
|
||||
@@ -9,11 +9,7 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
@@ -106,18 +102,11 @@ test("does not preload session summaries into the data context", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("proactively syncs project metadata newest first", async () => {
|
||||
test("proactively syncs project metadata", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/project") return
|
||||
return json([
|
||||
{
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
@@ -156,13 +145,6 @@ test("proactively syncs project metadata newest first", async () => {
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
@@ -1800,19 +1782,12 @@ test("refreshes references after updates", async () => {
|
||||
test("keeps shell state scoped to location", async () => {
|
||||
const events = createEventStream()
|
||||
const other = "/tmp/opencode/other"
|
||||
const workspace = "ws_other"
|
||||
let removed: URL | undefined
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
|
||||
removed = url
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/shell") return
|
||||
const requestDirectory = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: {
|
||||
directory: requestDirectory ?? directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project: { id: "proj_test", directory: requestDirectory ?? directory },
|
||||
},
|
||||
data: [
|
||||
@@ -1823,7 +1798,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
cwd: requestDirectory ?? directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
|
||||
time: { started: 1 },
|
||||
},
|
||||
],
|
||||
@@ -1833,15 +1808,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return (
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</RouteProvider>
|
||||
)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
@@ -1855,31 +1822,19 @@ test("keeps shell state scoped to location", async () => {
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
||||
await data.shell.sync({ directory: other, workspaceID: workspace })
|
||||
await data.shell.sync({ directory: other })
|
||||
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
|
||||
["sh_default", directory],
|
||||
["sh_other", other],
|
||||
])
|
||||
|
||||
await app.waitForFrame((frame) => frame.includes("pnpm dev"))
|
||||
app.mockInput.pressArrow("down")
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
await wait(() => removed !== undefined)
|
||||
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
||||
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
|
||||
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||
|
||||
events.emit({
|
||||
id: "evt_shell_created",
|
||||
created: 0,
|
||||
type: "shell.created",
|
||||
location: { directory: other, workspaceID: workspace },
|
||||
location: { directory: other },
|
||||
data: {
|
||||
info: {
|
||||
id: "sh_live_other",
|
||||
@@ -1888,18 +1843,13 @@ test("keeps shell state scoped to location", async () => {
|
||||
cwd: other,
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/opencode-shell-live",
|
||||
metadata: { sessionID: "ses_shared" },
|
||||
metadata: { sessionID: "ses_other" },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() =>
|
||||
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
|
||||
)
|
||||
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
|
||||
other,
|
||||
)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdtempSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen } from "../../../src/component/dialog-open"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("selecting an unhydrated session preserves its location", async () => {
|
||||
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_remote",
|
||||
projectID: "proj_remote",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Remote session",
|
||||
location: remote,
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Remote session"))
|
||||
expect(fixture.data.session.get("ses_remote")).toBeUndefined()
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
|
||||
expect(fixture.location.ref).toEqual(remote)
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows the current project and opens its root", async () => {
|
||||
const root = "/tmp/opencode/project"
|
||||
const subfolder = `${root}/packages/tui`
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({
|
||||
directory: subfolder,
|
||||
project: { id: "proj_current", directory: root, canonical: root },
|
||||
})
|
||||
return undefined
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: subfolder })
|
||||
location.set({ directory: subfolder })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode") && value.includes("●"))
|
||||
expect(frame).toContain(root)
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
|
||||
expect(fixture.location.ref).toEqual({ directory: root })
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves a moved project when sessions arrive", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_first",
|
||||
canonical: "/tmp/opencode/first",
|
||||
name: "First project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_second",
|
||||
canonical: "/tmp/opencode/second",
|
||||
name: "Second project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_recent",
|
||||
projectID: "proj_first",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 2, updated: 3 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/tmp/opencode/first" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
|
||||
} finally {
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
data: ReturnType<typeof useData>
|
||||
location: ReturnType<typeof useLocation>
|
||||
}) => void | Promise<void>,
|
||||
) {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(handler, events)
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
const dialog = useDialog()
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
onMount(
|
||||
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
return {
|
||||
app,
|
||||
get route() {
|
||||
return route
|
||||
},
|
||||
get location() {
|
||||
return location
|
||||
},
|
||||
get data() {
|
||||
return data
|
||||
},
|
||||
dispose() {
|
||||
app.renderer.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { dialogWidth } from "../../../src/ui/dialog"
|
||||
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
|
||||
import { truncateFilePath } from "../../../src/ui/file-path"
|
||||
import { stringWidth } from "../../../src/util/string-width"
|
||||
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -82,12 +79,7 @@ async function renderSelect(
|
||||
return app
|
||||
}
|
||||
|
||||
async function mountSelect(
|
||||
root: string,
|
||||
initial: DialogSelectOption<string>[],
|
||||
current?: string,
|
||||
focusCurrent?: boolean,
|
||||
) {
|
||||
async function mountSelect(root: string, initial: DialogSelectOption<string>[], current?: string) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const config = createTuiResolvedConfig()
|
||||
@@ -123,7 +115,6 @@ async function mountSelect(
|
||||
title="Mutable options"
|
||||
options={options()}
|
||||
current={current}
|
||||
focusCurrent={focusCurrent}
|
||||
onMove={(option) => moved.push(option.value)}
|
||||
onSelect={(option) => selected.push(option.value)}
|
||||
/>
|
||||
@@ -156,28 +147,6 @@ async function mountSelect(
|
||||
return { app, moved, replaceOptions, selected }
|
||||
}
|
||||
|
||||
test("budgets option content for constrained and full-width large dialogs", () => {
|
||||
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
|
||||
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
|
||||
})
|
||||
|
||||
test("renders the complete truncated footer within the option row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const title = "Project"
|
||||
const footer = truncateFilePath(
|
||||
"/tmp/opencode/projects/a-very-long-project-directory/distinctive-tail.tsx",
|
||||
dialogSelectContentWidth(dialogWidth("medium")) - stringWidth(title),
|
||||
)
|
||||
const select = await mountSelect(tmp.path, [{ title, footer, value: "project" }])
|
||||
|
||||
try {
|
||||
await select.app.waitForFrame((frame) => frame.includes(footer))
|
||||
expect(select.app.captureCharFrame()).toContain(footer)
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renders actions with a current selection", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = await renderSelect(
|
||||
@@ -358,24 +327,3 @@ test("keeps the current option selected when options reorder", async () => {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the first row selected when current is only a marker", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = { title: "project", value: "project" }
|
||||
const select = await mountSelect(tmp.path, [project], "current", false)
|
||||
|
||||
try {
|
||||
select.replaceOptions([
|
||||
{ title: "recent session", value: "recent" },
|
||||
project,
|
||||
{ title: "current session", value: "current" },
|
||||
])
|
||||
await select.app.waitForFrame((frame) => frame.includes("recent session"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["recent"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,8 +103,6 @@ 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
|
||||
|
||||
@@ -258,12 +256,6 @@ 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",
|
||||
|
||||
@@ -2,56 +2,41 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdtempSync, rmSync, watch } from "fs"
|
||||
import { mkdtempSync, rmSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider, useClient } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
|
||||
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||
async function wait(fn: () => boolean, timeout = 2_000) {
|
||||
const start = Date.now()
|
||||
while (!(await fn())) {
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
|
||||
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
||||
async function renderSessionTabs(initialSessionID: string) {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${initialSessionID}`) return
|
||||
return json({
|
||||
data: {
|
||||
id: initialSessionID,
|
||||
title: options?.title,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
const calls = createFetch(undefined, events)
|
||||
let tabs!: ReturnType<typeof useSessionTabs>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
tabs = useSessionTabs()
|
||||
route = useRoute()
|
||||
client = useClient()
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -79,12 +64,11 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
|
||||
return {
|
||||
tabs,
|
||||
route,
|
||||
data,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.destroy()
|
||||
if (!options?.state) rmSync(state, { recursive: true, force: true })
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -104,50 +88,6 @@ test("stores session tabs globally by default", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
|
||||
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
try {
|
||||
titled = await renderSessionTabs("shared", { state, title: "Generated title" })
|
||||
untitled = await renderSessionTabs("shared", { state })
|
||||
const file = path.join(state, "test", "tui", "tabs.json")
|
||||
await titled.data.session.sync("shared")
|
||||
await wait(async () => {
|
||||
if (!(await Bun.file(file).exists())) return false
|
||||
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
|
||||
})
|
||||
const observed = ["Generated title"]
|
||||
const pending = new Set<Promise<void>>()
|
||||
const watcher = watch(path.dirname(file), (_, name) => {
|
||||
if (name !== path.basename(file)) return
|
||||
const read = Bun.file(file)
|
||||
.json()
|
||||
.then((value) => {
|
||||
const title = value.global.tabs[0]?.title
|
||||
if (title && observed.at(-1) !== title) observed.push(title)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => pending.delete(read))
|
||||
pending.add(read)
|
||||
})
|
||||
try {
|
||||
await untitled.data.session.sync("shared")
|
||||
await Bun.sleep(500)
|
||||
} finally {
|
||||
watcher.close()
|
||||
await Promise.allSettled(pending)
|
||||
}
|
||||
|
||||
expect(observed).toEqual(["Generated title"])
|
||||
} finally {
|
||||
titled?.destroy()
|
||||
untitled?.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { lastAssistantWithUsage } from "../../src/util/session"
|
||||
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
@@ -13,6 +13,22 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
})
|
||||
|
||||
describe("util.session", () => {
|
||||
test("recognizes generated parent and child titles", () => {
|
||||
expect(isDefaultTitle(undefined)).toBeTrue()
|
||||
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
|
||||
test("derives display-only titles for untitled sessions", () => {
|
||||
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
|
||||
"New session - 1970-01-01T00:00:00.000Z",
|
||||
)
|
||||
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
|
||||
"Child session - 1970-01-01T00:00:00.000Z",
|
||||
)
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export * as SessionTitleFallback from "./session-title-fallback.js"
|
||||
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
interface Info {
|
||||
readonly title?: string
|
||||
readonly parentID?: string
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplies the timestamped title required by compatibility surfaces. */
|
||||
export function withTimestampedFallback(info: Info) {
|
||||
return info.title ?? fallback(info)
|
||||
}
|
||||
|
||||
/** Supplies a compact human label and collapses historical timestamped fallbacks. */
|
||||
export function displayLabel(info: Pick<Info, "title" | "parentID">) {
|
||||
if (!info.title) return info.parentID ? "Child session" : "New session"
|
||||
return info.title.match(pattern)?.[1] ?? info.title
|
||||
}
|
||||
|
||||
/** Recognizes missing and historical root or child fallback titles. */
|
||||
export function isFallbackTitle(title?: string) {
|
||||
return title === undefined || pattern.test(title)
|
||||
}
|
||||
|
||||
/** Recognizes a missing title or the exact root fallback for this session. */
|
||||
export function isExactRootFallback(info: Pick<Info, "title" | "time">) {
|
||||
return info.title === undefined || info.title === fallback({ time: info.time })
|
||||
}
|
||||
|
||||
function fallback(info: Pick<Info, "parentID" | "time">) {
|
||||
return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}`
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { SessionTitleFallback } from "../src/session-title-fallback.js"
|
||||
|
||||
const root = { title: undefined, time: { created: 0 } }
|
||||
const child = { ...root, parentID: "ses_parent" }
|
||||
|
||||
describe("SessionTitleFallback", () => {
|
||||
test("supplies timestamped compatibility titles", () => {
|
||||
expect(SessionTitleFallback.withTimestampedFallback(root)).toBe("New session - 1970-01-01T00:00:00.000Z")
|
||||
expect(SessionTitleFallback.withTimestampedFallback(child)).toBe("Child session - 1970-01-01T00:00:00.000Z")
|
||||
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "Generated title" })).toBe("Generated title")
|
||||
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "" })).toBe("")
|
||||
})
|
||||
|
||||
test("supplies compact display labels", () => {
|
||||
expect(SessionTitleFallback.displayLabel(root)).toBe("New session")
|
||||
expect(SessionTitleFallback.displayLabel(child)).toBe("Child session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "", parentID: "ses_parent" })).toBe("Child session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "New session - 2026-07-30T18:45:03.662Z" })).toBe("New session")
|
||||
expect(SessionTitleFallback.displayLabel({ title: "Child session - 2026-07-30T18:45:03.662Z" })).toBe(
|
||||
"Child session",
|
||||
)
|
||||
expect(SessionTitleFallback.displayLabel({ title: "Generated title" })).toBe("Generated title")
|
||||
})
|
||||
|
||||
test("recognizes historical fallback titles", () => {
|
||||
expect(SessionTitleFallback.isFallbackTitle(undefined)).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("New session - 2026-07-30T18:45:03.662Z")).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("Child session - 2026-07-30T18:45:03.662Z")).toBeTrue()
|
||||
expect(SessionTitleFallback.isFallbackTitle("")).toBeFalse()
|
||||
expect(SessionTitleFallback.isFallbackTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
|
||||
test("recognizes only the fallback associated with a session", () => {
|
||||
expect(SessionTitleFallback.isExactRootFallback(root)).toBeTrue()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 1970-01-01T00:00:00.000Z" }),
|
||||
).toBeTrue()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 2099-01-01T00:00:00.000Z" }),
|
||||
).toBeFalse()
|
||||
expect(
|
||||
SessionTitleFallback.isExactRootFallback({
|
||||
...child,
|
||||
title: "Child session - 1970-01-01T00:00:00.000Z",
|
||||
}),
|
||||
).toBeFalse()
|
||||
})
|
||||
})
|
||||
@@ -111,7 +111,7 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"media": {
|
||||
"attachments": {
|
||||
"image": {
|
||||
"auto_resize": true,
|
||||
"max_width": 2000,
|
||||
|
||||
@@ -95,7 +95,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
|
||||
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
|
||||
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
|
||||
| `buffer` | `20000` | Token reserve used by the automatic threshold. The requested model output allowance wins when it is larger. |
|
||||
|
||||
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
|
||||
preserves more recent detail but leaves less room for future work. Larger
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Media
|
||||
### Attachments
|
||||
|
||||
Control how oversized images loaded by the `read` tool are resized or rejected
|
||||
before they are sent to a model.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"media": {
|
||||
"attachments": {
|
||||
"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 }
|
||||
```
|
||||
|
||||
### Media
|
||||
### Attachments
|
||||
|
||||
Rename the singular `attachment` object to `media`. Nested image settings keep the same names:
|
||||
Rename the singular `attachment` object to `attachments`. Nested image settings keep the same names:
|
||||
|
||||
```jsonc
|
||||
// V1
|
||||
{ "attachment": { "image": { "auto_resize": true } } }
|
||||
|
||||
// V2
|
||||
{ "media": { "image": { "auto_resize": true } } }
|
||||
{ "attachments": { "image": { "auto_resize": true } } }
|
||||
```
|
||||
|
||||
### MCP servers
|
||||
|
||||
Reference in New Issue
Block a user