mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 22:23:18 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ef5758b8c | |||
| 65752f0947 | |||
| 0ebce26501 | |||
| ef386bc57f | |||
| 3b002e9602 | |||
| 241c413cd2 | |||
| f265aa1222 | |||
| 0df11e0f5b | |||
| 2135ab50d5 | |||
| 771c0f5850 | |||
| b1a0ef91bb | |||
| 2e67cee75b | |||
| 890735c1d7 | |||
| 049f0b0c3b | |||
| 6020f36862 | |||
| 0d24ebdbbe | |||
| b22c182406 | |||
| c7f2f367e3 | |||
| ccd2135e7a | |||
| c7c22b9d7e | |||
| 67e99993f5 | |||
| be6abc02b5 | |||
| e012a57d1d | |||
| 697e3e13cc |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
|
||||
@@ -41,6 +41,7 @@ const SSE_EVENTS = new Set([
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
@@ -1004,11 +1005,13 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
})
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
}),
|
||||
)
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -41,6 +41,13 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||
const omitsFunctionCallIds = (modelID: string) => {
|
||||
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -75,10 +82,15 @@ export type ProviderOptionsInput = OptionsInput
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
// Gemini is known to send explicit `null` for optional streaming fields
|
||||
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||
// so the shared schemas stay safe there.
|
||||
const GeminiTextPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
thought: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -91,11 +103,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
id: optionalNull(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -115,8 +127,8 @@ const GeminiContentPart = Schema.Union([
|
||||
])
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
@@ -179,33 +191,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
content: optionalNull(GeminiContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
@@ -217,6 +229,7 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -274,20 +287,14 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
@@ -303,8 +310,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const previous = contents.at(-1)
|
||||
// Gemini rejects a continuation whose function-response turn carries extra
|
||||
// parts, so an update after a tool result starts its own user turn.
|
||||
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
||||
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
}
|
||||
@@ -336,7 +343,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part)
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -361,7 +368,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -382,7 +389,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
id: functionCallId(part.providerMetadata),
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -395,8 +402,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
@@ -486,21 +493,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
const promptTokens = usage.promptTokenCount ?? undefined
|
||||
const cached = usage.cachedContentTokenCount ?? undefined
|
||||
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||
const visible = usage.candidatesTokenCount ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
inputTokens: promptTokens,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
@@ -535,7 +546,10 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||
// the "nothing to finish" check below keeps its meaning.
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
@@ -581,8 +595,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
for (const part of candidate.content.parts ?? []) {
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
@@ -618,13 +634,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
@@ -637,7 +654,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -651,6 +669,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -428,7 +428,7 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
...(url ? { file_url: url } : { file_data: media.base64 }),
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
@@ -580,8 +580,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id))
|
||||
input.push({ type: "item_reference", id })
|
||||
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
@@ -1126,7 +1125,8 @@ export const step = (state: ParserState, event: Event) => {
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
if (!event.item_id || typeof value !== "string")
|
||||
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
|
||||
@@ -156,6 +156,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -204,11 +207,16 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
@@ -509,6 +517,17 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||
// whenever the conversation history contains tool calls/results. Send an
|
||||
// explicit empty array when we have history but no active tools.
|
||||
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool") return true
|
||||
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
return {
|
||||
@@ -532,12 +551,15 @@ 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"
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
@@ -581,11 +603,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cached =
|
||||
(usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -691,8 +720,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
|
||||
@@ -134,13 +134,12 @@ const HOSTED_TOOLS = {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
|
||||
@@ -28,7 +28,11 @@ export const ResponseIncludables = [
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -38,7 +42,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -38,13 +38,23 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, labels }
|
||||
return { ...body, contents, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,10 +4,4 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
@@ -8,6 +8,8 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { Azure } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
|
||||
|
||||
const chatModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).chat("gpt-5.6-luna")
|
||||
|
||||
const responsesModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).responses("gpt-5.6-luna")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "azure",
|
||||
provider: "azure",
|
||||
requires: ["AZURE_OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Azure OpenAI recorded", () => {
|
||||
recorded.effect("chat streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("bonjour")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -156,14 +156,13 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -201,8 +200,8 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -210,14 +209,12 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
@@ -228,6 +225,104 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers function call ids for gemini 3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
]
|
||||
const legacy = yield* compileRequest(LLM.request({ model, messages }))
|
||||
const older = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-1.5-flash" }),
|
||||
messages,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(legacy.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
|
||||
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes function call ids for non-gemini model ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemma-3-27b-it" }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -419,13 +514,16 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
@@ -849,7 +947,7 @@ describe("Gemini route", () => {
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call",
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
@@ -857,7 +955,7 @@ describe("Gemini route", () => {
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
@@ -873,7 +971,6 @@ describe("Gemini route", () => {
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -977,7 +1074,7 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -987,7 +1084,7 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -1023,15 +1120,15 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
@@ -1059,11 +1156,11 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1214,7 +1311,6 @@ describe("Gemini route", () => {
|
||||
id: "call_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "call_0" } },
|
||||
})
|
||||
expect(response.toolCalls[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
@@ -1230,6 +1326,37 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
id: "dup_call",
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
@@ -1365,6 +1492,73 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives explicit null usage counts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
|
||||
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
|
||||
expect(response.usage?.inputTokens).toBeUndefined()
|
||||
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
|
||||
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
|
||||
expect(response.usage?.reasoningTokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives null candidates, content, parts, and finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: null },
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null thought flag on a text part as visible output", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
|
||||
|
||||
expect(reasoningStart).toBeUndefined()
|
||||
expect(response.reasoning ?? "").toBe("")
|
||||
expect(response.text).toBe("Visible")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails invalid stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = GoogleVertex.configure({
|
||||
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.5-flash")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_VERTEX_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Vertex Gemini recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -75,6 +75,53 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||
expect(prepared.body.contents).toMatchObject([
|
||||
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -342,6 +342,7 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||
],
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -78,6 +78,45 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
|
||||
},
|
||||
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -114,7 +153,12 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }], phase: null },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
@@ -189,6 +233,7 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -213,6 +258,7 @@ describe("Open Responses-compatible route", () => {
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
service_tier: "provider-tier",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
|
||||
@@ -188,13 +188,11 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1273,7 +1271,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1300,7 +1298,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: base64 },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1333,7 +1331,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1358,7 +1356,7 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "AAECAw==" },
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2652,6 +2650,47 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "computer_call",
|
||||
id: "computer_1",
|
||||
call_id: "call_1",
|
||||
status: "completed",
|
||||
action: { type: "click", x: 100, y: 200 },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
input: { type: "click", x: 100, y: 200 },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
@@ -2765,7 +2804,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2796,7 +2835,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2821,7 +2860,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "AAECAw==",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("hello"),
|
||||
})
|
||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ tool },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
|
||||
@@ -179,6 +179,7 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
}
|
||||
|
||||
async function expectSideGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
|
||||
@@ -188,15 +189,20 @@ async function expectSideGeometry(page: Page) {
|
||||
terminalLeft: terminal.left,
|
||||
terminalRight: terminal.right,
|
||||
terminalTop: terminal.top,
|
||||
terminalBottom: terminal.bottom,
|
||||
reviewTop: review.top,
|
||||
reviewBottom: review.bottom,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectBottomGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
@@ -226,6 +232,30 @@ async function expectBottomGeometry(page: Page) {
|
||||
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
|
||||
}
|
||||
|
||||
async function expectPanelGap(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
const gap = terminal.top - review.bottom
|
||||
return gap
|
||||
})
|
||||
})
|
||||
.toBeGreaterThanOrEqual(expected - 1)
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
})
|
||||
})
|
||||
.toBeLessThanOrEqual(expected + 1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -243,7 +243,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -42,6 +42,16 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/animation.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
@@ -94,24 +104,454 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectWidthMotions(page, 1)
|
||||
await expectReviewWidthStable(page)
|
||||
await expectLogicalSideAlignment(page, "ltr")
|
||||
await page.evaluate(() => (document.documentElement.dir = "rtl"))
|
||||
await expectLogicalSideAlignment(page, "rtl")
|
||||
await page.evaluate(() => (document.documentElement.dir = "ltr"))
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
const terminalContent = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expectHeightMotions(page, "session-side-region", 1)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 1)
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
const reviewContent = page.locator('[data-component="session-review-v2"]')
|
||||
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectHeightMotions(page, "session-side-region", 3)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 3)
|
||||
|
||||
await resetTerminalContentSizes(page)
|
||||
await resetPanelGaps(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
|
||||
await expectHeightMotions(page, "session-side-region", 4)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 4)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectTerminalContentCachedSize(page)
|
||||
await expectStackPainted(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
|
||||
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expectWidthMotions(page, 2)
|
||||
await expectSideSlideSettled(page, 2)
|
||||
await expectHiddenSideAligned(page)
|
||||
|
||||
await resetHeightMotions(page)
|
||||
await resetHorizontalScrolls(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expectWidthMotions(page, 3)
|
||||
await expectSideSlideSettled(page, 3)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 4)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 5)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
|
||||
)
|
||||
})
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectAnimation(page, "terminal-panel-size-in")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expectAnimation(page, "terminal-panel-size-out")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
|
||||
})
|
||||
|
||||
type MotionProbe = {
|
||||
widths: number
|
||||
widthEnds: number
|
||||
horizontalScrolls: number[]
|
||||
reviewWidths: number[]
|
||||
paintGaps: { review: number; terminalSurface: number }[]
|
||||
terminalContentSizes: { width: number; height: number }[]
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
}
|
||||
|
||||
async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
}
|
||||
const observed = new WeakSet<Element>()
|
||||
const observers: ResizeObserver[] = []
|
||||
const observeReview = () => {
|
||||
const review = document.querySelector('[data-component="session-review-v2"]')
|
||||
if (!review || observed.has(review)) return
|
||||
observed.add(review)
|
||||
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
|
||||
observer.observe(review)
|
||||
observers.push(observer)
|
||||
}
|
||||
const observedRegions = new WeakSet<Element>()
|
||||
const observeStack = () => {
|
||||
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
|
||||
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
|
||||
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
|
||||
observedRegions.add(reviewRegion)
|
||||
const observer = new ResizeObserver(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
height: terminalContent.getBoundingClientRect().height,
|
||||
})
|
||||
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
|
||||
if (probe.resetAnchorOnMotion) {
|
||||
if (anchorGap > 8) return
|
||||
probe.terminalAnchorGaps = []
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
|
||||
terminalSurface: Math.abs(
|
||||
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
|
||||
),
|
||||
})
|
||||
})
|
||||
observer.observe(reviewRegion)
|
||||
observer.observe(terminalRegion)
|
||||
observers.push(observer)
|
||||
}
|
||||
new MutationObserver(() => {
|
||||
observeReview()
|
||||
observeStack()
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
observeReview()
|
||||
observeStack()
|
||||
document.addEventListener("transitionrun", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const slot = event.target.getAttribute("data-slot")
|
||||
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
|
||||
if (event.propertyName === "height" && slot) {
|
||||
probe.heights.push(slot)
|
||||
}
|
||||
})
|
||||
document.addEventListener("transitionend", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
|
||||
probe.widthEnds++
|
||||
})
|
||||
document.addEventListener("animationstart", (event) => {
|
||||
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
|
||||
probe.animations.push(event.animationName)
|
||||
})
|
||||
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
|
||||
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
|
||||
})
|
||||
}
|
||||
|
||||
async function expectWidthMotions(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function resetHeightMotions(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.heights = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
const heights = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
|
||||
)
|
||||
expect(heights).toEqual([])
|
||||
}
|
||||
|
||||
async function expectHiddenSideAligned(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
|
||||
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
|
||||
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
|
||||
const row = chat.parentElement.getBoundingClientRect()
|
||||
const hidden = side.getBoundingClientRect()
|
||||
return Math.max(
|
||||
Math.abs(row.top - hidden.top),
|
||||
Math.abs(row.right - hidden.right),
|
||||
Math.abs(row.bottom - hidden.bottom),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetHorizontalScrolls(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.horizontalScrolls = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: Page) {
|
||||
const scrolls = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
|
||||
)
|
||||
expect(Math.max(0, ...scrolls)).toBe(0)
|
||||
expect(await page.evaluate(() => window.scrollX)).toBe(0)
|
||||
}
|
||||
|
||||
async function expectReviewWidthStable(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
const widths = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
|
||||
)
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
}
|
||||
|
||||
async function expectStackedGeometry(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(9)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(7)
|
||||
}
|
||||
|
||||
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((direction) => {
|
||||
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
|
||||
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
|
||||
if (!frame || !content) return Number.POSITIVE_INFINITY
|
||||
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
|
||||
}, direction),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectStackPainted(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalBottoms = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalBottomFixed(page: Page) {
|
||||
const bottoms = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
|
||||
)
|
||||
expect(bottoms.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalAnchorGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.resetAnchorOnMotion = true
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPanelGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.panelGaps = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectPanelGapHeld(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
|
||||
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectTerminalTopAnchored(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
|
||||
}
|
||||
|
||||
async function resetTerminalContentSizes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalContentSizes = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalContentCachedSize(page: Page) {
|
||||
const sizes = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
|
||||
)
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(slot) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
|
||||
.length ?? 0,
|
||||
slot,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectAnimation(page: Page, name: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(name) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
|
||||
name,
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,133 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="true"] {
|
||||
animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="false"] {
|
||||
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
|
||||
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
|
||||
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-out {
|
||||
from {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -458,8 +458,7 @@ export const dict = {
|
||||
"dialog.project.edit.color": "Color",
|
||||
"dialog.project.edit.color.select": "Select {{color}} color",
|
||||
"dialog.project.edit.worktree.startup": "Workspace startup script",
|
||||
"dialog.project.edit.worktree.startup.description":
|
||||
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
|
||||
|
||||
"dialog.releaseNotes.action.getStarted": "Get started",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
|
||||
"md:max-w-[1000px] md:mx-auto": controller.centered(),
|
||||
}}
|
||||
>
|
||||
<Show when={controller.state.questionRequest()} keyed>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SessionSidePanel(props: {
|
||||
fileBrowserState: SessionFileBrowserState
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
}) {
|
||||
@@ -79,6 +79,7 @@ export function SessionSidePanel(props: {
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent)
|
||||
const fileOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
@@ -88,11 +89,12 @@ export function SessionSidePanel(props: {
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const visible = createMemo(() => reviewVisible() || fileOpen())
|
||||
const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width()))
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!open()) return "0px"
|
||||
if (reviewOpen()) return "auto"
|
||||
if (!visible()) return "0px"
|
||||
if (reviewVisible()) return "auto"
|
||||
return `${fileTreeWidth()}px`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
@@ -252,14 +254,14 @@ export function SessionSidePanel(props: {
|
||||
"h-full min-h-0": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
"flex-1": reviewOpen(),
|
||||
!props.size.active(),
|
||||
"flex-1": reviewVisible(),
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={open()}>
|
||||
<Show when={visible()}>
|
||||
<div class="size-full flex">
|
||||
<Show when={reviewOpen()}>
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
<DragDropProvider
|
||||
|
||||
@@ -101,11 +101,11 @@ export const focusTerminalById = (id: string) => {
|
||||
|
||||
const textarea = terminal.querySelector("textarea")
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
textarea.focus()
|
||||
textarea.focus({ preventScroll: true })
|
||||
return true
|
||||
}
|
||||
|
||||
terminal.focus()
|
||||
terminal.focus({ preventScroll: true })
|
||||
terminal.dispatchEvent(
|
||||
typeof PointerEvent === "function"
|
||||
? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
|
||||
|
||||
@@ -124,11 +124,12 @@ export function createSessionReview(input: {
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()],
|
||||
})
|
||||
}, 100)
|
||||
onCleanup(
|
||||
location().event.listen((event) => {
|
||||
createEffect(() => {
|
||||
const stop = location().event.listen((event) => {
|
||||
if (event.type === "filesystem.changed") refresh()
|
||||
}),
|
||||
)
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => input.screen.review.open() || mobileChanges(),
|
||||
|
||||
@@ -58,7 +58,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel }) {
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -79,7 +79,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel }) {
|
||||
fileBrowserState={props.review.panelState}
|
||||
activeDiff={props.review.activeFile()}
|
||||
focusReviewDiff={props.review.focusFile}
|
||||
reviewSnap={props.review.screen.review.snap()}
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
/>
|
||||
|
||||
@@ -2,12 +2,11 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { useNotification } from "@/shell/notifications/notification"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
@@ -86,7 +85,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value()}>
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
@@ -114,23 +113,18 @@ function SessionStatePanel(props: ParentProps) {
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
|
||||
return (
|
||||
// Keep workspace-scoped file, prompt, comment, and terminal state alive when
|
||||
// the user switches between Sessions in the same workspace.
|
||||
<Show when={`${server.scope}\0${location().directory}`} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
// These providers select their scoped state reactively and retain bounded caches,
|
||||
// so keep their owners alive while navigating between workspaces on this server.
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createComputed, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -14,11 +15,9 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
|
||||
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
|
||||
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
|
||||
const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen())
|
||||
const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminalOpen = createMemo(
|
||||
() => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom",
|
||||
)
|
||||
const sideTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "side")
|
||||
const bottomTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "bottom")
|
||||
const sideTerminalOpen = createMemo(() => terminalOpen() && sideTerminal())
|
||||
const fileTreeOpen = createMemo(
|
||||
() =>
|
||||
session.isDesktop() &&
|
||||
@@ -29,14 +28,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
)
|
||||
const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen())
|
||||
const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen())
|
||||
const [rowWidth, setRowWidth] = createSignal<number>()
|
||||
const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({})
|
||||
let row: HTMLDivElement | undefined
|
||||
createResizeObserver(
|
||||
() => row,
|
||||
({ width }) => setRowWidth(width),
|
||||
({ width, height }) => setRowSize({ width, height }),
|
||||
)
|
||||
const available = createMemo<number | undefined>(() => {
|
||||
const width = rowWidth()
|
||||
const width = rowSize.width
|
||||
if (width === undefined) return undefined
|
||||
return width - 8
|
||||
})
|
||||
@@ -65,24 +64,30 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
files: fileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
const [reviewSnap, setReviewSnap] = createSignal(false)
|
||||
let reviewFrame: number | undefined
|
||||
createComputed((previous) => {
|
||||
const open = reviewOpen()
|
||||
if (previous === undefined || previous === open) return open
|
||||
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
setReviewSnap(true)
|
||||
reviewFrame = requestAnimationFrame(() => {
|
||||
reviewFrame = undefined
|
||||
setReviewSnap(false)
|
||||
})
|
||||
return open
|
||||
}, reviewOpen())
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
|
||||
createEffect((previous) => {
|
||||
const stacked = panelLayout().stacked
|
||||
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
|
||||
return stacked
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
|
||||
const sideHeight = createMemo(() => rowSize.height)
|
||||
const fullSideHeight = createMemo(() => (sideHeight() === undefined ? "100%" : `${sideHeight()}px`))
|
||||
const stackedReviewHeight = createMemo(() => {
|
||||
const height = sideHeight()
|
||||
if (height === undefined) return `calc(100% - ${terminalPaneHeight()} - 8px)`
|
||||
return `${Math.max(0, height - terminalPane() - 8)}px`
|
||||
})
|
||||
|
||||
const sideContentWidth = createMemo<string>((previous) => {
|
||||
const width = available()
|
||||
if (resizable() && width !== undefined) return `${Math.max(0, width - resizedWidth())}px`
|
||||
if (fileTreeOpen()) return `${layout.fileTree.width()}px`
|
||||
return previous
|
||||
}, "100%")
|
||||
return {
|
||||
centered: createMemo(() => session.isDesktop()),
|
||||
files: { open: fileTreeOpen },
|
||||
@@ -99,14 +104,36 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
|
||||
review: {
|
||||
open: reviewOpen,
|
||||
panelOpen: reviewPanelOpen,
|
||||
snap: reviewSnap,
|
||||
},
|
||||
side: { layout: panelLayout },
|
||||
side: {
|
||||
contentWidth: sideContentWidth,
|
||||
gap: {
|
||||
closing: () => motion.closing,
|
||||
height: createMemo(() => (motion.gap ? "8px" : "0px")),
|
||||
},
|
||||
layout: panelLayout,
|
||||
region: {
|
||||
height: createMemo(() => {
|
||||
if (!sideRegionOpen()) return "0px"
|
||||
if (sideTerminalOpen()) return stackedReviewHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
open: sideRegionOpen,
|
||||
},
|
||||
terminal: {
|
||||
contentHeight: createMemo(() => (sideRegionOpen() ? terminalPaneHeight() : fullSideHeight())),
|
||||
height: createMemo(() => {
|
||||
if (!sideTerminalOpen()) return "0px"
|
||||
if (sideRegionOpen()) return terminalPaneHeight()
|
||||
return fullSideHeight()
|
||||
}),
|
||||
},
|
||||
},
|
||||
size,
|
||||
terminal: {
|
||||
bottomOpen: bottomTerminalOpen,
|
||||
inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()),
|
||||
bottom: bottomTerminal,
|
||||
open: terminalOpen,
|
||||
side: sideTerminal,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
@@ -28,7 +29,42 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const screen = createSessionScreenLayout(session, serverSDK.scope)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const messagesReady = timeline.ready
|
||||
const [store, setStore] = createStore({ deferRender: false })
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
bottomTerminalCached: false,
|
||||
sideHeightMotion: false,
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
sideTerminalPresent: false,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
bottomTerminal?: HTMLDivElement
|
||||
}>({})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
})
|
||||
const bottomTerminalPresence = createPresence({
|
||||
show: bottomTerminalVisible,
|
||||
element: () => elements.bottomTerminal ?? null,
|
||||
})
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
if (!sideVisible()) setStore("sideHeightMotion", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!isDesktop() || screen.terminal.bottom()) setStore("sideTerminalPresent", false)
|
||||
if (isDesktop() && screen.terminal.side()) setStore("bottomTerminalCached", false)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (screen.side.region.open()) setStore("sideRegionPresent", true)
|
||||
if (screen.review.panelOpen()) setStore("sideReviewPresent", true)
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = session.identity.sessionKey()
|
||||
@@ -75,15 +111,9 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<SessionIdentityHeader
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
session={session.data.info()}
|
||||
/>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show
|
||||
when={messagesReady() ? session.identity.params.id : undefined}
|
||||
keyed
|
||||
>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
session={session}
|
||||
@@ -135,13 +165,14 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && !screen.review.snap() && !screen.terminal.inlineOnlyOpen(),
|
||||
!screen.size.active(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
@@ -171,51 +202,125 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isDesktop() && screen.side.layout().visible}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={screen.review.panelOpen() || screen.files.open()}>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionDesktopReview review={review} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={screen.terminal.open() && !screen.terminal.bottomOpen()}>
|
||||
<Show when={sidePresence.present() || store.sideTerminalPresent}>
|
||||
<div
|
||||
ref={(element) => setElements("side", element)}
|
||||
data-slot="session-side-panel-presence"
|
||||
data-opened={sideVisible()}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
setStore("sideHeightMotion", true)
|
||||
}}
|
||||
classList={{
|
||||
"relative z-0 min-w-0 h-full flex-1 overflow-visible": sidePresence.present(),
|
||||
"absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible":
|
||||
!sidePresence.present(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-panel-content"
|
||||
class="absolute inset-y-0 start-0 h-full"
|
||||
style={{ width: screen.side.contentWidth() }}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-region"
|
||||
classList={{
|
||||
"min-h-0 shrink-0": screen.side.layout().stacked,
|
||||
"min-h-0 flex-1": !screen.side.layout().stacked,
|
||||
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<TerminalPanel stacked={screen.side.layout().stacked} />
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
data-opened={screen.side.region.open()}
|
||||
class="absolute inset-0"
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "side-region-presence-out") return
|
||||
if (screen.side.region.open()) return
|
||||
if (sideTerminalVisible()) return
|
||||
setStore("sideRegionPresent", false)
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="absolute inset-x-0 bottom-0 flex flex-col">
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"delay-0": !screen.side.gap.closing(),
|
||||
"delay-[200ms]": screen.side.gap.closing(),
|
||||
}}
|
||||
style={{ height: screen.side.gap.height() }}
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<Show when={screen.side.layout().stacked}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-side-terminal-region"
|
||||
classList={{
|
||||
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.terminal.height() }}
|
||||
>
|
||||
<Show when={store.sideTerminalPresent}>
|
||||
<div
|
||||
data-slot="side-terminal-panel-presence"
|
||||
data-opened={sideTerminalVisible()}
|
||||
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
|
||||
<TerminalPanel
|
||||
fill
|
||||
framed={false}
|
||||
present={store.sideTerminalPresent}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={screen.terminal.open() && (!isDesktop() || screen.terminal.bottomOpen())}>
|
||||
<div classList={{ "relative min-h-0 shrink-0": isDesktop() }}>
|
||||
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
data-opened={bottomTerminalVisible()}
|
||||
classList={{
|
||||
hidden: !bottomTerminalPresence.present(),
|
||||
"relative min-h-0 shrink-0": isDesktop(),
|
||||
}}
|
||||
>
|
||||
<Show when={isDesktop()}>
|
||||
<div
|
||||
class="absolute z-10 -top-1 left-0 right-0 h-2"
|
||||
onPointerDown={() => screen.size.start()}
|
||||
>
|
||||
<div class="absolute z-10 -top-1 left-0 right-0 h-2" onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
@@ -231,7 +336,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={isDesktop()} />
|
||||
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Terminal } from "@/session/terminal/terminal"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTerminal } from "@/session/terminal/context"
|
||||
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { terminalTabLabel } from "@/session/terminal/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/session/helpers"
|
||||
@@ -26,7 +26,20 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { TerminalSurface } from "./surface"
|
||||
|
||||
export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
const MAX_CACHED_TERMINAL_WORKSPACES = 20
|
||||
|
||||
type TerminalBinding = ReturnType<ReturnType<typeof useTerminal>["bind"]>
|
||||
type CachedTerminalSurface = {
|
||||
key: string
|
||||
workspace: string
|
||||
pty: LocalPTY
|
||||
ops: TerminalBinding
|
||||
focus: boolean
|
||||
}
|
||||
|
||||
export function TerminalPanel(
|
||||
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
@@ -45,18 +58,26 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
onCleanup(() => terminal.cancelFocus())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
autoCreated: undefined as string | undefined,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
surfaces: [] as CachedTerminalSurface[],
|
||||
workspaces: [] as string[],
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
const stacked = createMemo(() => isDesktop() && !!props.stacked)
|
||||
const panelHeight = createMemo(() =>
|
||||
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
|
||||
const panelHeight = createMemo(() => {
|
||||
if (props.fill) return "100%"
|
||||
if (!opened()) return "0px"
|
||||
if (isDesktop()) return stacked() ? `${pane()}px` : "100%"
|
||||
return `${pane()}px`
|
||||
})
|
||||
const contentHeight = createMemo(
|
||||
() => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`),
|
||||
)
|
||||
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||
const present = createMemo(() => opened() || !!props.present)
|
||||
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||
|
||||
onMount(() => {
|
||||
@@ -68,24 +89,29 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
makeEventListener(document, "focusin", (event) => {
|
||||
if (event.target instanceof Element && event.target.closest("#terminal-panel")) return
|
||||
setStore("surfaces", (surface) => surface.focus, "focus", false)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) {
|
||||
setStore("autoCreated", false)
|
||||
setStore("autoCreated", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||
const workspace = workspaceKey()
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated === workspace) return
|
||||
terminal.new()
|
||||
setStore("autoCreated", true)
|
||||
setStore("autoCreated", workspace)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
() => [workspaceKey(), terminal.all().length] as const,
|
||||
([workspace, count], previous) => {
|
||||
if (!previous || previous[0] !== workspace || previous[1] <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
@@ -97,7 +123,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||
([next, id, requested]) => {
|
||||
if (!next || !id || !requested) return
|
||||
focusTerminalById(id)
|
||||
requestAnimationFrame(() => {
|
||||
if (!opened() || terminal.active() !== id || !terminal.focusRequested(id)) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -136,19 +165,44 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
|
||||
const all = terminal.all
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [workspaceKey(), terminal.ready(), terminal.active(), terminal.all()] as const,
|
||||
([workspace, ready, active, ptys]) => {
|
||||
if (!ready) return
|
||||
|
||||
const ids = new Set(ptys.map((pty) => pty.id))
|
||||
const surfaces = store.surfaces.filter((surface) => surface.workspace !== workspace || ids.has(surface.pty.id))
|
||||
const pty = ptys.find((item) => item.id === active)
|
||||
const key = pty ? `${workspace}\0${pty.id}` : undefined
|
||||
if (pty && key && !surfaces.some((surface) => surface.key === key)) {
|
||||
surfaces.push({ key, workspace, pty, ops: terminal.bind(), focus: terminal.focusRequested(pty.id) })
|
||||
}
|
||||
|
||||
const workspaces = [...store.workspaces.filter((item) => item !== workspace), workspace].slice(
|
||||
-MAX_CACHED_TERMINAL_WORKSPACES,
|
||||
)
|
||||
const keep = new Set(workspaces)
|
||||
setStore({ surfaces: surfaces.filter((surface) => keep.has(surface.workspace)), workspaces })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
const index = store.surfaces.findIndex((surface) => surface.key === key)
|
||||
if (!store.surfaces[index]?.focus) return
|
||||
setStore("surfaces", index, "focus", false)
|
||||
if (!opened() || terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
terminal.consumeFocus(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
@@ -167,6 +221,8 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
}}
|
||||
label={language.t("terminal.title")}
|
||||
opened={opened()}
|
||||
present={present()}
|
||||
framed={props.framed}
|
||||
desktop={isDesktop()}
|
||||
stacked={stacked()}
|
||||
height={panelHeight()}
|
||||
@@ -182,7 +238,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
onCollapse={close}
|
||||
>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
when={terminal.ready() || store.surfaces.length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
|
||||
@@ -268,34 +324,35 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={terminal.focusRequested(id)}
|
||||
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) =>
|
||||
ops.trim(terminalID),
|
||||
)
|
||||
}
|
||||
onCleanup={(terminal) => ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={store.surfaces}>
|
||||
{(surface) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${surface.pty.id}`}
|
||||
class="absolute inset-0"
|
||||
classList={{
|
||||
hidden:
|
||||
!present() || surface.workspace !== workspaceKey() || surface.pty.id !== terminal.active(),
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
pty={surface.pty}
|
||||
autoFocus={terminal.focusRequested(surface.pty.id)}
|
||||
onAutoFocus={() => {
|
||||
focusTerminalById(surface.pty.id)
|
||||
terminal.consumeFocus(surface.pty.id)
|
||||
}}
|
||||
class="!px-[14px]"
|
||||
onConnect={() =>
|
||||
markTerminalConnected(surface.key, surface.pty.id, (terminalID) => surface.ops.trim(terminalID))
|
||||
}
|
||||
onCleanup={(terminal) => surface.ops.update(terminal)}
|
||||
onConnectError={() =>
|
||||
recoverTerminal(surface.key, surface.pty.id, (terminalID) => surface.ops.clone(terminalID))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
@@ -5,6 +5,8 @@ export function TerminalSurface(
|
||||
props: ParentProps<{
|
||||
label: string
|
||||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
@@ -22,6 +24,9 @@ export function TerminalSurface(
|
||||
<aside
|
||||
ref={props.ref}
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
@@ -29,13 +34,12 @@ export function TerminalSurface(
|
||||
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"w-full": !props.desktop || props.stacked,
|
||||
"min-w-0 h-full flex-1": props.desktop && props.opened && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !props.opened,
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop,
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!props.desktop && !props.resizing,
|
||||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
|
||||
}}
|
||||
style={{ height: props.height }}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
|
||||
<ResizeHandle
|
||||
@@ -50,7 +54,8 @@ export function TerminalSurface(
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop,
|
||||
"pointer-events-none": !props.opened,
|
||||
|
||||
@@ -347,9 +347,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
t.focus()
|
||||
t.textarea?.focus()
|
||||
setTimeout(() => t.textarea?.focus(), 0)
|
||||
const focus = () => (t.textarea ? t.textarea.focus({ preventScroll: true }) : t.focus())
|
||||
focus()
|
||||
setTimeout(focus, 0)
|
||||
}
|
||||
const handlePointerDown = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
@@ -499,7 +499,7 @@ function MessageTimelineView(
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -8,7 +9,7 @@ import { type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const supported = true
|
||||
const supported = !props.project.id || props.project.id === "global"
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
@@ -71,14 +72,9 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
|
||||
commands: { start },
|
||||
})
|
||||
dialog.close()
|
||||
return
|
||||
// TODO: Restore project edits when the V2 client exposes a project update API.
|
||||
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
|
||||
throw new Error(`Project ${props.project.id} cannot be updated`)
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
|
||||
@@ -225,20 +225,23 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
},
|
||||
)
|
||||
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
node: tree.node,
|
||||
isDirLoaded: tree.isLoaded,
|
||||
refreshDir: (dir) => {
|
||||
void tree.listDir(dir, { force: true })
|
||||
},
|
||||
createEffect(() => {
|
||||
const stop = sdk().event.on("filesystem.changed", (event) => {
|
||||
invalidateFromWatcher(event, {
|
||||
normalize: path.normalize,
|
||||
hasFile: (file) => Boolean(store.file[file]),
|
||||
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
|
||||
loadFile: (file) => {
|
||||
void load(file, { force: true })
|
||||
},
|
||||
node: tree.node,
|
||||
isDirLoaded: tree.isLoaded,
|
||||
refreshDir: (dir) => {
|
||||
void tree.listDir(dir, { force: true })
|
||||
},
|
||||
})
|
||||
})
|
||||
onCleanup(stop)
|
||||
})
|
||||
|
||||
const get = (input: string) => {
|
||||
@@ -266,7 +269,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
withPath(input, (file) => view().setSelectedLines(file, range))
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
viewCache.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -1291,15 +1291,6 @@ export interface CredentialApi<E = never> {
|
||||
export type ProjectListOutput = ReadonlyArray<Project.Info>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly name?: string | undefined
|
||||
readonly icon?: Project.Icon | undefined
|
||||
readonly commands?: Project.Commands | undefined
|
||||
}
|
||||
export type ProjectUpdateOutput = Project.Info
|
||||
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1308,7 +1299,6 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly update: ProjectUpdateOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -139,8 +139,6 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -921,14 +919,6 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
|
||||
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
|
||||
preserveEffect<ProjectUpdateOutput>()(
|
||||
raw["project.update"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
|
||||
preserveEffect<ProjectCurrentOutput>()(
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -936,7 +926,6 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
|
||||
|
||||
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
|
||||
list: EndpointProjectList(raw),
|
||||
update: EndpointProjectUpdate(raw),
|
||||
current: EndpointProjectCurrent(raw),
|
||||
})
|
||||
|
||||
|
||||
@@ -133,8 +133,6 @@ import type {
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
@@ -1253,18 +1251,6 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectUpdateOutput>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}`,
|
||||
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCurrentOutput>(
|
||||
{
|
||||
|
||||
@@ -2269,14 +2269,6 @@ export type McpServerNotFoundError = {
|
||||
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
|
||||
|
||||
export type ProjectNotFoundError = {
|
||||
readonly _tag: "ProjectNotFoundError"
|
||||
readonly projectID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
|
||||
|
||||
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
|
||||
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
|
||||
@@ -4387,27 +4379,6 @@ export type CredentialRemoveOutput = void
|
||||
|
||||
export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly name?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["name"]
|
||||
readonly icon?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["icon"]
|
||||
readonly commands?: {
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["commands"]
|
||||
}
|
||||
|
||||
export type ProjectUpdateOutput = Project
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
LocationGetOutput,
|
||||
@@ -37,7 +39,12 @@ import type {
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
|
||||
import {
|
||||
isFormAlreadySettledError,
|
||||
isFormNotFoundError,
|
||||
isPermissionNotFoundError,
|
||||
type SessionPromptInput,
|
||||
} from "../promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
@@ -120,6 +127,16 @@ function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
if (sessionID !== "global" || !ref) return undefined
|
||||
return {
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent(ref.directory),
|
||||
...(ref.workspaceID ? { "x-opencode-workspace": ref.workspaceID } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSync() {
|
||||
type Pending = { promise: Promise<void>; invalidated: boolean }
|
||||
const state = new Map<string, true | Pending>()
|
||||
@@ -228,6 +245,32 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
}
|
||||
|
||||
function removeForm(sessionID: string, formID: string, ref?: LocationRef) {
|
||||
const forms = store.session.form[sessionID]
|
||||
if (!forms) return false
|
||||
const location = ref && locationKey(ref)
|
||||
const next = forms.filter((form) => {
|
||||
if (form.id !== formID) return true
|
||||
if (sessionID !== "global" || !location) return false
|
||||
return !form.location || locationKey(form.location) !== location
|
||||
})
|
||||
if (next.length === forms.length) return false
|
||||
setStore("session", "form", sessionID, next)
|
||||
return true
|
||||
}
|
||||
|
||||
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
|
||||
return request
|
||||
.catch((error: unknown) => {
|
||||
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
|
||||
})
|
||||
.then(() => {
|
||||
if (!removeForm(input.sessionID, input.formID, ref)) return
|
||||
result.session.form.invalidate(input.sessionID, ref)
|
||||
void result.session.form.sync(input.sessionID, ref).catch(() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
|
||||
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
|
||||
const item = store.session.pending[sessionID]?.[index]
|
||||
@@ -998,12 +1041,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "form.replied":
|
||||
case "form.cancelled":
|
||||
setStore(
|
||||
"session",
|
||||
"form",
|
||||
event.data.sessionID,
|
||||
(store.session.form[event.data.sessionID] ?? []).filter((form) => form.id !== event.data.id),
|
||||
)
|
||||
removeForm(event.data.sessionID, event.data.id, event.location)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1420,6 +1458,12 @@ export function createData(config: CreateDataInput) {
|
||||
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
|
||||
)
|
||||
},
|
||||
reply(input: FormReplyInput, ref?: LocationRef) {
|
||||
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
|
||||
},
|
||||
cancel(input: FormCancelInput, ref?: LocationRef) {
|
||||
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -50,7 +50,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
@@ -81,29 +81,6 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/project",
|
||||
commands: { start: "bun install" },
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
}
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json(project)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
|
||||
expect(request?.method).toBe("PATCH")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
|
||||
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types.js"
|
||||
import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell.js"
|
||||
|
||||
/**
|
||||
* Check if a string is a file ID based on the given prefixes
|
||||
@@ -23,13 +22,11 @@ export async function convertToOpenAIResponsesInput({
|
||||
systemMessageMode,
|
||||
fileIdPrefixes,
|
||||
store,
|
||||
hasLocalShellTool = false,
|
||||
}: {
|
||||
prompt: LanguageModelV3Prompt
|
||||
systemMessageMode: "system" | "developer" | "remove"
|
||||
fileIdPrefixes?: readonly string[]
|
||||
store: boolean
|
||||
hasLocalShellTool?: boolean
|
||||
}): Promise<{
|
||||
input: OpenAIResponsesInput
|
||||
warnings: Array<SharedV3Warning>
|
||||
@@ -138,25 +135,6 @@ export async function convertToOpenAIResponsesInput({
|
||||
break
|
||||
}
|
||||
|
||||
if (hasLocalShellTool && part.toolName === "local_shell") {
|
||||
const parsedInput = localShellInputSchema.parse(part.input)
|
||||
input.push({
|
||||
type: "local_shell_call",
|
||||
call_id: part.toolCallId,
|
||||
id: store ? ((part.providerOptions?.copilot?.itemId as string) ?? undefined) : undefined,
|
||||
action: {
|
||||
type: "exec",
|
||||
command: parsedInput.action.command,
|
||||
timeout_ms: parsedInput.action.timeoutMs,
|
||||
user: parsedInput.action.user,
|
||||
working_directory: parsedInput.action.workingDirectory,
|
||||
env: parsedInput.action.env,
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: part.toolCallId,
|
||||
@@ -261,15 +239,6 @@ export async function convertToOpenAIResponsesInput({
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") {
|
||||
input.push({
|
||||
type: "local_shell_call_output",
|
||||
call_id: part.toolCallId,
|
||||
output: localShellOutputSchema.parse(output.value).output,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
let contentValue: string
|
||||
switch (output.type) {
|
||||
case "text":
|
||||
|
||||
@@ -9,8 +9,6 @@ export type OpenAIResponsesInputItem =
|
||||
| OpenAIResponsesFunctionCall
|
||||
| OpenAIResponsesFunctionCallOutput
|
||||
| OpenAIResponsesComputerCall
|
||||
| OpenAIResponsesLocalShellCall
|
||||
| OpenAIResponsesLocalShellCallOutput
|
||||
| OpenAIResponsesReasoning
|
||||
| OpenAIResponsesItemReference
|
||||
| OpenAIResponsesMcpApprovalResponse
|
||||
@@ -69,26 +67,6 @@ export type OpenAIResponsesComputerCall = {
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesLocalShellCall = {
|
||||
type: "local_shell_call"
|
||||
id?: string
|
||||
call_id: string
|
||||
action: {
|
||||
type: "exec"
|
||||
command: string[]
|
||||
timeout_ms?: number
|
||||
user?: string
|
||||
working_directory?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAIResponsesLocalShellCallOutput = {
|
||||
type: "local_shell_call_output"
|
||||
call_id: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesItemReference = {
|
||||
type: "item_reference"
|
||||
id: string
|
||||
@@ -199,9 +177,6 @@ export type OpenAIResponsesTool =
|
||||
quality: "auto" | "low" | "medium" | "high" | undefined
|
||||
size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined
|
||||
}
|
||||
| {
|
||||
type: "local_shell"
|
||||
}
|
||||
|
||||
export type OpenAIResponsesReasoning = {
|
||||
type: "reasoning"
|
||||
|
||||
@@ -29,7 +29,6 @@ import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-rea
|
||||
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
|
||||
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
|
||||
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
|
||||
import { localShellInputSchema } from "./tool/local-shell.js"
|
||||
|
||||
const webSearchCallItem = z.object({
|
||||
type: z.literal("web_search_call"),
|
||||
@@ -86,20 +85,6 @@ const codeInterpreterCallItem = z.object({
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
const localShellCallItem = z.object({
|
||||
type: z.literal("local_shell_call"),
|
||||
id: z.string(),
|
||||
call_id: z.string(),
|
||||
action: z.object({
|
||||
type: z.literal("exec"),
|
||||
command: z.array(z.string()),
|
||||
timeout_ms: z.number().optional(),
|
||||
user: z.string().optional(),
|
||||
working_directory: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const imageGenerationCallItem = z.object({
|
||||
type: z.literal("image_generation_call"),
|
||||
id: z.string(),
|
||||
@@ -205,7 +190,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
systemMessageMode: modelConfig.systemMessageMode,
|
||||
fileIdPrefixes: this.config.fileIdPrefixes,
|
||||
store,
|
||||
hasLocalShellTool: hasOpenAITool("openai.local_shell"),
|
||||
})
|
||||
|
||||
warnings.push(...inputWarnings)
|
||||
@@ -462,7 +446,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
fileSearchCallItem,
|
||||
codeInterpreterCallItem,
|
||||
imageGenerationCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("function_call"),
|
||||
call_id: z.string(),
|
||||
@@ -560,22 +543,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
break
|
||||
}
|
||||
|
||||
case "local_shell_call": {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.call_id,
|
||||
toolName: "local_shell",
|
||||
input: JSON.stringify({ action: part.action } satisfies z.infer<typeof localShellInputSchema>),
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
itemId: part.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case "message": {
|
||||
for (const contentPart of part.content) {
|
||||
if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) {
|
||||
@@ -1093,27 +1060,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
result: value.item.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
})
|
||||
} else if (value.item.type === "local_shell_call") {
|
||||
ongoingToolCalls[value.output_index] = undefined
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.call_id,
|
||||
toolName: "local_shell",
|
||||
input: JSON.stringify({
|
||||
action: {
|
||||
type: "exec",
|
||||
command: value.item.action.command,
|
||||
timeoutMs: value.item.action.timeout_ms,
|
||||
user: value.item.action.user,
|
||||
workingDirectory: value.item.action.working_directory,
|
||||
env: value.item.action.env,
|
||||
},
|
||||
} satisfies z.infer<typeof localShellInputSchema>),
|
||||
providerMetadata: {
|
||||
copilot: { itemId: value.item.id },
|
||||
},
|
||||
})
|
||||
} else if (value.item.type === "message") {
|
||||
if (currentTextId) {
|
||||
controller.enqueue({
|
||||
@@ -1528,7 +1474,6 @@ const responseOutputItemDoneSchema = z.object({
|
||||
imageGenerationCallItem,
|
||||
webSearchCallItem,
|
||||
fileSearchCallItem,
|
||||
localShellCallItem,
|
||||
z.object({
|
||||
type: z.literal("computer_call"),
|
||||
id: z.string(),
|
||||
|
||||
@@ -70,12 +70,6 @@ export function prepareResponsesTools({
|
||||
|
||||
break
|
||||
}
|
||||
case "openai.local_shell": {
|
||||
openaiTools.push({
|
||||
type: "local_shell",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "openai.web_search_preview": {
|
||||
const args = webSearchPreviewArgsSchema.parse(tool.args)
|
||||
openaiTools.push({
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const localShellInputSchema = z.object({
|
||||
action: z.object({
|
||||
type: z.literal("exec"),
|
||||
command: z.array(z.string()),
|
||||
timeoutMs: z.number().optional(),
|
||||
user: z.string().optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const localShellOutputSchema = z.object({
|
||||
output: z.string(),
|
||||
})
|
||||
@@ -273,28 +273,53 @@ export const layer = (options?: Options) =>
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
let current: Credential.OAuth | undefined = found.value
|
||||
const integrationID = entry.integrationID
|
||||
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
|
||||
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
|
||||
let presented = found.value.refresh
|
||||
const readOAuthCredential = async () => {
|
||||
const stored = await Effect.runPromise(credentials.list(integrationID))
|
||||
const match = stored.find((credential) => credential.id === credentialID)
|
||||
return match && match.value.type === "oauth" ? match.value : undefined
|
||||
}
|
||||
return MCPOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
|
||||
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
// connection may have already replaced ours; deleting then would discard the newer valid credential and
|
||||
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
|
||||
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
current = undefined
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
},
|
||||
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
|
||||
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
|
||||
// token fails with invalid_grant.
|
||||
store: {
|
||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
||||
tokens: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth) return undefined
|
||||
presented = oauth.refresh
|
||||
return MCPOAuth.toTokens(oauth)
|
||||
},
|
||||
saveTokens: async (tokens) => {
|
||||
current = MCPOAuth.toCredential({
|
||||
const previous = await readOAuthCredential()
|
||||
const value = MCPOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
||||
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
|
||||
},
|
||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
saveCodeVerifier: async () => {},
|
||||
|
||||
@@ -29,13 +29,6 @@ export type Current = ProjectSchema.Current
|
||||
export const Info = ProjectSchema.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = ProjectSchema.UpdateInput
|
||||
export type UpdateInput = ProjectSchema.UpdateInput
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
|
||||
projectID: ID,
|
||||
}) {}
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
@@ -54,7 +47,6 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
@@ -153,31 +145,6 @@ const layer = Layer.effect(
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const row = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name === undefined ? undefined : input.name || null,
|
||||
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
|
||||
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
|
||||
commands:
|
||||
input.commands?.start === undefined
|
||||
? undefined
|
||||
: input.commands.start
|
||||
? { start: input.commands.start }
|
||||
: null,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
|
||||
const project = fromRow(row)
|
||||
yield* bus.publish(ProjectSchema.Event.Updated, project)
|
||||
return project
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -291,7 +258,7 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, update, resolve })
|
||||
return Service.of({ list, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ export type Current = typeof Current.Type
|
||||
export const Info = Project.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Project.UpdateInput
|
||||
export type UpdateInput = typeof UpdateInput.Type
|
||||
|
||||
export const Event = Project.Event
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -165,9 +165,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
* Execution lifecycle is published per busy period by SessionExecution, not here.
|
||||
@@ -334,7 +332,10 @@ const layer = Layer.effect(
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promoted > 0) yield* startTitle(sessionID)
|
||||
if (promoted > 0)
|
||||
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
@@ -715,22 +716,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
/** Starts one title request at a time after a successful step makes user input visible. */
|
||||
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
if (titlesRunning.has(sessionID)) return
|
||||
titlesRunning.add(sessionID)
|
||||
forkTitle(
|
||||
title.generateForFirstPrompt(sessionID).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
titlesRunning.delete(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
|
||||
@@ -90,6 +90,7 @@ const layer = Layer.effect(
|
||||
) {
|
||||
const beforeEvent: PluginHooks.Domains["tool"]["execute.before"] = {
|
||||
tool: name,
|
||||
inputSchema: definition(tool).inputSchema,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
|
||||
@@ -18,8 +18,6 @@ import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
|
||||
@@ -89,7 +87,6 @@ export type Error =
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
@@ -150,7 +147,6 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
@@ -264,30 +260,6 @@ const layer = Layer.effect(
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
if (command && project) {
|
||||
const shell = process.platform === "win32" ? "cmd" : "bash"
|
||||
const args = process.platform === "win32" ? ["/c", command] : ["-lc", command]
|
||||
yield* processService
|
||||
.run(
|
||||
ChildProcess.make(shell, args, {
|
||||
cwd: result.directory,
|
||||
env: {
|
||||
OPENCODE_WORKTREE_BASE: project.worktree,
|
||||
OPENCODE_WORKTREE_PATH: result.directory,
|
||||
},
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -370,7 +342,7 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
|
||||
@@ -78,7 +78,6 @@ describe("node build", () => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,6 @@ export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -455,7 +455,7 @@ describe("Plugin", () => {
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: unknown
|
||||
before?: { input: unknown; inputSchema: unknown }
|
||||
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
|
||||
} = {}
|
||||
|
||||
@@ -480,7 +480,7 @@ describe("Plugin", () => {
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.before = event.input
|
||||
seen.before = { input: event.input, inputSchema: event.inputSchema }
|
||||
event.input = { text: "before-mutated" }
|
||||
}),
|
||||
)
|
||||
@@ -526,7 +526,15 @@ describe("Plugin", () => {
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({ text: "original" })
|
||||
expect(seen.before).toEqual({
|
||||
input: { text: "original" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
required: ["text"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -66,55 +66,6 @@ describe("Project.list", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.update", () => {
|
||||
it.effect("updates and clears project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const project = yield* Project.Service
|
||||
const id = Project.ID.make("update")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id,
|
||||
worktree: abs("/update"),
|
||||
sandboxes: [],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(
|
||||
yield* project.update({
|
||||
projectID: id,
|
||||
name: "Updated",
|
||||
icon: { color: "blue", override: "data:image/png;base64,test" },
|
||||
commands: { start: "bun install" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id,
|
||||
name: "Updated",
|
||||
icon: { color: "blue", override: "data:image/png;base64,test" },
|
||||
commands: { start: "bun install" },
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* project.update({
|
||||
projectID: id,
|
||||
name: "",
|
||||
icon: { color: "", override: "" },
|
||||
commands: { start: "" },
|
||||
}),
|
||||
).toMatchObject({ id })
|
||||
expect((yield* project.list())[0]).toEqual({
|
||||
id,
|
||||
canonical: abs("/update"),
|
||||
time: { created: 1, updated: expect.any(Number) },
|
||||
sandboxes: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
@@ -897,30 +897,38 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
])
|
||||
})
|
||||
|
||||
const prepareTitleGeneration = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const watchRename = Effect.fnUntraced(function* (sessionID: Session.ID) {
|
||||
const bus = yield* Bus.Service
|
||||
return yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("generates the title while the first model step is still running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
const runner = yield* SessionRunner.Service
|
||||
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Fiber.join(renamed)
|
||||
@@ -930,19 +938,46 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces title generation while a request is active", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
const titleStarted = yield* Deferred.make<void>()
|
||||
const releaseTitle = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(
|
||||
Stream.unwrap(
|
||||
Deferred.succeed(titleStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseTitle)),
|
||||
Effect.as(Stream.fromIterable(TestLLM.text("Generated title", "text-title"))),
|
||||
),
|
||||
),
|
||||
TestLLM.text("First response", "text-first"),
|
||||
TestLLM.text("Second response", "text-second"),
|
||||
)
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(titleStarted).pipe(Effect.timeout("5 seconds"))
|
||||
expect(requests[0]?.system.map((part) => part.text)).toContain("Generate a title.")
|
||||
yield* Fiber.join(first)
|
||||
yield* admit(session, "Second prompt")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
yield* Deferred.succeed(releaseTitle, undefined)
|
||||
yield* Fiber.join(renamed)
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(releaseTitle, undefined)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries title generation from the first prompt after title and execution failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
yield* prepareTitleGeneration
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
|
||||
@@ -961,13 +996,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Effect.yieldNow
|
||||
expect((yield* session.get(sessionID)).title).toBeUndefined()
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const renamed = yield* watchRename(sessionID)
|
||||
yield* admit(session, "Third prompt")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
|
||||
@@ -192,43 +192,6 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs the project setup script with worktree paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* input.db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
commands: {
|
||||
start:
|
||||
"bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"",
|
||||
},
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
|
||||
input.sourceDirectory,
|
||||
created.directory,
|
||||
created.directory,
|
||||
])
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a missing source directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./palette": "./src/palette.ts",
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MermaidDiagramKind } from "./diagnostics.js"
|
||||
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { isMermaidGanttDiagram } from "./gantt/parser.js"
|
||||
import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js"
|
||||
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
|
||||
import { isMermaidStateDiagram } from "./state/parser.js"
|
||||
@@ -7,6 +8,7 @@ import { isMermaidTimelineDiagram } from "./timeline/parser.js"
|
||||
|
||||
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
|
||||
if (isMermaidFlowchartDiagram(content)) return "flowchart"
|
||||
if (isMermaidGanttDiagram(content)) return "gantt"
|
||||
if (isMermaidGitGraphDiagram(content)) return "gitGraph"
|
||||
if (isMermaidSequenceDiagram(content)) return "sequence"
|
||||
if (isMermaidStateDiagram(content)) return "state"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph"
|
||||
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph" | "gantt"
|
||||
|
||||
/** An otherwise valid diagram contains syntax that this renderer does not support. */
|
||||
export class MermaidSyntaxError extends Error {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderGanttDiagram } from "./diagram.js"
|
||||
import { drawGanttDiagramGrid } from "./drawing.js"
|
||||
import { isMermaidGanttDiagram, parseMermaidGanttDiagram } from "./parser.js"
|
||||
|
||||
const secondsDiagram = `gantt
|
||||
dateFormat s
|
||||
axisFormat %Ss
|
||||
section OLD (blocking)
|
||||
provider.create (Modal sandbox) :crit, 0, 15
|
||||
model streams first token :15, 17
|
||||
section NEW (eager kick)
|
||||
reserve (DB insert) :0, 1
|
||||
model streams first token :0, 2
|
||||
provisioning in background :active, 0, 15
|
||||
model calls bash → spawn runs :15, 16
|
||||
section NEW (pure chat thread)
|
||||
reserve (DB insert) :0, 1
|
||||
model answers, never calls bash :0, 4`
|
||||
|
||||
describe("GanttDiagram", () => {
|
||||
test("detects and parses second-based task ranges and states", () => {
|
||||
const diagram = parseMermaidGanttDiagram(secondsDiagram)
|
||||
|
||||
expect(diagram.dateFormat).toBe("s")
|
||||
expect(diagram.axisFormat).toBe("%Ss")
|
||||
expect(diagram.tasks).toHaveLength(8)
|
||||
expect(diagram.tasks[0]).toMatchObject({
|
||||
label: "provider.create (Modal sandbox)",
|
||||
start: 0,
|
||||
end: 15_000,
|
||||
state: "critical",
|
||||
})
|
||||
expect(diagram.tasks[4]).toMatchObject({
|
||||
label: "provisioning in background",
|
||||
start: 0,
|
||||
end: 15_000,
|
||||
state: "active",
|
||||
})
|
||||
})
|
||||
|
||||
test("renders sections, a formatted axis, and aligned task bars", () => {
|
||||
expectDiagram(renderGanttDiagram(secondsDiagram, { layoutMaxWidth: 100 })).toContainInOrder(
|
||||
"00s",
|
||||
"15s",
|
||||
"OLD (blocking)",
|
||||
"provider.create (Modal sandbox)",
|
||||
"model streams first token",
|
||||
"NEW (eager kick)",
|
||||
"provisioning in background",
|
||||
"NEW (pure chat thread)",
|
||||
"model answers, never calls bash",
|
||||
)
|
||||
expect(renderGanttDiagram(secondsDiagram)).toContain("\n\nNEW (eager kick)")
|
||||
expect(renderGanttDiagram(secondsDiagram)).not.toContain("·")
|
||||
})
|
||||
|
||||
test("renders alternate terminal bar styles", () => {
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "block" })).toContain("█")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "capsule" })).toContain("╶")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "points" })).toContain("●")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", track: "dots" })).toContain("·")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", track: "line" })).not.toContain("·")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", endpoints: "points" })).toContain("●")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "thin" })).toContain("─")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "double" })).toContain("═")
|
||||
expect(renderGanttDiagram(secondsDiagram, { style: "track", line: "dashed" })).toContain("╌")
|
||||
expect(renderGanttDiagram(secondsDiagram, { labels: "tree" })).toContain("├─ provider.create")
|
||||
expect(renderGanttDiagram(secondsDiagram, { labels: "tree" })).toContain("└─ model streams first token")
|
||||
expect(renderGanttDiagram(secondsDiagram, { sections: "spaced" })).toContain("\n\nNEW (eager kick)")
|
||||
|
||||
const points = drawGanttDiagramGrid(parseMermaidGanttDiagram(secondsDiagram), {
|
||||
style: "track",
|
||||
endpoints: "points",
|
||||
trackTone: "faint",
|
||||
}).rows.flatMap((row) => row.filter((cell) => cell.char === "●"))
|
||||
expect(points.every((cell) => cell.style === "trackFaint")).toBe(true)
|
||||
})
|
||||
|
||||
test("resolves task ids, after dependencies, durations, and milestones", () => {
|
||||
const diagram = parseMermaidGanttDiagram(`gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
task one :done, first, 2026-08-01, 2d
|
||||
deploy :milestone, after first, 0d`)
|
||||
|
||||
expect(diagram.tasks[1]).toMatchObject({
|
||||
start: Date.UTC(2026, 7, 3),
|
||||
end: Date.UTC(2026, 7, 3),
|
||||
state: "milestone",
|
||||
})
|
||||
expect(
|
||||
renderGanttDiagram(`gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
task one :first, 2026-08-01, 2d
|
||||
deploy :milestone, after first, 0d`),
|
||||
).toContain("◆")
|
||||
})
|
||||
|
||||
test("rejects unsupported or ambiguous syntax with source diagnostics", () => {
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n task :not-a-date, 2d")).toThrow(
|
||||
new MermaidSyntaxError(
|
||||
"gantt",
|
||||
2,
|
||||
"task :not-a-date, 2d",
|
||||
'Unsupported date "not-a-date" for dateFormat YYYY-MM-DD',
|
||||
),
|
||||
)
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n task :after missing, 2d")).toThrow('Unknown Gantt task id "missing"')
|
||||
expect(() => parseMermaidGanttDiagram("gantt\n excludes weekends")).toThrow(
|
||||
"excludes is not supported in gantt diagram",
|
||||
)
|
||||
})
|
||||
|
||||
test("recognizes only Gantt headers", () => {
|
||||
expect(isMermaidGanttDiagram("%% comment\ngantt\n task :0, 1")).toBe(true)
|
||||
expect(isMermaidGanttDiagram("timeline\n 2026 : ship")).toBe(false)
|
||||
})
|
||||
|
||||
test("renders partial diagrams containing only sections", () => {
|
||||
expect(renderGanttDiagram("gantt\n section Planning")).toBe("Planning")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { drawGanttDiagramGrid } from "./drawing.js"
|
||||
import { parseMermaidGanttDiagram } from "./parser.js"
|
||||
import { renderGanttGridText } from "./render-grid.js"
|
||||
import type { GanttDiagramRenderOptions } from "./types.js"
|
||||
|
||||
export function renderGanttDiagram(content: string, options: GanttDiagramRenderOptions = {}): string {
|
||||
return renderGanttGridText(drawGanttDiagramGrid(parseMermaidGanttDiagram(content), options))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { DiagramCanvas } from "../core/canvas.js"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import type { GanttGrid } from "./render-grid.js"
|
||||
import type {
|
||||
GanttCellStyle,
|
||||
GanttDiagram,
|
||||
GanttDiagramRenderOptions,
|
||||
GanttLabelLayout,
|
||||
GanttLineStyle,
|
||||
GanttRenderStyle,
|
||||
GanttTask,
|
||||
} from "./types.js"
|
||||
|
||||
const LABEL_GAP = 2
|
||||
const MIN_CHART_WIDTH = 24
|
||||
const MAX_CHART_WIDTH = 64
|
||||
|
||||
export function drawGanttDiagramGrid(diagram: GanttDiagram, options: GanttDiagramRenderOptions = {}): GanttGrid {
|
||||
if (diagram.entries.length === 0) return new DiagramCanvas(0, 0)
|
||||
const labels = diagram.entries.map((_, index) => entryLabel(diagram, index, options.labels ?? "left"))
|
||||
const labelWidth = Math.max(...labels.map(diagramTextWidth))
|
||||
const entryRows: number[] = []
|
||||
let bodyHeight = 0
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
if ((options.sections ?? "spaced") === "spaced" && entry.type === "section" && index > 0) bodyHeight += 1
|
||||
entryRows.push(bodyHeight)
|
||||
bodyHeight += 1
|
||||
})
|
||||
if (diagram.tasks.length === 0) {
|
||||
const grid: GanttGrid = new DiagramCanvas(labelWidth, bodyHeight)
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
if (entry.type === "section") grid.setText(0, entryRows[index]!, entry.section.label, "section")
|
||||
})
|
||||
return grid
|
||||
}
|
||||
const available = (options.layoutMaxWidth ?? 120) - labelWidth - LABEL_GAP
|
||||
const chartWidth = Math.max(MIN_CHART_WIDTH, Math.min(MAX_CHART_WIDTH, available))
|
||||
const starts = diagram.tasks.map((task) => task.start)
|
||||
const ends = diagram.tasks.map((task) => task.end)
|
||||
const minimum = Math.min(...starts)
|
||||
const maximum = Math.max(...ends)
|
||||
const span = Math.max(1, maximum - minimum)
|
||||
const titleHeight = diagram.title ? 2 : 0
|
||||
const axisHeight = 2
|
||||
const grid: GanttGrid = new DiagramCanvas(labelWidth + LABEL_GAP + chartWidth, titleHeight + axisHeight + bodyHeight)
|
||||
const chartX = labelWidth + LABEL_GAP
|
||||
|
||||
if (diagram.title)
|
||||
grid.setText(
|
||||
Math.max(0, chartX + Math.floor((chartWidth - diagramTextWidth(diagram.title)) / 2)),
|
||||
0,
|
||||
diagram.title,
|
||||
"title",
|
||||
)
|
||||
drawAxis(grid, chartX, titleHeight, chartWidth, minimum, span, diagram.axisFormat)
|
||||
|
||||
diagram.entries.forEach((entry, index) => {
|
||||
const y = titleHeight + axisHeight + entryRows[index]!
|
||||
if (entry.type === "section") {
|
||||
grid.setText(0, y, labels[index]!, "section")
|
||||
return
|
||||
}
|
||||
const label = labels[index]!
|
||||
const labelX = options.labels === "right" ? labelWidth - diagramTextWidth(label) : 0
|
||||
grid.setText(labelX, y, label, entry.task.state)
|
||||
drawTask(grid, entry.task, chartX, y, chartWidth, minimum, span, options)
|
||||
})
|
||||
return grid
|
||||
}
|
||||
|
||||
function drawAxis(
|
||||
grid: GanttGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
minimum: number,
|
||||
span: number,
|
||||
format: string,
|
||||
): void {
|
||||
for (let offset = 0; offset < width; offset++) grid.setCell(x + offset, y + 1, "─", "axis")
|
||||
const step = tickStep(span)
|
||||
const ticks: number[] = []
|
||||
for (let value = Math.ceil(minimum / step) * step; value <= minimum + span; value += step) ticks.push(value)
|
||||
if (ticks.length === 0) ticks.push(minimum, minimum + span)
|
||||
for (const value of ticks) {
|
||||
const offset = Math.round(((value - minimum) / span) * (width - 1))
|
||||
const label = formatTime(value, format)
|
||||
const labelX = Math.max(
|
||||
x,
|
||||
Math.min(x + width - diagramTextWidth(label), x + offset - Math.floor(diagramTextWidth(label) / 2)),
|
||||
)
|
||||
grid.setText(labelX, y, label, "axis")
|
||||
grid.setCell(x + offset, y + 1, "┬", "axis")
|
||||
}
|
||||
}
|
||||
|
||||
function tickStep(span: number): number {
|
||||
const steps = [
|
||||
1_000, 5_000, 10_000, 30_000, 60_000, 300_000, 900_000, 3_600_000, 21_600_000, 43_200_000, 86_400_000, 172_800_000,
|
||||
604_800_000, 2_592_000_000, 31_536_000_000,
|
||||
]
|
||||
return steps.find((step) => step >= span / 4) ?? steps.at(-1)!
|
||||
}
|
||||
|
||||
function drawTask(
|
||||
grid: GanttGrid,
|
||||
task: GanttTask,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
minimum: number,
|
||||
span: number,
|
||||
options: GanttDiagramRenderOptions,
|
||||
): void {
|
||||
const style: GanttRenderStyle = options.style ?? "track"
|
||||
const line = lineGlyph(options.line ?? "thin")
|
||||
const start = Math.round(((task.start - minimum) / span) * (width - 1))
|
||||
const end = Math.round(((task.end - minimum) / span) * (width - 1))
|
||||
if (task.state === "milestone" || start === end) {
|
||||
grid.setCell(x + start, y, "◆", task.state)
|
||||
return
|
||||
}
|
||||
if (style === "track") {
|
||||
for (let offset = 0; offset < width; offset++) {
|
||||
grid.setCell(x + offset, y, (options.track ?? "line") === "line" ? line : "·", trackCellStyle(options))
|
||||
}
|
||||
}
|
||||
const glyph = style === "block" ? "█" : line
|
||||
for (let offset = start; offset <= end; offset++) grid.setCell(x + offset, y, glyph, task.state)
|
||||
if (style === "block") return
|
||||
if (style === "capsule" || style === "track") {
|
||||
if (style === "track") {
|
||||
if (options.endpoints === "points") {
|
||||
grid.setCell(x + start, y, "●", trackCellStyle(options))
|
||||
grid.setCell(x + end, y, "●", trackCellStyle(options))
|
||||
}
|
||||
return
|
||||
}
|
||||
const caps = capGlyphs(options.line ?? "thin")
|
||||
grid.setCell(x + start, y, caps.start, task.state)
|
||||
grid.setCell(x + end, y, caps.end, task.state)
|
||||
return
|
||||
}
|
||||
if (style === "points") {
|
||||
grid.setCell(x + start, y, "●", task.state)
|
||||
grid.setCell(x + end, y, "●", task.state)
|
||||
return
|
||||
}
|
||||
grid.setCell(x + start, y, "┣", task.state)
|
||||
grid.setCell(x + end, y, "┫", task.state)
|
||||
}
|
||||
|
||||
function entryLabel(diagram: GanttDiagram, index: number, layout: GanttLabelLayout): string {
|
||||
const entry = diagram.entries[index]!
|
||||
if (entry.type === "section" || layout !== "tree") {
|
||||
return entry.type === "section" ? entry.section.label : entry.task.label
|
||||
}
|
||||
const last = diagram.entries[index + 1]?.type !== "task"
|
||||
return ` ${last ? "└" : "├"}─ ${entry.task.label}`
|
||||
}
|
||||
|
||||
function lineGlyph(line: GanttLineStyle): string {
|
||||
if (line === "thin") return "─"
|
||||
if (line === "double") return "═"
|
||||
if (line === "dashed") return "╌"
|
||||
return "━"
|
||||
}
|
||||
|
||||
function capGlyphs(line: GanttLineStyle): { start: string; end: string } {
|
||||
if (line === "thin" || line === "dashed") return { start: "╶", end: "╴" }
|
||||
if (line === "double") return { start: "╞", end: "╡" }
|
||||
return { start: "╺", end: "╸" }
|
||||
}
|
||||
|
||||
function trackCellStyle(options: GanttDiagramRenderOptions): "trackMedium" | "trackDim" | "trackFaint" {
|
||||
if (options.trackTone === "medium") return "trackMedium"
|
||||
if (options.trackTone === "dim") return "trackDim"
|
||||
return "trackFaint"
|
||||
}
|
||||
|
||||
function formatTime(value: number, format: string): string {
|
||||
const date = new Date(value)
|
||||
const parts: Record<string, string> = {
|
||||
"%Y": String(date.getUTCFullYear()),
|
||||
"%m": String(date.getUTCMonth() + 1).padStart(2, "0"),
|
||||
"%d": String(date.getUTCDate()).padStart(2, "0"),
|
||||
"%H": String(date.getUTCHours()).padStart(2, "0"),
|
||||
"%M": String(date.getUTCMinutes()).padStart(2, "0"),
|
||||
"%S": String(date.getUTCSeconds()).padStart(2, "0"),
|
||||
}
|
||||
return Object.entries(parts).reduce((result, [token, replacement]) => result.replaceAll(token, replacement), format)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
|
||||
import { MermaidSyntaxError } from "../diagnostics.js"
|
||||
import type { GanttDiagram, GanttEntry, GanttSection, GanttTask, GanttTaskState } from "./types.js"
|
||||
|
||||
const HEADER_RE = /^gantt$/i
|
||||
const DIRECTIVE_RE = /^(title|dateFormat|axisFormat|tickInterval|excludes|todayMarker)\s+(.+)$/i
|
||||
const SECTION_RE = /^section(?:\s+(.+))?$/i
|
||||
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
|
||||
const TASK_STATES = new Set(["active", "done", "crit", "milestone", "vert"])
|
||||
|
||||
export function isMermaidGanttDiagram(content: string): boolean {
|
||||
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
||||
}
|
||||
|
||||
export function parseMermaidGanttDiagram(content: string): GanttDiagram {
|
||||
const tasks: GanttTask[] = []
|
||||
const entries: GanttEntry[] = []
|
||||
const tasksById = new Map<string, GanttTask>()
|
||||
let title: string | undefined
|
||||
let dateFormat = "YYYY-MM-DD"
|
||||
let axisFormat = "%Y-%m-%d"
|
||||
let section: GanttSection | undefined
|
||||
let headerSeen = false
|
||||
let inAccessibilityDescription = false
|
||||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = stripComment(source.text)
|
||||
if (inAccessibilityDescription) {
|
||||
if (line === "}") inAccessibilityDescription = false
|
||||
continue
|
||||
}
|
||||
if (/^accDescr\s*\{$/i.test(line)) {
|
||||
inAccessibilityDescription = true
|
||||
continue
|
||||
}
|
||||
if (ACCESSIBILITY_RE.test(line)) continue
|
||||
if (!line) continue
|
||||
if (HEADER_RE.test(line)) {
|
||||
if (headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header can only appear once")
|
||||
headerSeen = true
|
||||
continue
|
||||
}
|
||||
if (!headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header is required")
|
||||
|
||||
const sectionMatch = line.match(SECTION_RE)
|
||||
if (sectionMatch) {
|
||||
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Gantt section cannot be empty")
|
||||
section = { label: stripMermaidQuotes(sectionMatch[1]) }
|
||||
entries.push({ type: "section", section })
|
||||
continue
|
||||
}
|
||||
|
||||
const directive = line.match(DIRECTIVE_RE)
|
||||
if (directive) {
|
||||
const name = directive[1]!.toLowerCase()
|
||||
const value = directive[2]!.trim()
|
||||
if (name === "title") title = stripMermaidQuotes(value)
|
||||
if (name === "dateformat") dateFormat = value
|
||||
if (name === "axisformat") axisFormat = value
|
||||
if (!["title", "dateformat", "axisformat"].includes(name)) {
|
||||
throw syntaxError(source.lineNumber, line, `${directive[1]} is not supported`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const separator = line.indexOf(":")
|
||||
if (separator < 1) throw syntaxError(source.lineNumber, line)
|
||||
const label = stripMermaidQuotes(line.slice(0, separator))
|
||||
const fields = line
|
||||
.slice(separator + 1)
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
const flags = new Set<string>()
|
||||
while (fields[0] && TASK_STATES.has(fields[0].toLowerCase())) flags.add(fields.shift()!.toLowerCase())
|
||||
if (fields.length < 2 || fields.length > 3) {
|
||||
throw syntaxError(source.lineNumber, line, "Gantt tasks require a start and end or duration")
|
||||
}
|
||||
const id = fields.length === 3 ? fields.shift() : undefined
|
||||
if (id && tasksById.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate task id "${id}"`)
|
||||
const start = parseStart(fields[0]!, dateFormat, tasksById, source.lineNumber, line)
|
||||
const end = parseEnd(fields[1]!, start, dateFormat, source.lineNumber, line)
|
||||
if (end < start) throw syntaxError(source.lineNumber, line, "Gantt task cannot end before it starts")
|
||||
const task: GanttTask = {
|
||||
label,
|
||||
...(id ? { id } : {}),
|
||||
...(section ? { section } : {}),
|
||||
start,
|
||||
end,
|
||||
state: taskState(flags),
|
||||
}
|
||||
tasks.push(task)
|
||||
entries.push({ type: "task", task })
|
||||
if (id) tasksById.set(id, task)
|
||||
}
|
||||
|
||||
if (!headerSeen) throw new MermaidSyntaxError("gantt", 1, "", "Gantt header is required")
|
||||
return { ...(title === undefined ? {} : { title }), dateFormat, axisFormat, tasks, entries }
|
||||
}
|
||||
|
||||
function parseStart(
|
||||
value: string,
|
||||
format: string,
|
||||
tasksById: Map<string, GanttTask>,
|
||||
lineNumber: number,
|
||||
sourceLine: string,
|
||||
): number {
|
||||
if (!/^after\s+/i.test(value)) return parseDate(value, format, lineNumber, sourceLine)
|
||||
const dependencies = value
|
||||
.replace(/^after\s+/i, "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
const tasks = dependencies.map((id) => tasksById.get(id))
|
||||
const missing = dependencies.find((_, index) => !tasks[index])
|
||||
if (missing) throw syntaxError(lineNumber, sourceLine, `Unknown Gantt task id "${missing}"`)
|
||||
return Math.max(...tasks.map((task) => task!.end))
|
||||
}
|
||||
|
||||
function parseEnd(value: string, start: number, format: string, lineNumber: number, sourceLine: string): number {
|
||||
const duration = value.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i)
|
||||
if (!duration) return parseDate(value, format, lineNumber, sourceLine)
|
||||
const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }
|
||||
return start + Number(duration[1]) * units[duration[2]!.toLowerCase() as keyof typeof units]
|
||||
}
|
||||
|
||||
function parseDate(value: string, format: string, lineNumber: number, sourceLine: string): number {
|
||||
const numeric = Number(value)
|
||||
if (format === "s" && Number.isFinite(numeric)) return numeric * 1_000
|
||||
if (format === "X" && Number.isFinite(numeric)) return numeric * 1_000
|
||||
if (format === "x" && Number.isFinite(numeric)) return numeric
|
||||
|
||||
const calendar = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/)
|
||||
if (calendar && ["YYYY-MM-DD", "YYYY-MM-DD HH:mm", "YYYY-MM-DD HH:mm:ss"].includes(format)) {
|
||||
return Date.UTC(
|
||||
Number(calendar[1]),
|
||||
Number(calendar[2]) - 1,
|
||||
Number(calendar[3]),
|
||||
Number(calendar[4] ?? 0),
|
||||
Number(calendar[5] ?? 0),
|
||||
Number(calendar[6] ?? 0),
|
||||
)
|
||||
}
|
||||
throw syntaxError(lineNumber, sourceLine, `Unsupported date "${value}" for dateFormat ${format}`)
|
||||
}
|
||||
|
||||
function taskState(flags: Set<string>): GanttTaskState {
|
||||
if (flags.has("milestone")) return "milestone"
|
||||
if (flags.has("crit")) return "critical"
|
||||
if (flags.has("done")) return "done"
|
||||
if (flags.has("active")) return "active"
|
||||
return "task"
|
||||
}
|
||||
|
||||
function stripComment(value: string): string {
|
||||
const comment = value.indexOf("%%")
|
||||
return (comment < 0 ? value : value.slice(0, comment)).trim()
|
||||
}
|
||||
|
||||
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
|
||||
return new MermaidSyntaxError("gantt", lineNumber, sourceLine, reason)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StyledText } from "@opentui/core"
|
||||
import type { DiagramCanvas } from "../core/canvas.js"
|
||||
import { renderDiagramGridStyledText } from "../core/render-grid.js"
|
||||
import type { GanttStyleColors } from "./style.js"
|
||||
import type { GanttCellStyle } from "./types.js"
|
||||
|
||||
export type GanttGrid = DiagramCanvas<GanttCellStyle>
|
||||
|
||||
export function renderGanttGridText(grid: GanttGrid): string {
|
||||
return grid.toString({ trimBottom: true })
|
||||
}
|
||||
|
||||
export function renderGanttGridStyledText(grid: GanttGrid, colors: GanttStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor, rgba, type DiagramRgb } from "../core/color/style.js"
|
||||
import type { GanttBaseCellStyle, GanttCellStyle } from "./types.js"
|
||||
|
||||
const DEFAULT_THEME_RGB = {
|
||||
title: [228, 239, 232],
|
||||
axis: [111, 138, 126],
|
||||
section: [154, 184, 169],
|
||||
task: [134, 225, 200],
|
||||
active: [134, 225, 200],
|
||||
critical: [230, 177, 126],
|
||||
done: [111, 138, 126],
|
||||
milestone: [198, 160, 246],
|
||||
} as const satisfies Record<GanttBaseCellStyle, DiagramRgb>
|
||||
|
||||
export type GanttStyleColors = Required<Record<GanttCellStyle, RGBA>>
|
||||
|
||||
export function resolveGanttStyleColors(
|
||||
colors: Partial<Record<GanttBaseCellStyle | "background", RGBA | undefined>> = {},
|
||||
): GanttStyleColors {
|
||||
const axis = colors.axis ?? rgba(DEFAULT_THEME_RGB.axis)
|
||||
const background = colors.background ?? rgba([13, 17, 23])
|
||||
return {
|
||||
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
|
||||
axis,
|
||||
section: colors.section ?? rgba(DEFAULT_THEME_RGB.section),
|
||||
task: colors.task ?? rgba(DEFAULT_THEME_RGB.task),
|
||||
active: colors.active ?? rgba(DEFAULT_THEME_RGB.active),
|
||||
critical: colors.critical ?? rgba(DEFAULT_THEME_RGB.critical),
|
||||
done: colors.done ?? rgba(DEFAULT_THEME_RGB.done),
|
||||
milestone: colors.milestone ?? rgba(DEFAULT_THEME_RGB.milestone),
|
||||
trackMedium: blendColor(axis, background, 0.35),
|
||||
trackDim: blendColor(axis, background, 0.55),
|
||||
trackFaint: blendColor(axis, background, 0.72),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type GanttTaskState = "task" | "active" | "critical" | "done" | "milestone"
|
||||
|
||||
export interface GanttSection {
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface GanttTask {
|
||||
label: string
|
||||
id?: string
|
||||
section?: GanttSection
|
||||
start: number
|
||||
end: number
|
||||
state: GanttTaskState
|
||||
}
|
||||
|
||||
export type GanttEntry = { type: "section"; section: GanttSection } | { type: "task"; task: GanttTask }
|
||||
|
||||
export interface GanttDiagram {
|
||||
title?: string
|
||||
dateFormat: string
|
||||
axisFormat: string
|
||||
tasks: GanttTask[]
|
||||
entries: GanttEntry[]
|
||||
}
|
||||
|
||||
export interface GanttDiagramRenderOptions {
|
||||
layoutMaxWidth?: number
|
||||
style?: GanttRenderStyle
|
||||
track?: "dots" | "line"
|
||||
endpoints?: "plain" | "points"
|
||||
line?: GanttLineStyle
|
||||
labels?: GanttLabelLayout
|
||||
sections?: "compact" | "spaced"
|
||||
trackTone?: GanttTrackTone
|
||||
}
|
||||
|
||||
export type GanttRenderStyle = "rail" | "block" | "capsule" | "points" | "track"
|
||||
export type GanttLineStyle = "heavy" | "thin" | "double" | "dashed"
|
||||
export type GanttLabelLayout = "right" | "left" | "tree"
|
||||
export type GanttTrackTone = "medium" | "dim" | "faint"
|
||||
|
||||
export type GanttBaseCellStyle = "title" | "axis" | "section" | GanttTaskState
|
||||
export type GanttTrackCellStyle = "trackMedium" | "trackDim" | "trackFaint"
|
||||
export type GanttCellStyle = GanttBaseCellStyle | GanttTrackCellStyle
|
||||
@@ -17,6 +17,11 @@ import { detectMermaidDiagram } from "./detect.js"
|
||||
import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
|
||||
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
|
||||
import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
|
||||
import { drawGanttDiagramGrid } from "./gantt/drawing.js"
|
||||
import { parseMermaidGanttDiagram } from "./gantt/parser.js"
|
||||
import { renderGanttGridStyledText } from "./gantt/render-grid.js"
|
||||
import { resolveGanttStyleColors } from "./gantt/style.js"
|
||||
import type { GanttDiagramRenderOptions } from "./gantt/types.js"
|
||||
import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js"
|
||||
import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js"
|
||||
import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js"
|
||||
@@ -48,6 +53,8 @@ export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
|
||||
layoutMaxWidth?: number
|
||||
/** Gantt-specific terminal rendering options. */
|
||||
gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
@@ -162,6 +169,28 @@ function prepareDiagram(
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "gantt": {
|
||||
const grid = drawGanttDiagramGrid(parseMermaidGanttDiagram(source), { ...options.gantt, layoutMaxWidth })
|
||||
const size = grid.getTextSize({ trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
text: renderGanttGridStyledText(
|
||||
grid,
|
||||
resolveGanttStyleColors({
|
||||
title: color(colors.text),
|
||||
axis: color(colors.muted),
|
||||
background: color(colors.background),
|
||||
section: color(colors.secondary),
|
||||
task: color(colors.primary),
|
||||
active: color(colors.primary),
|
||||
critical: color(colors.warning),
|
||||
done: color(colors.muted),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact })
|
||||
const size = grid.getTextSize()
|
||||
|
||||
@@ -13,7 +13,7 @@ let treeSitterClient: TreeSitterClient
|
||||
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
const dataPath = join(tmpdir(), "merman-markdown-test-data")
|
||||
const dataPath = join(tmpdir(), "mermaid-markdown-test-data")
|
||||
await mkdir(dataPath, { recursive: true })
|
||||
treeSitterClient = new TreeSitterClient({ dataPath })
|
||||
await treeSitterClient.initialize()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolDraft {
|
||||
@@ -13,6 +14,7 @@ export interface ToolDraft {
|
||||
export interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Hooks, Transform } from "./registration.js"
|
||||
|
||||
export interface ToolContext extends Omit<Tool.Context, "progress"> {
|
||||
@@ -30,6 +31,7 @@ interface ToolDraft {
|
||||
interface ToolHooks {
|
||||
readonly "execute.before": {
|
||||
readonly tool: string
|
||||
readonly inputSchema: JsonSchema.JsonSchema
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpResource,
|
||||
@@ -91,6 +93,8 @@ export interface Data {
|
||||
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
||||
sync(sessionID: string, location?: LocationRef): Promise<void>
|
||||
invalidate(sessionID: string, location?: LocationRef): void
|
||||
reply(input: FormReplyInput, location?: LocationRef): Promise<void>
|
||||
cancel(input: FormCancelInput, location?: LocationRef): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly project: {
|
||||
|
||||
@@ -62,15 +62,6 @@ export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundEr
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
|
||||
"ProjectNotFoundError",
|
||||
{
|
||||
projectID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
|
||||
"AgentNotFoundError",
|
||||
{
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
import { ProjectNotFoundError } from "../errors.js"
|
||||
|
||||
const root = "/api/project"
|
||||
const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"]))
|
||||
|
||||
export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
.add(
|
||||
@@ -19,20 +17,6 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: UpdatePayload,
|
||||
success: Project.Info,
|
||||
error: ProjectNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.update",
|
||||
summary: "Update project",
|
||||
description: "Update project display metadata and workspace commands.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("project.current", `${root}/current`, {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -42,7 +42,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a worktree for a project and run its configured setup script.",
|
||||
description: "Create a worktree for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -46,13 +46,5 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "Project" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
commands: optional(Commands),
|
||||
}).annotate({ identifier: "Project.UpdateInput" })
|
||||
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
|
||||
|
||||
const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
||||
@@ -3,24 +3,10 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
|
||||
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
|
||||
handlers
|
||||
.handle("project.list", () => Project.Service.use((project) => project.list()))
|
||||
.handle("project.update", (ctx) =>
|
||||
Project.Service.use((project) =>
|
||||
project.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new ProjectNotFoundError({
|
||||
projectID: ctx.params.projectID,
|
||||
message: `Project not found: ${ctx.params.projectID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.handle("project.current", () =>
|
||||
Location.Service.use((location) =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -233,7 +233,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
data-timeline-row={props.row._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": input.centered?.(),
|
||||
"md:max-w-[1000px] md:mx-auto": input.centered?.(),
|
||||
"pt-3": props.row._tag === "AssistantPart" && props.row.previousAssistantPart,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1282,7 +1282,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useData, type FormWithLocation } from "../../context/data"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
@@ -41,22 +40,8 @@ function truncate(label: string, max: number) {
|
||||
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
|
||||
}
|
||||
|
||||
function requestOptions(form: FormWithLocation) {
|
||||
if (form.sessionID !== "global" || !form.location) return undefined
|
||||
return {
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent(form.location.directory),
|
||||
...(form.location.workspaceID ? { "x-opencode-workspace": form.location.workspaceID } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: {
|
||||
form: FormWithLocation
|
||||
onReply?: (answer: FormAnswer) => void | Promise<void>
|
||||
onCancel?: () => void | Promise<void>
|
||||
}) {
|
||||
const client = useClient()
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const data = useData()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const themeMode = themes.mode
|
||||
@@ -258,16 +243,9 @@ export function FormPrompt(props: {
|
||||
}
|
||||
|
||||
function reply(answer: FormAnswer) {
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
props.onReply
|
||||
? props.onReply(answer)
|
||||
: client.api.form.reply(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
|
||||
requestOptions(props.form),
|
||||
),
|
||||
)
|
||||
.catch((error: unknown) => setStore("error", errorMessage(error)))
|
||||
void data.session.form
|
||||
.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer }, props.form.location)
|
||||
.catch(showError)
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
@@ -457,11 +435,13 @@ export function FormPrompt(props: {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (props.onCancel) {
|
||||
void props.onCancel()
|
||||
return
|
||||
}
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
void data.session.form
|
||||
.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, props.form.location)
|
||||
.catch(showError)
|
||||
}
|
||||
|
||||
function showError(error: unknown) {
|
||||
setStore("error", errorMessage(error))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useConfig } from "../config"
|
||||
import { copy, copyOnSelectRelease } from "../util/selection"
|
||||
|
||||
export type DialogSize = "medium" | "large" | "xlarge"
|
||||
|
||||
@@ -210,17 +211,6 @@ export function DialogProvider(props: ParentProps) {
|
||||
const copyOnSelectEnabled = () =>
|
||||
(config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select"
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
void clipboard.write(text).then(
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
@@ -231,11 +221,11 @@ export function DialogProvider(props: ParentProps) {
|
||||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copySelection()) return
|
||||
if (!copy(renderer, toast, clipboard)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
|
||||
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
|
||||
@@ -23,6 +23,16 @@ type SelectionKeyEvent = {
|
||||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
export function copyOnSelectRelease(
|
||||
event: { isDragging?: boolean },
|
||||
renderer: Renderer,
|
||||
toast: Toast,
|
||||
clipboard: ClipboardService,
|
||||
): boolean {
|
||||
if (!event.isDragging) return false
|
||||
return copy(renderer, toast, clipboard)
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
@@ -39,7 +49,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
// Keep the highlight. clearSelection() also resets OpenTUI's click
|
||||
// counter, so clearing here would turn a triple-click into a new single-click.
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user