mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 02:56:18 +00:00
Compare commits
21
Commits
cache-tmp
...
pty-groups
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be5a81a7b | ||
|
|
a02b0a4729 | ||
|
|
d8ce27fa29 | ||
|
|
2f740cec5d | ||
|
|
162c3fcebd | ||
|
|
71f81dc0fe | ||
|
|
de388dede4 | ||
|
|
c936acd3fe | ||
|
|
c19186ee54 | ||
|
|
563943c52e | ||
|
|
575bbd6ea1 | ||
|
|
5d9b53b2c7 | ||
|
|
4780248e84 | ||
|
|
43d4968356 | ||
|
|
0164c1c8bc | ||
|
|
6a687398eb | ||
|
|
f4cb9d06c8 | ||
|
|
3e82b1a9fd | ||
|
|
e2a7600a2a | ||
|
|
aa8c1f6dac | ||
|
|
23c3a1461c |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
|
||||
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
|
||||
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
|
||||
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
|
||||
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
|
||||
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
|
||||
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
|
||||
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
|
||||
const AnthropicThinkingBlock = Schema.Struct({
|
||||
type: Schema.tag("thinking"),
|
||||
thinking: Schema.String,
|
||||
signature: Schema.optional(Schema.String),
|
||||
signature: Schema.String,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
})
|
||||
|
||||
@@ -701,6 +701,26 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const requireThinkingSignature = (request: LLMRequest) => {
|
||||
if (request.model.compatibility?.requireSignature !== undefined)
|
||||
return request.model.compatibility.requireSignature
|
||||
const provider = request.model.provider.toLowerCase()
|
||||
const model = request.model.id.toLowerCase()
|
||||
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
|
||||
if (
|
||||
provider === "kimi-for-coding" ||
|
||||
provider === "moonshotai" ||
|
||||
provider === "moonshotai-cn" ||
|
||||
model.startsWith("kimi-") ||
|
||||
baseURL.includes("api.kimi.com/coding") ||
|
||||
baseURL.includes("api.moonshot.ai/anthropic") ||
|
||||
baseURL.includes("api.moonshot.cn/anthropic")
|
||||
)
|
||||
return false
|
||||
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Mid-conversation system messages became available with Opus 4.8 and version
|
||||
// 5 of the other supported Claude families. Treat later family versions as
|
||||
// compatible without assuming that every Anthropic Messages model is Claude.
|
||||
@@ -807,15 +827,30 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
|
||||
// thinking; only signature-less parts carrying redactedData
|
||||
// round-trip as opaque redacted_thinking blocks.
|
||||
// A signature marks visible thinking; only signature-less parts carrying
|
||||
// redactedData round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
}
|
||||
if (typeof signature !== "string" || signature.trim().length === 0) {
|
||||
if (part.text.trim().length === 0) continue
|
||||
if (!requireThinkingSignature(request)) {
|
||||
content.push({ type: "thinking", thinking: part.text, signature: "" })
|
||||
continue
|
||||
}
|
||||
// Without a signature this cannot be a valid thinking block per
|
||||
// the SDK ThinkingBlockParam:3217 — demote to text so the
|
||||
// conversation remains sendable.
|
||||
content.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
})
|
||||
continue
|
||||
}
|
||||
content.push({ type: "thinking", thinking: part.text, signature })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -666,6 +666,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
@@ -683,11 +684,18 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
|
||||
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
|
||||
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
|
||||
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
|
||||
...(options.truncation ? { truncation: options.truncation } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
|
||||
if (configured !== undefined) return configured
|
||||
const disabled = request.toolChoice?.disableParallelToolUse
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
@@ -1186,7 +1194,6 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (
|
||||
event.type === "response.reasoning.done" ||
|
||||
event.type === "response.reasoning_summary_text.done" ||
|
||||
event.type === "response.reasoning_summary.done" ||
|
||||
event.type === "response.reasoning_text.done"
|
||||
) {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
|
||||
@@ -51,7 +51,12 @@ const OpenAIChatFunction = Schema.Struct({
|
||||
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: OpenAIChatFunction,
|
||||
function: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
parameters: JsonObject,
|
||||
strict: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
@@ -133,6 +138,7 @@ export const bodyFields = {
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
tool_stream: Schema.optional(Schema.Boolean),
|
||||
max_completion_tokens: Schema.optional(Schema.Number),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -264,12 +270,18 @@ interface LoweringOptions {
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||
const lowerTool = (
|
||||
tool: ToolDefinition,
|
||||
inputSchema: JsonSchema,
|
||||
options: LoweringOptions,
|
||||
supportsStrictMode: boolean,
|
||||
): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: inputSchema,
|
||||
...(supportsStrictMode ? { strict: false } : {}),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
@@ -528,11 +540,122 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
|
||||
// explicit `compatibility.maxTokensField` is not set. Aligned with
|
||||
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
|
||||
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
|
||||
// require `max_tokens`.
|
||||
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
if (
|
||||
p === "deepseek" ||
|
||||
url.includes("deepseek.com") ||
|
||||
p === "moonshotai" ||
|
||||
url.includes("api.moonshot.ai") ||
|
||||
p === "togetherai" ||
|
||||
url.includes("api.together.") ||
|
||||
p === "zai" ||
|
||||
p === "zai-coding-plan" ||
|
||||
p === "zhipuai" ||
|
||||
p === "zhipuai-coding-plan" ||
|
||||
url.includes("api.z.ai") ||
|
||||
url.includes("open.bigmodel.cn") ||
|
||||
p === "nvidia" ||
|
||||
url.includes("integrate.api.nvidia.com") ||
|
||||
p === "cerebras" ||
|
||||
url.includes("cerebras.ai") ||
|
||||
url.includes("llm.chutes.ai") ||
|
||||
p === "chutes" ||
|
||||
p === "cloudflare-ai-gateway" ||
|
||||
url.includes("gateway.ai.cloudflare.com") ||
|
||||
p === "cloudflare-workers-ai" ||
|
||||
url.includes("api.cloudflare.com")
|
||||
)
|
||||
return "max_tokens"
|
||||
return "max_completion_tokens"
|
||||
}
|
||||
|
||||
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
|
||||
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
|
||||
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
|
||||
const isZai =
|
||||
p === "zai" ||
|
||||
p === "zai-coding-plan" ||
|
||||
p === "zhipuai" ||
|
||||
p === "zhipuai-coding-plan" ||
|
||||
url.includes("api.z.ai") ||
|
||||
url.includes("open.bigmodel.cn")
|
||||
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
|
||||
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
|
||||
const isXai = p === "xai" || url.includes("api.x.ai")
|
||||
const isChutes = p === "chutes" || url.includes("chutes.ai")
|
||||
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
|
||||
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
|
||||
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
|
||||
const isOpencode = p === "opencode" || url.includes("opencode.ai")
|
||||
const isNonStandard =
|
||||
isNvidia ||
|
||||
isCerebras ||
|
||||
isXai ||
|
||||
isTogether ||
|
||||
isChutes ||
|
||||
isDeepSeek ||
|
||||
isZai ||
|
||||
isMoonshot ||
|
||||
isOpencode ||
|
||||
isCloudflareWorkersAI ||
|
||||
isCloudflareAiGateway ||
|
||||
isVercelAiGateway ||
|
||||
isAntLing
|
||||
return !isNonStandard
|
||||
}
|
||||
|
||||
const detectSupportsUsageInStreaming = (): boolean => true
|
||||
|
||||
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
|
||||
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
|
||||
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
|
||||
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
|
||||
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
|
||||
}
|
||||
|
||||
const detectZaiToolStream = (
|
||||
provider: string,
|
||||
baseURL: string | undefined,
|
||||
modelID: string,
|
||||
): boolean => {
|
||||
const p = provider.toLowerCase()
|
||||
const url = (baseURL ?? "").toLowerCase()
|
||||
const isZai =
|
||||
p === "zai" ||
|
||||
p === "zai-coding-plan" ||
|
||||
p === "zhipuai" ||
|
||||
p === "zhipuai-coding-plan" ||
|
||||
url.includes("api.z.ai") ||
|
||||
url.includes("open.bigmodel.cn")
|
||||
if (!isZai) return false
|
||||
const id = modelID.toLowerCase()
|
||||
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
|
||||
return true
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
|
||||
// For providers that support `store`, ensure stateless `store:false` is sent
|
||||
// even when no explicit `providerOptions.store` was supplied, mirroring the
|
||||
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
|
||||
...(supportsStore && options.store === undefined ? { store: false } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
@@ -551,8 +674,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
const provider = String(request.model.provider)
|
||||
const baseURL = request.model.route.endpoint.baseURL
|
||||
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
|
||||
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
|
||||
const supportsUsageInStreaming =
|
||||
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
|
||||
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
|
||||
const zaiToolStream =
|
||||
request.model.compatibility?.zaiToolStream ??
|
||||
detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = request.tools.length > 0
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
@@ -566,11 +700,13 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
options,
|
||||
supportsStrictMode,
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
|
||||
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
|
||||
...(maxTokensField === "max_completion_tokens"
|
||||
? { max_completion_tokens: generation?.maxTokens }
|
||||
: { max_tokens: generation?.maxTokens }),
|
||||
@@ -580,7 +716,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stop: generation?.stop,
|
||||
...lowerOptions(request),
|
||||
...lowerOptions(request, supportsStore),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -111,8 +111,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
extension,
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return {
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -162,7 +164,7 @@ const HOSTED_TOOLS = {
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
if (event.type === "response.reasoning_text.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
|
||||
@@ -155,6 +155,11 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelCompatibility {
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "gpt-4o-mini",
|
||||
stream: true,
|
||||
max_tokens: 30,
|
||||
max_completion_tokens: 30,
|
||||
temperature: 0.5,
|
||||
top_p: 0.9,
|
||||
frequency_penalty: 0.25,
|
||||
|
||||
+9
-3
@@ -7,7 +7,13 @@
|
||||
"route": "cloudflare-workers-ai",
|
||||
"transport": "http",
|
||||
"model": "@cf/openai/gpt-oss-20b",
|
||||
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
|
||||
"tags": [
|
||||
"prefix:cloudflare-workers-ai",
|
||||
"provider:cloudflare-workers-ai",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
]
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -18,7 +24,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
|
||||
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -29,4 +35,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+5
-5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,6 +18,15 @@ const opus48 = AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-opus-4-8" })
|
||||
|
||||
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -564,6 +573,66 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("demotes unsigned reasoning when signatures are required", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileUnsignedReasoning(model)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("infers empty-signature compatibility across Kimi providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const coding = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://compatible.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
const moonshot = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "moonshotai",
|
||||
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
|
||||
auth: Auth.bearer("test"),
|
||||
})
|
||||
.model({ id: "kimi-k2.6" })
|
||||
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
|
||||
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
|
||||
|
||||
expect(codingPrepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
|
||||
},
|
||||
])
|
||||
expect(moonshotPrepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets an explicit signature requirement override inference", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = AnthropicMessages.route
|
||||
.with({
|
||||
provider: "kimi-for-coding",
|
||||
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
})
|
||||
.model({ id: "k3", compatibility: { requireSignature: true } })
|
||||
const prepared = yield* compileUnsignedReasoning(compatible)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
@@ -55,7 +55,8 @@ describe("OpenAI Chat route", () => {
|
||||
],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: 20,
|
||||
store: false,
|
||||
max_completion_tokens: 20,
|
||||
temperature: 0,
|
||||
})
|
||||
}),
|
||||
@@ -325,7 +326,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "user", content: "What is the weather?" },
|
||||
@@ -345,6 +346,7 @@ describe("OpenAI Chat route", () => {
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
store: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
baseURL: "https://api.deepseek.test/v1/",
|
||||
query: { "api-version": "2026-01-01" },
|
||||
})
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
|
||||
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
|
||||
},
|
||||
],
|
||||
tool_choice: "required",
|
||||
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
@@ -158,6 +158,29 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepare = (provider: string, baseURL: string, id: string) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
|
||||
prompt: "Use a tool.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
|
||||
}),
|
||||
)
|
||||
|
||||
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
|
||||
expect(current.body).toMatchObject({ tool_stream: true })
|
||||
|
||||
const legacy = yield* Effect.all(
|
||||
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
|
||||
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
|
||||
),
|
||||
)
|
||||
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches AI SDK compatible tool request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -180,7 +203,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{ role: "user", content: "What is the weather?" },
|
||||
@@ -204,6 +227,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -132,6 +132,40 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers canonical parallel tool control", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Read the file.",
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "read",
|
||||
description: "Read a file.",
|
||||
inputSchema: { type: "object" },
|
||||
}),
|
||||
],
|
||||
toolChoice: { type: "auto", disableParallelToolUse: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.parallel_tool_calls).toBe(false)
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "read",
|
||||
description: "Read a file.",
|
||||
parameters: { type: "object" },
|
||||
strict: false,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -258,6 +258,31 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the canonical parallel tool setting with provider-option precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
toolChoice: { type: "auto", disableParallelToolUse: true },
|
||||
}),
|
||||
)
|
||||
const enabled = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
toolChoice: { type: "auto", disableParallelToolUse: false },
|
||||
}),
|
||||
)
|
||||
const overridden = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
toolChoice: { type: "auto", disableParallelToolUse: true },
|
||||
providerOptions: { parallelToolCalls: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(disabled.body.parallel_tool_calls).toBe(false)
|
||||
expect(enabled.body.parallel_tool_calls).toBe(true)
|
||||
expect(overridden.body.parallel_tool_calls).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to developer messages in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
|
||||
const messages = Reflect.get(second, "messages")
|
||||
const tools = Reflect.get(second, "tools")
|
||||
|
||||
expect(Reflect.get(second, "max_tokens")).toBe(50)
|
||||
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
|
||||
expect(Reflect.get(second, "tool_choice")).toBe("auto")
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(
|
||||
|
||||
@@ -20,45 +20,47 @@ const messages = [
|
||||
},
|
||||
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "session-message-revert",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Session message revert",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 4 },
|
||||
}
|
||||
const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
vcs: "git",
|
||||
name: "session-message-revert",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
pageMessages: () => ({ items: messages }),
|
||||
}
|
||||
|
||||
test("reverts directly to the selected user message", async ({ page }) => {
|
||||
const staged: { sessionID: string; messageID: string }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
vcs: "git",
|
||||
name: "session-message-revert",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "session-message-revert",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Session message revert",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 4 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
...fixture,
|
||||
sessions: [session],
|
||||
onRevertStage: (input) => staged.push(input),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
@@ -77,3 +79,19 @@ test("reverts directly to the selected user message", async ({ page }) => {
|
||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
|
||||
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
|
||||
})
|
||||
|
||||
test("hides revert actions in a child session", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: [
|
||||
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
|
||||
{ ...session, parentID: "ses_parent" },
|
||||
],
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Session message revert")
|
||||
|
||||
const message = page.locator('[data-message-id="msg_second"]')
|
||||
await message.hover()
|
||||
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -126,6 +126,8 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
|
||||
const composer = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await composer.click()
|
||||
await expect(composer).toBeFocused()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
|
||||
@@ -274,15 +274,8 @@ async function sendCommand(
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
id: value.id,
|
||||
command: command.command,
|
||||
arguments: command.arguments,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
text: command.arguments,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
skills: request.skills,
|
||||
|
||||
@@ -193,9 +193,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
actions.session.layout.view().terminal.open()
|
||||
if (terminal.all().length > 0) terminal.new({ focus: true })
|
||||
if (terminal.all().length === 0) terminal.requestFocus()
|
||||
actions.session.layout.view().terminal.open()
|
||||
}
|
||||
|
||||
const closeTerminal = () => {
|
||||
@@ -361,8 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
actions.session.layout.view().terminal.close()
|
||||
return
|
||||
}
|
||||
terminal.requestFocus(terminal.active())
|
||||
actions.session.layout.view().terminal.open()
|
||||
terminal.requestFocus(terminal.active())
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
|
||||
@@ -156,6 +156,7 @@ export function createActiveSessionRegion(input: {
|
||||
session: input.session,
|
||||
setActiveMessage: input.timeline.actions.setActiveMessage,
|
||||
})
|
||||
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
|
||||
useComposerCommands()
|
||||
useSessionCommands({
|
||||
session: input.session,
|
||||
@@ -178,7 +179,13 @@ export function createActiveSessionRegion(input: {
|
||||
|
||||
return {
|
||||
actions: {
|
||||
timeline: { revert: ({ messageID }) => revert.to(messageID), openAttachment } satisfies SessionUserActions,
|
||||
timeline: {
|
||||
get revert() {
|
||||
if (input.session.data.isChild()) return
|
||||
return revertMessage
|
||||
},
|
||||
openAttachment,
|
||||
} satisfies SessionUserActions,
|
||||
},
|
||||
region: {
|
||||
centered: input.screen.centered,
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function streamTurn(input: {
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly writeTextFile: boolean
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
@@ -345,6 +346,11 @@ export async function streamTurn(input: {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
})
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
|
||||
@@ -326,6 +326,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
action: prepared.command !== undefined,
|
||||
control,
|
||||
connectionSignal: input.connection.signal,
|
||||
sessionSignal: state.abort.signal,
|
||||
@@ -377,9 +378,8 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: session.id,
|
||||
id: prompt.start.id,
|
||||
command: prompt.command.name,
|
||||
arguments: prompt.slash?.args,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
},
|
||||
|
||||
@@ -121,3 +121,42 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
}
|
||||
})
|
||||
|
||||
test("acp action resolves without prompt lifecycle events", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await streamTurn({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
connection: {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_action" },
|
||||
writeTextFile: false,
|
||||
action: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {},
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ stopReason: "end_turn" })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -87,7 +87,6 @@ export const planAgent = {
|
||||
export const reviewCommand = {
|
||||
name: "review",
|
||||
description: "Review changes",
|
||||
template: "",
|
||||
} satisfies CommandInfo
|
||||
|
||||
export const verifySkill = {
|
||||
|
||||
@@ -255,18 +255,14 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
|
||||
|
||||
export type SessionCommandInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
readonly arguments?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
|
||||
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
||||
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type SessionCommandOutput = SessionInbox.User
|
||||
export type SessionCommandOutput = void
|
||||
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
|
||||
|
||||
export type SessionSkillInput = {
|
||||
@@ -1699,6 +1695,16 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
|
||||
export type WorkspaceDestroyOutput = Workspace.DestroyResult
|
||||
export type WorkspaceDestroyOperation<E = never> = (
|
||||
input: WorkspaceDestroyInput,
|
||||
) => Effect.Effect<WorkspaceDestroyOutput, E>
|
||||
|
||||
export interface WorkspaceApi<E = never> {
|
||||
readonly destroy: WorkspaceDestroyOperation<E>
|
||||
}
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1816,6 +1822,7 @@ export interface AppApi<E = never> {
|
||||
readonly shell: ShellApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly workspace: WorkspaceApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
|
||||
@@ -214,6 +214,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -440,21 +442,14 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
id: input["id"],
|
||||
command: input["command"],
|
||||
arguments: input["arguments"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
|
||||
@@ -1278,6 +1273,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
refresh: EndpointWorktreeRefresh(raw),
|
||||
})
|
||||
|
||||
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
|
||||
preserveEffect<WorkspaceDestroyOutput>()(
|
||||
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({ destroy: EndpointWorkspaceDestroy(raw) })
|
||||
|
||||
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
|
||||
preserveEffect<VcsGetOutput>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1368,6 +1370,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
shell: adaptGroupShell(raw["server.shell"]),
|
||||
reference: adaptGroupReference(raw["server.reference"]),
|
||||
worktree: adaptGroupWorktree(raw["server.worktree"]),
|
||||
workspace: adaptGroupWorkspace(raw["server.workspace"]),
|
||||
vcs: adaptGroupVcs(raw["server.vcs"]),
|
||||
debug: adaptGroupDebug(raw["server.debug"]),
|
||||
migration: adaptGroupMigration(raw["server.migration"]),
|
||||
|
||||
@@ -210,6 +210,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -631,28 +633,24 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionCommandOutput }>(
|
||||
request<SessionCommandOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
||||
body: {
|
||||
id: input["id"],
|
||||
command: input["command"],
|
||||
arguments: input["arguments"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
text: input["text"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
delivery: input["delivery"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 400, 404, 500, 401],
|
||||
empty: false,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 500, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
),
|
||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSkillOutput>(
|
||||
{
|
||||
@@ -1770,6 +1768,19 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
workspace: {
|
||||
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
|
||||
request<WorkspaceDestroyOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/workspace/${encodeURIComponent(input.workspaceID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsGetOutput>(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"question",
|
||||
"reference",
|
||||
"worktree",
|
||||
"workspace",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
@@ -280,6 +281,21 @@ test("worktree methods use the global project contract", async () => {
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("workspace.destroy returns the transition result", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ destroyed: false })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.workspace.destroy({ workspaceID: "wrk_missing" })).toEqual({ destroyed: false })
|
||||
expect(request?.method).toBe("DELETE")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/workspace/wrk_missing")
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const shell = {
|
||||
|
||||
+68
-223
@@ -1,26 +1,32 @@
|
||||
export * as Command from "./command.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Location } from "./location.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export { Event } from "@opencode-ai/schema/command"
|
||||
|
||||
export type Evaluation = {
|
||||
readonly text: string
|
||||
export interface Invocation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Types.DeepMutable<Info>>
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (definition: Definition) => void
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
|
||||
@@ -28,234 +34,73 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
|
||||
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
|
||||
command: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly evaluate: (input: {
|
||||
readonly execute: (input: {
|
||||
readonly name: string
|
||||
readonly arguments?: string
|
||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
||||
readonly invocation: Invocation
|
||||
}) => Effect.Effect<void, NotFoundError | ExecutionError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
|
||||
const layer = () =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const bus = yield* Bus.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.commands.delete(name)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
|
||||
const mcpCommands = Effect.fnUntraced(function* () {
|
||||
return (yield* mcp.prompts()).map((prompt) =>
|
||||
Info.make({
|
||||
name: mcpCommandName(prompt.server, prompt.name),
|
||||
template: "",
|
||||
description: prompt.description,
|
||||
}),
|
||||
)
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Map<string, Definition>, Draft>({
|
||||
name: "command",
|
||||
initial: () => new Map(),
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("Command.get")(function* (name) {
|
||||
const command = staticCommand(name)
|
||||
if (command) return command
|
||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
||||
return Service.of({
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("Command.get")((name) =>
|
||||
Effect.sync(() => {
|
||||
const definition = state.get().get(name)
|
||||
return definition ? info(definition) : undefined
|
||||
}),
|
||||
list: Effect.fn("Command.list")(function* () {
|
||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
||||
const names = new Set(commands.map((command) => command.name))
|
||||
return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
|
||||
}),
|
||||
evaluate: Effect.fn("Command.evaluate")(function* (input) {
|
||||
const command = staticCommand(input.name)
|
||||
if (command)
|
||||
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
(prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
|
||||
)
|
||||
if (!prompt)
|
||||
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
||||
const result = yield* mcp
|
||||
.prompt({
|
||||
server: prompt.server,
|
||||
name: prompt.name,
|
||||
args: Object.fromEntries(
|
||||
(prompt.arguments ?? []).map((argument, index) => [
|
||||
argument.name,
|
||||
parseArguments(input.arguments ?? "")[index] ?? "",
|
||||
]),
|
||||
),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", () =>
|
||||
Effect.fail(
|
||||
new EvaluationError({
|
||||
command: input.name,
|
||||
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!result)
|
||||
return yield* new EvaluationError({
|
||||
command: input.name,
|
||||
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
|
||||
})
|
||||
return {
|
||||
text: result.messages
|
||||
.map((message) => promptMessageText(message.content))
|
||||
.join("\n")
|
||||
.trim(),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function evaluateTemplate(
|
||||
command: string,
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const expanded = evaluateArguments(template, input)
|
||||
return { text: yield* evaluateShell(command, expanded, services) }
|
||||
})
|
||||
}
|
||||
|
||||
function evaluateArguments(template: string, input: string) {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
|
||||
return `${withArguments}\n\n${input}`.trim()
|
||||
return withArguments.trim()
|
||||
}
|
||||
|
||||
const evaluateShell = Effect.fnUntraced(function* (
|
||||
command: string,
|
||||
text: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{
|
||||
combineOutput: true,
|
||||
},
|
||||
),
|
||||
list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))),
|
||||
execute: Effect.fn("Command.execute")(function* (input) {
|
||||
const definition = state.get().get(input.name)
|
||||
if (!definition)
|
||||
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
||||
return yield* definition.execute(input.invocation).pipe(
|
||||
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
|
||||
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new EvaluationError({
|
||||
command,
|
||||
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
function promptMessageText(content: unknown) {
|
||||
if (typeof content === "string") return content
|
||||
if (!content || typeof content !== "object") return ""
|
||||
if (!("type" in content) || content.type !== "text") return ""
|
||||
if (!("text" in content) || typeof content.text !== "string") return ""
|
||||
return content.text
|
||||
}
|
||||
|
||||
function mcpCommandName(server: string, prompt: string) {
|
||||
return `${sanitize(server)}:${sanitize(prompt)}`
|
||||
}
|
||||
|
||||
function sanitize(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
|
||||
layer,
|
||||
deps: [Bus.node],
|
||||
})
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "string") return error
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
|
||||
return error.message
|
||||
return "Command execution failed"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Command } from "../../command.js"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -23,6 +29,9 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -51,17 +60,41 @@ export const Plugin = define({
|
||||
yield* ctx.command.transform((draft) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
draft.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined)
|
||||
item.model = {
|
||||
id: command.model.model,
|
||||
providerID: command.model.providerID,
|
||||
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
config,
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,3 +147,67 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly config: Config.Interface
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.preferred()
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as MCP from "./index.js"
|
||||
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
@@ -19,6 +19,7 @@ import { State } from "../state.js"
|
||||
import type { MCPClient } from "./client.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
|
||||
export type ServerName = typeof ServerName.Type
|
||||
|
||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
||||
@@ -453,7 +454,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.map((defs) => {
|
||||
entry.prompts = defs.map((def) => toPrompt(name, def))
|
||||
}),
|
||||
Effect.andThen(bus.publish(Command.Event.Updated, {})),
|
||||
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
|
||||
)
|
||||
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
@@ -572,7 +573,7 @@ export const layer = (options?: Options) =>
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Group } from "./persistent-pty/group.js"
|
||||
@@ -0,0 +1,103 @@
|
||||
export * as Group from "./group.js"
|
||||
|
||||
import { Group } from "@opencode-ai/schema/group"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { KV } from "../kv.js"
|
||||
|
||||
export const ID = Group.ID
|
||||
export type ID = Group.ID
|
||||
export const Item = Group.Item
|
||||
export type Item = Group.Item
|
||||
export const Info = Group.Info
|
||||
export type Info = Group.Info
|
||||
export const Event = Group.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly create: (items?: ReadonlyArray<Item>) => Effect.Effect<Info>
|
||||
readonly set: (group: Info) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Group") {}
|
||||
|
||||
const key = "group:v1"
|
||||
const Document = Schema.Array(Info)
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const bus = yield* Bus.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const list = Effect.fn("Group.list")(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
return Schema.is(Document)(value) ? value : []
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get: Effect.fn("Group.get")(function* (id) {
|
||||
return (yield* list()).find((group) => group.id === id)
|
||||
}),
|
||||
create: Effect.fn("Group.create")(function* (items = []) {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const group = Info.make({ id: ID.create(), items: Array.from(items) })
|
||||
yield* kv.set(key, (yield* list()).concat(group))
|
||||
return group
|
||||
}),
|
||||
)
|
||||
}),
|
||||
set: Effect.fn("Group.set")(function* (group) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* list()
|
||||
const index = groups.findIndex((item) => item.id === group.id)
|
||||
yield* kv.set(
|
||||
key,
|
||||
index === -1 ? groups.concat(group) : groups.map((item) => (item.id === group.id ? group : item)),
|
||||
)
|
||||
const previous = groups[index]
|
||||
if (!previous) return
|
||||
yield* Effect.forEach(
|
||||
group.items.filter(
|
||||
(item) => !previous.items.some((current) => current.type === item.type && current.id === item.id),
|
||||
),
|
||||
(item) => bus.publish(Event.ItemAdded, { groupID: group.id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
previous.items.filter(
|
||||
(item) => !group.items.some((next) => next.type === item.type && next.id === item.id),
|
||||
),
|
||||
(item) => bus.publish(Event.ItemRemoved, { groupID: group.id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
remove: Effect.fn("Group.remove")(function* (id) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* list()
|
||||
const group = groups.find((group) => group.id === id)
|
||||
yield* kv.set(key, groups.filter((group) => group.id !== id))
|
||||
if (!group) return
|
||||
yield* Effect.forEach(
|
||||
group.items,
|
||||
(item) => bus.publish(Event.ItemRemoved, { groupID: id, item }),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Bus.node] })
|
||||
@@ -1,8 +1,10 @@
|
||||
export * as CommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Location } from "../location.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
@@ -10,15 +12,104 @@ export const Plugin = define({
|
||||
id: "opencode.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loaded = { prompts: [] as MCP.Prompt[] }
|
||||
yield* bus
|
||||
.subscribe(MCP.PromptsChanged)
|
||||
.pipe(
|
||||
Stream.runForEach(() =>
|
||||
mcp.prompts().pipe(
|
||||
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.prompts = yield* mcp.prompts()
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
draft.add({
|
||||
name: "init",
|
||||
description: "guided AGENTS.md setup",
|
||||
execute: (input) =>
|
||||
ctx.session
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
draft.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
draft.add({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
execute: (input) =>
|
||||
ctx.session
|
||||
.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
for (const prompt of loaded.prompts) {
|
||||
draft.add({
|
||||
name: mcpCommandName(prompt.server, prompt.name),
|
||||
description: prompt.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.prompt({
|
||||
server: prompt.server,
|
||||
name: prompt.name,
|
||||
args: Object.fromEntries(
|
||||
(prompt.arguments ?? []).map((argument, index) => [
|
||||
argument.name,
|
||||
parseArguments(input.prompt.text)[index] ?? "",
|
||||
]),
|
||||
),
|
||||
})
|
||||
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: result.messages
|
||||
.map((message) => promptMessageText(message.content))
|
||||
.join("\n")
|
||||
.trim(),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function append(template: string, input: string) {
|
||||
return [template, input.trim()].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
function promptMessageText(content: unknown) {
|
||||
if (typeof content === "string") return content
|
||||
if (!content || typeof content !== "object") return ""
|
||||
if (!("type" in content) || content.type !== "text") return ""
|
||||
if (!("text" in content) || typeof content.text !== "string") return ""
|
||||
return content.text
|
||||
}
|
||||
|
||||
function mcpCommandName(server: string, prompt: string) {
|
||||
return `${sanitize(server)}:${sanitize(prompt)}`
|
||||
}
|
||||
|
||||
function sanitize(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
|
||||
@@ -246,26 +246,14 @@ export interface Interface {
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
arguments?: string
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<
|
||||
SessionInbox.User,
|
||||
| NotFoundError
|
||||
| PromptConflictError
|
||||
| AttachmentError
|
||||
| SkillNotFoundError
|
||||
| Command.NotFoundError
|
||||
| Command.EvaluationError
|
||||
>
|
||||
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
readonly shell: (input: {
|
||||
id?: Event.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -655,35 +643,19 @@ const layer = Layer.effect(
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const command = yield* commands.get(input.command)
|
||||
if (!command)
|
||||
return yield* new Command.NotFoundError({
|
||||
command: input.command,
|
||||
message: `Command not found: ${input.command}`,
|
||||
})
|
||||
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
||||
|
||||
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
|
||||
const agent = command.agent ?? input.agent
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (!command.agent) return undefined
|
||||
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* agents.get(Agent.ID.make(command.agent))
|
||||
})
|
||||
const model = command.model ?? commandAgent?.model ?? input.model
|
||||
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
|
||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||
|
||||
return yield* result.prompt({
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
text: evaluated.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
delivery: input.delivery,
|
||||
resume: input.resume,
|
||||
const delivery = input.delivery ?? "steer"
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.sessionID,
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
},
|
||||
delivery,
|
||||
},
|
||||
})
|
||||
}),
|
||||
shell: Effect.fn("Session.shell")(function* (input) {
|
||||
|
||||
@@ -35,9 +35,11 @@ export interface Interface {
|
||||
readonly connect: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
readonly destroy: (
|
||||
workspaceID: ID,
|
||||
) => Effect.Effect<void, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
|
||||
/** Makes the workspace absent; reports whether this call destroyed an existing workspace. */
|
||||
readonly destroy: (workspaceID: ID) => Effect.Effect<
|
||||
Workspace.DestroyResult,
|
||||
WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
|
||||
>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
@@ -79,13 +81,16 @@ const layer = (options: Options) =>
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
|
||||
|
||||
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
|
||||
const row = yield* db
|
||||
const find = (workspaceID: ID) =>
|
||||
db
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
|
||||
const row = yield* find(workspaceID)
|
||||
if (!row) return yield* new NotFound({ workspaceID })
|
||||
return row
|
||||
})
|
||||
@@ -267,9 +272,10 @@ const layer = (options: Options) =>
|
||||
attempts.delete(workspaceID)
|
||||
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
|
||||
}
|
||||
yield* locks.withLock(workspaceID)(
|
||||
return yield* locks.withLock(workspaceID)(
|
||||
Effect.gen(function* () {
|
||||
const row = yield* load(workspaceID)
|
||||
const row = yield* find(workspaceID)
|
||||
if (!row) return { destroyed: false }
|
||||
const connection = connections.get(workspaceID)
|
||||
connections.delete(workspaceID)
|
||||
if (connection) yield* Scope.close(connection.scope, Exit.void)
|
||||
@@ -284,6 +290,7 @@ const layer = (options: Options) =>
|
||||
),
|
||||
)
|
||||
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
|
||||
return { destroyed: true }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,77 +1,71 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Command.node, [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Command.node))
|
||||
|
||||
describe("Command", () => {
|
||||
it.effect("applies command transforms and preserves later overrides", () =>
|
||||
it.effect("registers and executes callback commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((editor) => {
|
||||
editor.update("review", (command) => {
|
||||
command.template = "First"
|
||||
command.description = "Review code"
|
||||
})
|
||||
editor.update("review", (command) => {
|
||||
command.template = "Second"
|
||||
command.model = {
|
||||
id: Model.ID.make("claude"),
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
}
|
||||
const calls: Command.Invocation[] = []
|
||||
yield* command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "goal",
|
||||
description: "Manage the session goal",
|
||||
execute: (input) => Effect.sync(() => calls.push(input)),
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* command.get("review")).toEqual(
|
||||
Command.Info.make({
|
||||
name: "review",
|
||||
template: "Second",
|
||||
description: "Review code",
|
||||
model: {
|
||||
id: Model.ID.make("claude"),
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
},
|
||||
}),
|
||||
expect(yield* command.get("goal")).toEqual(
|
||||
Command.Info.make({ name: "goal", description: "Manage the session goal" }),
|
||||
)
|
||||
expect(yield* command.list()).toEqual([
|
||||
Command.Info.make({
|
||||
name: "review",
|
||||
template: "Second",
|
||||
description: "Review code",
|
||||
model: {
|
||||
id: Model.ID.make("claude"),
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
},
|
||||
}),
|
||||
])
|
||||
const invocation = {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
|
||||
delivery: "steer" as const,
|
||||
}
|
||||
yield* command.execute({ name: "goal", invocation })
|
||||
expect(calls).toEqual([invocation])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates command template shell blocks", () =>
|
||||
it.effect("replaces commands with later definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((editor) => {
|
||||
editor.update("review", (command) => {
|
||||
command.template = "Output: !`echo command-output`"
|
||||
yield* command.transform((draft) => {
|
||||
draft.add({ name: "goal", description: "First", execute: () => Effect.void })
|
||||
draft.add({ name: "goal", description: "Second", execute: () => Effect.void })
|
||||
})
|
||||
|
||||
expect(yield* command.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns callback error messages without stack traces", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
yield* command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "fail",
|
||||
execute: () => Effect.fail(new Error("command failed")),
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
||||
const error = yield* command
|
||||
.execute({
|
||||
name: "fail",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toMatchObject({ _tag: "Command.ExecutionError", message: "command failed" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -15,11 +17,11 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
@@ -28,12 +30,25 @@ import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const shellLayer = Layer.succeed(
|
||||
ShellSelect.Service,
|
||||
ShellSelect.Service.of({
|
||||
preferred: () => Effect.succeed("sh"),
|
||||
transform: () => Effect.die("unused shell.transform"),
|
||||
reload: () => Effect.die("unused shell.reload"),
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
@@ -65,6 +80,7 @@ Review files`,
|
||||
const bus = yield* Bus.Service
|
||||
const update = yield* bus.publish(Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
const prompts: { text: string; files?: readonly { readonly uri: string }[]; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -73,6 +89,20 @@ Review files`,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -89,28 +119,46 @@ Review files`,
|
||||
expect(yield* command.list()).toEqual([
|
||||
Command.Info.make({
|
||||
name: "review",
|
||||
template: "Review files",
|
||||
description: "File review",
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
model: {
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
id: Model.ID.make("claude"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
},
|
||||
subtask: true,
|
||||
}),
|
||||
Command.Info.make({ name: "empty", template: "" }),
|
||||
Command.Info.make({ name: "nested/docs", template: "Write docs" }),
|
||||
Command.Info.make({ name: "empty" }),
|
||||
Command.Info.make({ name: "nested/docs" }),
|
||||
])
|
||||
yield* command.execute({
|
||||
name: "nested/docs",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
text: "Write docs\n\ndetails",
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "commands", "review.md"), markdown("Review again", "Review again")),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* PubSub.publish(updates, update)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* command.get("review"))?.template === "Review again") break
|
||||
if ((yield* command.get("review"))?.description === "Review again") break
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
expect((yield* command.get("review"))?.template).toBe("Review again")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review again")
|
||||
yield* command.execute({
|
||||
name: "review",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "latest" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(prompts.at(-1)?.text).toBe("Review again\n\nlatest")
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -193,11 +241,13 @@ Review files`,
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(reloads).toBe(1)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review twice"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
|
||||
)
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* advance(() => reloads >= 2)
|
||||
expect(reloads).toBe(2)
|
||||
expect((yield* command.get("review"))?.template).toBe("Review twice")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review twice")
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
),
|
||||
),
|
||||
@@ -232,10 +282,12 @@ Review files`,
|
||||
expect(reloads).toBe(0)
|
||||
|
||||
// The feed stays live after unrelated updates.
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review related"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
|
||||
)
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect((yield* command.get("review"))?.template).toBe("Review related")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review related")
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
),
|
||||
),
|
||||
@@ -272,28 +324,47 @@ describeNative("ConfigCommandPlugin native watcher", () => {
|
||||
yield* watchReady(config, global)
|
||||
|
||||
const created = yield* nextCommandUpdate(bus)
|
||||
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native")
|
||||
yield* fs.writeFileString(
|
||||
path.join(global, "commands", "review.md"),
|
||||
markdown("Review native", "Review native"),
|
||||
)
|
||||
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
|
||||
expect((yield* command.get("review"))?.template).toBe("Review native")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review native")
|
||||
|
||||
const updated = yield* nextCommandUpdate(bus)
|
||||
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again")
|
||||
yield* fs.writeFileString(
|
||||
path.join(global, "commands", "review.md"),
|
||||
markdown("Review native again", "Review native again"),
|
||||
)
|
||||
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
|
||||
expect((yield* command.get("review"))?.template).toBe("Review native again")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review native again")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [
|
||||
[
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Config.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Global.node,
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
ShellSelect.node,
|
||||
]),
|
||||
[
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[ShellSelect.node, shellLayer],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
@@ -337,6 +408,10 @@ function directoryEntry(directory: string) {
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
function markdown(description: string, template: string) {
|
||||
return `---\ndescription: ${description}\n---\n${template}`
|
||||
}
|
||||
|
||||
function sourceCases() {
|
||||
return [
|
||||
{
|
||||
@@ -345,33 +420,37 @@ function sourceCases() {
|
||||
mutate: (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const file = path.join(directory, "review.md")
|
||||
await fs.writeFile(file, "Review created")
|
||||
await fs.writeFile(file, markdown("Review created", "Review created"))
|
||||
return [{ type: "create" as const, path: file }]
|
||||
}),
|
||||
verify: (command: Command.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* command.get("review"))?.template).toBe("Review created")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review created")
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "updated",
|
||||
prepare: (directory: string) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review first")),
|
||||
Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
|
||||
),
|
||||
mutate: (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const file = path.join(directory, "review.md")
|
||||
await fs.writeFile(file, "Review updated")
|
||||
await fs.writeFile(file, markdown("Review updated", "Review updated"))
|
||||
return [{ type: "update" as const, path: file }]
|
||||
}),
|
||||
verify: (command: Command.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* command.get("review"))?.template).toBe("Review updated")
|
||||
expect((yield* command.get("review"))?.description).toBe("Review updated")
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "renamed",
|
||||
prepare: (directory: string) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review renamed")),
|
||||
Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review renamed", "Review renamed")),
|
||||
),
|
||||
mutate: (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const previous = path.join(directory, "review.md")
|
||||
@@ -385,7 +464,7 @@ function sourceCases() {
|
||||
verify: (command: Command.Interface) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* command.get("review")).toBeUndefined()
|
||||
expect((yield* command.get("release"))?.template).toBe("Review renamed")
|
||||
expect((yield* command.get("release"))?.description).toBe("Review renamed")
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Group } from "@opencode-ai/core/persistent-pty"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Group.node, KV.node, Bus.node])))
|
||||
|
||||
describe("Group", () => {
|
||||
it.effect("persists ordered groups in one versioned KV document", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const kv = yield* KV.Service
|
||||
const created = yield* groups.create([
|
||||
{ type: "session", id: Session.ID.make("ses_one") },
|
||||
{ type: "terminal", id: Pty.ID.make("pty_one") },
|
||||
])
|
||||
|
||||
expect(yield* groups.get(created.id)).toEqual(created)
|
||||
expect(yield* groups.list()).toEqual([created])
|
||||
expect(yield* kv.get("group:v1")).toEqual([created])
|
||||
|
||||
const updated = Group.Info.make({
|
||||
id: created.id,
|
||||
items: [{ type: "terminal", id: Pty.ID.make("pty_two") }],
|
||||
})
|
||||
yield* groups.set(updated)
|
||||
expect(yield* groups.list()).toEqual([updated])
|
||||
|
||||
yield* groups.remove(created.id)
|
||||
expect(yield* groups.get(created.id)).toBeUndefined()
|
||||
expect(yield* kv.get("group:v1")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes concurrent document mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
yield* Effect.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
groups.create([{ type: "session", id: Session.ID.make(`ses_${index}`) }]),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(yield* groups.list()).toHaveLength(20)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes every removed group item", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
|
||||
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
|
||||
const group = yield* groups.create([session, terminal])
|
||||
const events = yield* bus
|
||||
.subscribe(Group.Event.ItemRemoved)
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* groups.set(Group.Info.make({ id: group.id, items: [session] }))
|
||||
yield* groups.remove(group.id)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(events)).map((event) => event.data)).toEqual([
|
||||
{ groupID: group.id, item: terminal },
|
||||
{ groupID: group.id, item: session },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes every added group item", () =>
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const bus = yield* Bus.Service
|
||||
const session = { type: "session" as const, id: Session.ID.make("ses_one") }
|
||||
const terminal = { type: "terminal" as const, id: Pty.ID.make("pty_one") }
|
||||
const group = yield* groups.create([session])
|
||||
const event = yield* bus.subscribe(Group.Event.ItemAdded).pipe(Stream.runHead, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* groups.set(Group.Info.make({ id: group.id, items: [session, terminal] }))
|
||||
|
||||
expect((yield* Fiber.join(event)).valueOrUndefined?.data).toEqual({ groupID: group.id, item: terminal })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { DateTime } from "effect"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
@@ -15,12 +23,18 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Command.node, [[Location.node, locationLayer]]))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("CommandPlugin.Plugin", () => {
|
||||
it.effect("registers built-in init and review commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
|
||||
yield* CommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -28,6 +42,20 @@ describe("CommandPlugin.Plugin", () => {
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, files: input.files })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
@@ -40,11 +68,24 @@ describe("CommandPlugin.Plugin", () => {
|
||||
name: "init",
|
||||
description: "guided AGENTS.md setup",
|
||||
})
|
||||
expect((yield* command.get("init"))?.template).toContain("`/repo`")
|
||||
expect(yield* command.get("review")).toMatchObject({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
})
|
||||
yield* command.execute({
|
||||
name: "init",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([
|
||||
{
|
||||
text: expect.stringContaining("extra context"),
|
||||
files: [{ uri: "file:///tmp/context.md" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
|
||||
yield* workspace.destroy(workspaceID)
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
|
||||
expect(calls).toEqual([{ operation: "destroy", binding: null }])
|
||||
expect(
|
||||
yield* Database.Service.use(({ db }) =>
|
||||
@@ -104,6 +104,27 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("succeeds without calling the driver when the workspace does not exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = Workspace.ID.create()
|
||||
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports whether destroy removed an existing workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
const workspaceID = yield* workspace.create("fake")
|
||||
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
|
||||
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
|
||||
expect(calls).toEqual([{ operation: "destroy", binding: null }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts eager provisioning in the background and lets callers join it", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspace = yield* Workspace.Service
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose"
|
||||
import { createAppAuth } from "@octokit/auth-app"
|
||||
import { Octokit } from "@octokit/rest"
|
||||
import { Resource } from "sst"
|
||||
import { parseRepositoryClaim } from "./github"
|
||||
|
||||
type Env = {
|
||||
SYNC_SERVER: DurableObjectNamespace<SyncServer>
|
||||
@@ -269,42 +270,41 @@ export default new Hono<{ Bindings: Env }>()
|
||||
|
||||
// verify token
|
||||
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
|
||||
let owner, repo
|
||||
let repository: ReturnType<typeof parseRepositoryClaim>
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: GITHUB_ISSUER,
|
||||
audience: EXPECTED_AUDIENCE,
|
||||
})
|
||||
const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main'
|
||||
const parts = sub.split(":")[1].split("/")
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
repository = parseRepositoryClaim(payload)
|
||||
} catch (err) {
|
||||
console.error("Token verification failed:", err)
|
||||
return c.json({ error: "Invalid or expired token" }, { status: 403 })
|
||||
}
|
||||
|
||||
// Create app JWT token
|
||||
const auth = createAppAuth({
|
||||
appId: Resource.GITHUB_APP_ID.value,
|
||||
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
|
||||
})
|
||||
const appAuth = await auth({ type: "app" })
|
||||
|
||||
// Lookup installation
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
try {
|
||||
const auth = createAppAuth({
|
||||
appId: Resource.GITHUB_APP_ID.value,
|
||||
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
|
||||
})
|
||||
const appAuth = await auth({ type: "app" })
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner: repository.owner,
|
||||
repo: repository.repo,
|
||||
})
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
return c.json({ token: installationAuth.token })
|
||||
} catch (error) {
|
||||
console.error("GitHub App token exchange failed:", error)
|
||||
return c.json(
|
||||
{ error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { JWTPayload } from "jose"
|
||||
|
||||
export function parseRepositoryClaim(payload: JWTPayload) {
|
||||
const claim = payload.repository
|
||||
if (typeof claim !== "string") throw new Error("Repository claim is missing")
|
||||
|
||||
const parts = claim.split("/")
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository claim is invalid")
|
||||
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseRepositoryClaim } from "../src/github"
|
||||
|
||||
describe("parseRepositoryClaim", () => {
|
||||
test("reads repository identity with a legacy subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repo:octocat/my-repo:ref:refs/heads/main",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("reads repository identity with an immutable subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repo:octocat@123456/my-repo@456789:ref:refs/heads/main",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("does not depend on a repository path in a customized subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repository_owner:octocat:repository_visibility:private",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("rejects a missing repository claim", () => {
|
||||
expect(() => parseRepositoryClaim({})).toThrow("Repository claim is missing")
|
||||
})
|
||||
|
||||
test("rejects an invalid repository claim", () => {
|
||||
expect(() => parseRepositoryClaim({ repository: "octocat" })).toThrow("Repository claim is invalid")
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@
|
||||
"./plugin": "./src/plugin.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"audit:layouts": "bun run script/layout-audit.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import {
|
||||
auditAllFixtures,
|
||||
summarizeAudits,
|
||||
worstAudits,
|
||||
type LayoutAudit,
|
||||
type LayoutMetrics,
|
||||
} from "../src/test/layout-audit/harness.js"
|
||||
|
||||
const outputPath = resolve(import.meta.dir, "../../../tmp/merman-layout-audit.md")
|
||||
const startedAt = performance.now()
|
||||
const audits = auditAllFixtures()
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
const summary = summarizeAudits(audits)
|
||||
|
||||
function label(audit: LayoutAudit): string {
|
||||
return `${audit.fixture.id} @${audit.viewport}`
|
||||
}
|
||||
|
||||
function metricTable(items: readonly LayoutAudit[]): string {
|
||||
return [
|
||||
"| Fixture | Viewport | Size | Area | Route length | Bends | Crossings | Shared cells | Overflow |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
...items.map(
|
||||
(audit) =>
|
||||
`| \`${audit.fixture.id}\` | ${audit.viewport} | ${audit.metrics.width}x${audit.metrics.height} | ${audit.metrics.area} | ${audit.metrics.routeLength} | ${audit.metrics.bends} | ${audit.metrics.crossings} | ${audit.metrics.sharedRouteCells} | ${audit.metrics.overflow} |`,
|
||||
),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function worstSection(metric: keyof LayoutMetrics): string {
|
||||
const worst = worstAudits(audits, metric)
|
||||
return [`### ${metric}`, "", metricTable(worst)].join("\n")
|
||||
}
|
||||
|
||||
function fixtureSection(audit: LayoutAudit): string {
|
||||
return [
|
||||
`<details${audit.fixture.curated || audit.violations.length > 0 ? " open" : ""}>`,
|
||||
`<summary><code>${label(audit)}</code> · ${audit.metrics.width}x${audit.metrics.height} · area ${audit.metrics.area} · bends ${audit.metrics.bends} · crossings ${audit.metrics.crossings} · overflow ${audit.metrics.overflow}</summary>`,
|
||||
"",
|
||||
...(audit.violations.length > 0 ? ["Violations:", "", ...audit.violations.map((item) => `- ${item}`), ""] : []),
|
||||
"Source:",
|
||||
"",
|
||||
"```mermaid",
|
||||
audit.fixture.source,
|
||||
"```",
|
||||
"",
|
||||
"Rendered output:",
|
||||
"",
|
||||
"```text",
|
||||
audit.output,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const grouped = Map.groupBy(audits, (audit) => `${audit.fixture.kind}/${audit.fixture.family}`)
|
||||
const violations = audits.flatMap((audit) => audit.violations.map((violation) => `${label(audit)}: ${violation}`))
|
||||
const markdown = [
|
||||
"# Merman Layout Audit",
|
||||
"",
|
||||
`Generated from ${new Set(audits.map((audit) => audit.fixture.id)).size} sources and ${audits.length} layout runs.`,
|
||||
"",
|
||||
`Structural violations: **${violations.length}**`,
|
||||
"",
|
||||
"## Aggregate Metrics",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(summary, null, 2),
|
||||
"```",
|
||||
"",
|
||||
"## Worst Offenders",
|
||||
"",
|
||||
...(["area", "bends", "crossings", "sharedRouteCells", "overflow"] as const).flatMap((metric) => [
|
||||
worstSection(metric),
|
||||
"",
|
||||
]),
|
||||
"## Fixtures",
|
||||
"",
|
||||
...[...grouped.entries()].flatMap(([family, items]) => [
|
||||
`### ${family}`,
|
||||
"",
|
||||
metricTable(items),
|
||||
"",
|
||||
...items.flatMap((audit) => [fixtureSection(audit), ""]),
|
||||
]),
|
||||
].join("\n")
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await Bun.write(outputPath, markdown)
|
||||
|
||||
console.log(`Wrote ${audits.length} layout runs to ${outputPath} in ${elapsedMs.toFixed(0)}ms`)
|
||||
if (violations.length > 0) {
|
||||
console.error(violations.join("\n"))
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -43,8 +43,9 @@ function mergeFlowchartCell(
|
||||
if (incoming.style !== "edge") return incoming
|
||||
if (existing.style === "label") return existing
|
||||
if (incoming.char === " ") return existing
|
||||
if ((existing.style !== "edge" && existing.style !== "group") || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
if (existing.style !== "edge" || existing.char === " ") return incoming
|
||||
if (DIAGRAM_ARROW_HEADS.has(existing.char)) return existing
|
||||
if (DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
@@ -75,22 +76,23 @@ function drawNode(
|
||||
): void {
|
||||
const chars = BorderChars[borderStyle]
|
||||
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
|
||||
const border: FlowchartCellStyle = node.shape === "database" ? "databaseBorder" : "nodeBorder"
|
||||
|
||||
if (node.shape === "decision") {
|
||||
drawDiagramDiamond(
|
||||
bounds,
|
||||
(x, y, char) => grid.setCell(x, y, char, style),
|
||||
(x, y, char) => grid.setCell(x, y, char, border),
|
||||
diagramDiamondCharactersFromBorder(chars),
|
||||
)
|
||||
} else if (node.shape === "subroutine") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawSubroutineNode(grid, bounds, chars, style)
|
||||
drawSubroutineNode(grid, bounds, chars, border)
|
||||
} else if (node.shape === "database") {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDatabaseNode(grid, bounds, chars, style)
|
||||
drawDatabaseNode(grid, bounds, chars, border)
|
||||
} else {
|
||||
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, border))
|
||||
}
|
||||
|
||||
const textTop =
|
||||
@@ -270,7 +272,7 @@ function drawSourceConnectors(
|
||||
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
|
||||
if (routeDirection && connectorDirection) {
|
||||
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
|
||||
if (cell && cell.style !== "label") {
|
||||
if (cell && cell.style !== "label" && !DIAGRAM_ARROW_HEADS.has(cell.char)) {
|
||||
grid.replaceCell(
|
||||
sourcePoint.x,
|
||||
sourcePoint.y,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseColor, TextAttributes } from "@opentui/core"
|
||||
import stringWidth from "string-width"
|
||||
import { diagramArrowHeadBetween } from "../core/drawing.js"
|
||||
import { orthogonalPathPoints } from "../core/geometry.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { deploymentArchitectureSource } from "../test/layout-audit/fixtures.js"
|
||||
import { drawFlowchartDiagramGrid as drawParsedFlowchartDiagramGrid } from "./drawing.js"
|
||||
import {
|
||||
DEFAULT_MIN_RANK_GAP,
|
||||
@@ -35,7 +38,7 @@ function routeRunsAlongHorizontalBorder(
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
if (from.y !== to.y || !borderYs.has(from.y)) continue
|
||||
if (Math.max(from.x, to.x) >= left && Math.min(from.x, to.x) <= right) return true
|
||||
if (Math.min(Math.max(from.x, to.x), right) > Math.max(Math.min(from.x, to.x), left)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -52,7 +55,7 @@ function routeRunsAlongVerticalBorder(
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
if (from.x !== to.x || !borderXs.has(from.x)) continue
|
||||
if (Math.max(from.y, to.y) >= top && Math.min(from.y, to.y) <= bottom) return true
|
||||
if (Math.min(Math.max(from.y, to.y), bottom) > Math.max(Math.min(from.y, to.y), top)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -114,6 +117,111 @@ function boundsIntersect(
|
||||
)
|
||||
}
|
||||
|
||||
function boundsContains(
|
||||
outer: { left: number; top: number; width: number; height: number },
|
||||
inner: { left: number; top: number; width: number; height: number },
|
||||
): boolean {
|
||||
return (
|
||||
inner.left >= outer.left &&
|
||||
inner.top >= outer.top &&
|
||||
inner.left + inner.width <= outer.left + outer.width &&
|
||||
inner.top + inner.height <= outer.top + outer.height
|
||||
)
|
||||
}
|
||||
|
||||
function routesIntersect(
|
||||
left: { points: readonly { x: number; y: number }[] },
|
||||
right: { points: readonly { x: number; y: number }[] },
|
||||
): boolean {
|
||||
const occupied = new Set(orthogonalPathPoints(left.points).map((point) => `${point.x}:${point.y}`))
|
||||
return orthogonalPathPoints(right.points).some((point) => occupied.has(`${point.x}:${point.y}`))
|
||||
}
|
||||
|
||||
function renderedDimensions(output: string): { width: number; height: number } {
|
||||
const lines = output.split("\n")
|
||||
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
|
||||
}
|
||||
|
||||
function expectResponsiveFlowchartValid(content: string, layoutMaxWidth: number) {
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const options = { compact: true, layoutMaxWidth }
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, options)
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, options)
|
||||
const output = renderFlowchartDiagram(content, options)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
|
||||
expect(layout.diagram.direction).toBe("TD")
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
for (const route of layout.routes) {
|
||||
expect(route.points.length).toBeGreaterThanOrEqual(2)
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
expect(from.x === to.x || from.y === to.y).toBe(true)
|
||||
}
|
||||
expect(terminalPointsTowardBounds(route, layout.bounds.get(route.edge.to)!)).toBe(true)
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
if (route.edge.label) expect(output).toContain(route.edge.label)
|
||||
}
|
||||
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
|
||||
|
||||
for (const subgraph of diagram.subgraphs ?? []) {
|
||||
const frame = layout.subgraphBounds.get(subgraph.id)!
|
||||
for (const nodeId of subgraph.nodeIds) expect(boundsContains(frame, layout.bounds.get(nodeId)!)).toBe(true)
|
||||
expect(output).toContain(subgraph.label)
|
||||
}
|
||||
for (const node of layout.bounds.values()) {
|
||||
for (const line of node.lines) expect(output).toContain(line)
|
||||
}
|
||||
|
||||
const widestContent = Math.max(
|
||||
...nodes.map((node) => node.width),
|
||||
...layout.routes.flatMap((route) =>
|
||||
route.edge.label ? [flowchartRouteLabelLayout(route, visualLength).width] : [],
|
||||
),
|
||||
)
|
||||
const dimensions = renderedDimensions(output)
|
||||
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(
|
||||
layoutMaxWidth + widestContent + 4,
|
||||
)
|
||||
return { dimensions, layout, output }
|
||||
}
|
||||
|
||||
function generatedWideRankFlowchart(count: number): string {
|
||||
const labels = [
|
||||
"地域 gateway Ω",
|
||||
"界面 worker λ",
|
||||
"Long-running synchronization service",
|
||||
"Cache café 🚀",
|
||||
"Audit and observability pipeline",
|
||||
"Provider μ endpoint",
|
||||
"Fallback Ж service",
|
||||
"Archive 数据 lake",
|
||||
"Terminal résumé queue",
|
||||
]
|
||||
const branches = labels
|
||||
.slice(0, count)
|
||||
.flatMap((label, index) => [
|
||||
` Hub ${index === 0 ? "-->|dispatch across regions and providers|" : "-->"} N${index}[${label}]`,
|
||||
` N${index} --> Join`,
|
||||
])
|
||||
return [
|
||||
"flowchart LR",
|
||||
" Start[Client α] --> Hub",
|
||||
" subgraph Services [地域 services Ω]",
|
||||
" Hub[Dispatch hub]",
|
||||
...branches,
|
||||
" Join[Join results]",
|
||||
" end",
|
||||
" Join --> Done[Complete ✓]",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function expectFlowchartRoutesAvoidUnrelatedNodes(layout: ReturnType<typeof layoutFlowchartDiagram>): void {
|
||||
for (const route of layout.routes) {
|
||||
for (const [id, bounds] of layout.bounds) {
|
||||
@@ -327,6 +435,52 @@ describe("FlowchartDiagram", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].map((compact) => ({ direction, compact })),
|
||||
),
|
||||
)(
|
||||
"preserves every target arrowhead after painting $direction routes with compact=$compact",
|
||||
({ direction, compact }) => {
|
||||
const content = `flowchart ${direction}
|
||||
A[A]
|
||||
B[B]
|
||||
C[C]
|
||||
D[D]
|
||||
A --> A
|
||||
A --> C
|
||||
C --> B`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, { compact })
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact })
|
||||
|
||||
for (const route of layout.routes) {
|
||||
const end = route.points.at(-1)!
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(route.points.at(-2)!, end))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(
|
||||
(["LR", "RL", "TD", "TB", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].map((compact) => ({ direction, compact })),
|
||||
),
|
||||
)("keeps crossed endpoint-disjoint $direction routes separate with compact=$compact", ({ direction, compact }) => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart ${direction}
|
||||
A[A]
|
||||
B[B]
|
||||
C[C]
|
||||
D[D]
|
||||
A --> C
|
||||
D --> B`,
|
||||
{ compact },
|
||||
)
|
||||
|
||||
expect(layout.routes).toHaveLength(2)
|
||||
expect(routesIntersect(layout.routes[0]!, layout.routes[1]!)).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps vertical feedback labels clear of unrelated nodes", () => {
|
||||
const content = `flowchart TD
|
||||
S[Source] --> A[Alpha]
|
||||
@@ -613,6 +767,7 @@ describe("FlowchartDiagram", () => {
|
||||
const loops = layout.routes.filter((route) => route.edge.from === "B" && route.edge.to === "B")
|
||||
|
||||
expect(loops).toHaveLength(3)
|
||||
expect(new Set(loops.map((route) => JSON.stringify(route.points))).size).toBe(3)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -811,6 +966,119 @@ describe("FlowchartDiagram", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
test("wraps the real deployment chart responsively without losing content or geometry", () => {
|
||||
const expected = new Map([
|
||||
[60, { width: 82, height: 108 }],
|
||||
[80, { width: 97, height: 85 }],
|
||||
[120, { width: 143, height: 77 }],
|
||||
[160, { width: 163, height: 69 }],
|
||||
])
|
||||
const results = [...expected].map(([budget, dimensions]) => {
|
||||
const result = expectResponsiveFlowchartValid(deploymentArchitectureSource, budget)
|
||||
expect(result.dimensions).toEqual(dimensions)
|
||||
for (const frame of result.layout.subgraphBounds.values()) {
|
||||
for (const other of result.layout.subgraphBounds.values()) {
|
||||
if (frame !== other) expect(boundsIntersect(frame, other)).toBe(false)
|
||||
}
|
||||
}
|
||||
return result.dimensions
|
||||
})
|
||||
|
||||
for (let index = 1; index < results.length; index++) {
|
||||
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([7, 9])("wraps generated %s-node Unicode subgraph ranks across width targets", (count) => {
|
||||
const results = [60, 80, 120].map(
|
||||
(budget) => expectResponsiveFlowchartValid(generatedWideRankFlowchart(count), budget).dimensions,
|
||||
)
|
||||
|
||||
for (let index = 1; index < results.length; index++) {
|
||||
expect(results[index - 1]!.width).toBeLessThan(results[index]!.width)
|
||||
expect(results[index - 1]!.height).toBeGreaterThanOrEqual(results[index]!.height)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps responsive local-direction subgraphs clear of sibling nodes", () => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart BT
|
||||
N0[Outside zero]
|
||||
subgraph Outer
|
||||
N2[Two]
|
||||
subgraph Inner
|
||||
direction LR
|
||||
N3[Three]
|
||||
N4[Four]
|
||||
end
|
||||
N5[X]
|
||||
end
|
||||
N7[Outside seven]
|
||||
N7 --> N2
|
||||
N2 -->|label 6| N5
|
||||
N5 --> N4
|
||||
N0 --> N7`,
|
||||
{ compact: true, layoutMaxWidth: 35 },
|
||||
)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps responsive sibling subgraph frames and long titles disjoint", () => {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart TD
|
||||
subgraph Parent
|
||||
direction LR
|
||||
subgraph Left [A deliberately long left group title]
|
||||
direction LR
|
||||
A1[One] --> A2[Two]
|
||||
end
|
||||
subgraph Right [A deliberately long right group title]
|
||||
direction LR
|
||||
B1[Three] --> B2[Four]
|
||||
end
|
||||
A2 --> B1
|
||||
end`,
|
||||
{ compact: true, layoutMaxWidth: 35 },
|
||||
)
|
||||
|
||||
expect(boundsIntersect(layout.subgraphBounds.get("Left")!, layout.subgraphBounds.get("Right")!)).toBe(false)
|
||||
})
|
||||
|
||||
test("does not change parallel routes for a non-binding width target", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
A[A] -->|one| B[B]
|
||||
A -->|two| B`)
|
||||
const unconstrained = layoutParsedFlowchartDiagram(diagram, { compact: true })
|
||||
const nonBinding = layoutParsedFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: 1_000 })
|
||||
|
||||
expect(nonBinding.routes.map((route) => route.points)).toEqual(unconstrained.routes.map((route) => route.points))
|
||||
})
|
||||
|
||||
test("keeps responsive fan-out labels inside the width target", () => {
|
||||
const content = `flowchart TD
|
||||
subgraph Group
|
||||
S[Source]
|
||||
S -->|route 0 detail| N0[Node 0]
|
||||
S -->|route 1 detail| N1[Node 1]
|
||||
S -->|route 2 detail| N2[Node 2]
|
||||
S -->|route 3 detail| N3[Node 3]
|
||||
end`
|
||||
const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
|
||||
const output = renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 30 })
|
||||
|
||||
for (const route of layout.routes) {
|
||||
const label = flowchartRouteLabelLayout(route, visualLength)
|
||||
expect(label.point.x + label.width).toBeLessThanOrEqual(30)
|
||||
}
|
||||
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(34)
|
||||
})
|
||||
|
||||
test("parses Mermaid flowchart nodes and standard arrows", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`
|
||||
flowchart TD
|
||||
@@ -1349,6 +1617,22 @@ flowchart LR
|
||||
expect(output).not.toContain("<br")
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["TD", "BT"] as const).flatMap((direction) =>
|
||||
[false, true].flatMap((compact) => [2, 4].map((lines) => ({ direction, compact, lines }))),
|
||||
),
|
||||
)(
|
||||
"keeps $lines-line $direction labels off both terminal rows with compact=$compact",
|
||||
({ direction, compact, lines }) => {
|
||||
const label = Array.from({ length: lines }, (_, index) => `line ${index + 1}`).join("<br/>")
|
||||
const route = layoutFlowchartDiagram(`flowchart ${direction}\n A[A] -->|${label}| B[B]`, { compact }).routes[0]!
|
||||
const layout = flowchartRouteLabelLayout(route, visualLength)
|
||||
const terminals = new Set([route.points[0]!.y, route.points.at(-1)!.y])
|
||||
|
||||
for (let y = layout.point.y; y < layout.point.y + layout.height; y++) expect(terminals.has(y)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
test("expands canvas for multiline back-edge labels", () => {
|
||||
const output = renderFlowchartDiagram(`flowchart TD
|
||||
A --> B
|
||||
@@ -1393,10 +1677,10 @@ graph LR
|
||||
expect(output).toContain("API")
|
||||
expect(output).toContain("DB")
|
||||
expect(output).toContain("╭─ Web App ")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).toContain("┼")
|
||||
expect(output.split("\n").find((line) => line.includes("API") && line.includes("DB"))).not.toContain("┼")
|
||||
})
|
||||
|
||||
test("merges horizontal routes through vertical subgraph borders", () => {
|
||||
test("breaks vertical subgraph borders where horizontal routes pass through", () => {
|
||||
const content = `flowchart LR
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Group
|
||||
@@ -1408,13 +1692,14 @@ graph LR
|
||||
const group = layout.subgraphBounds.get("Group")!
|
||||
const crossing = { x: group.left, y: layout.routes[0]!.points.at(-1)!.y }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
expect(grid.getCell(crossing.x, crossing.y + 1)?.char).toBe("│")
|
||||
})
|
||||
|
||||
test("merges vertical routes through horizontal subgraph borders", () => {
|
||||
test("breaks horizontal subgraph borders where vertical routes pass through", () => {
|
||||
const content = `flowchart TD
|
||||
Outside[Outside] --> Inside
|
||||
subgraph Outer [O]
|
||||
@@ -1428,7 +1713,8 @@ graph LR
|
||||
const outer = layout.subgraphBounds.get("Outer")!
|
||||
const crossing = { x: layout.routes[0]!.points[0]!.x, y: outer.top }
|
||||
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("┼")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.char).toBe("│")
|
||||
expect(grid.getCell(crossing.x, crossing.y)?.style).not.toBe("group")
|
||||
expect(grid.getCell(crossing.x - 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x + 1, crossing.y)?.char).toBe("─")
|
||||
expect(grid.getCell(crossing.x, crossing.y - 1)?.char).toBe("│")
|
||||
@@ -1466,21 +1752,20 @@ graph LR
|
||||
expect(output).not.toContain("<br")
|
||||
})
|
||||
|
||||
test("merges transition lines through subgraph frame borders", () => {
|
||||
const output = renderFlowchartDiagram(`
|
||||
flowchart TD
|
||||
subgraph Verse [verse]
|
||||
direction LR
|
||||
A[A] --> B[B]
|
||||
C[C] --> D[D]
|
||||
test("keeps long Unicode subgraph titles from replacing entering arrowheads", () => {
|
||||
const content = `flowchart TD
|
||||
U[Up] --> A
|
||||
subgraph G [界界界界界界]
|
||||
A[A]
|
||||
end
|
||||
B --> Join
|
||||
D --> Join
|
||||
`)
|
||||
const crossingLines = output.split("\n").filter((line) => line.includes("Join") || line.includes("├"))
|
||||
A --> D[Down]`
|
||||
const diagram = parseMermaidFlowchartDiagram(content)
|
||||
const layout = layoutParsedFlowchartDiagram(diagram, { compact: true })
|
||||
const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true })
|
||||
const entry = layout.routes.find((route) => route.edge.from === "U")!
|
||||
const end = entry.points.at(-1)!
|
||||
|
||||
expect(output).toContain(" verse ")
|
||||
expect(crossingLines.join("\n").match(/┼/g)).toHaveLength(2)
|
||||
expect(grid.getCell(end.x, end.y)?.char).toBe(diagramArrowHeadBetween(entry.points.at(-2)!, end))
|
||||
})
|
||||
|
||||
test("lays out subgraph-local directions independently from the outer flow", () => {
|
||||
@@ -1612,6 +1897,65 @@ flowchart TD
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps nested local-direction layouts rigid across direction and compact matrices", () => {
|
||||
const directions = ["LR", "RL", "TD", "BT"] as const
|
||||
for (const global of directions) {
|
||||
for (const outer of directions) {
|
||||
for (const inner of directions) {
|
||||
for (const compact of [false, true]) {
|
||||
const layout = layoutFlowchartDiagram(
|
||||
`flowchart ${global}
|
||||
X[X] --> A
|
||||
subgraph Outer [Outer]
|
||||
direction ${outer}
|
||||
subgraph Inner [Inner]
|
||||
direction ${inner}
|
||||
A[A] --> B[B]
|
||||
end
|
||||
B --> C[C]
|
||||
end
|
||||
C --> Y[Y]`,
|
||||
{ compact },
|
||||
)
|
||||
const nodes = [...layout.bounds.values()]
|
||||
const innerFrame = layout.subgraphBounds.get("Inner")!
|
||||
const outerFrame = layout.subgraphBounds.get("Outer")!
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
const c = layout.bounds.get("C")!
|
||||
|
||||
for (let left = 0; left < nodes.length; left++) {
|
||||
for (let right = left + 1; right < nodes.length; right++) {
|
||||
expect(boundsIntersect(nodes[left]!, nodes[right]!)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(boundsContains(innerFrame, a)).toBe(true)
|
||||
expect(boundsContains(innerFrame, b)).toBe(true)
|
||||
expect(boundsContains(outerFrame, innerFrame)).toBe(true)
|
||||
expect(boundsContains(outerFrame, c)).toBe(true)
|
||||
expect(layout.routes.every((route) => route.points.length >= 2)).toBe(true)
|
||||
expectFlowchartRoutesAvoidUnrelatedNodes(layout)
|
||||
for (const frame of layout.subgraphBounds.values()) {
|
||||
for (const route of layout.routes) {
|
||||
expect(routeRunsAlongHorizontalBorder(route, frame)).toBe(false)
|
||||
expect(routeRunsAlongVerticalBorder(route, frame)).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (inner === "LR") expect(b.left).toBeGreaterThan(a.left)
|
||||
if (inner === "RL") expect(b.left).toBeLessThan(a.left)
|
||||
if (inner === "TD") expect(b.top).toBeGreaterThan(a.top)
|
||||
if (inner === "BT") expect(b.top).toBeLessThan(a.top)
|
||||
if (outer === "LR") expect(c.centerX).toBeGreaterThan(b.centerX)
|
||||
if (outer === "RL") expect(c.centerX).toBeLessThan(b.centerX)
|
||||
if (outer === "TD") expect(c.centerY).toBeGreaterThan(b.centerY)
|
||||
if (outer === "BT") expect(c.centerY).toBeLessThan(b.centerY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("compacts stacked subgraph-local direction rows", () => {
|
||||
const layout = layoutFlowchartDiagram(`
|
||||
flowchart TD
|
||||
@@ -2037,8 +2381,10 @@ flowchart LR
|
||||
test("applies the global flowchart StyledText theme", () => {
|
||||
const grid = drawFlowchartDiagramGrid("flowchart LR\n A[Alpha] --> B[Beta]")
|
||||
const node = parseColor("#ff0000")
|
||||
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
|
||||
const nodeBorder = parseColor("#0000ff")
|
||||
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node, nodeBorder }))
|
||||
|
||||
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
|
||||
expect(styled.chunks.some((chunk) => chunk.text.includes("╭") && chunk.fg?.equals(nodeBorder))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ const LABEL_BUS_CLEARANCE = 3
|
||||
const LABEL_NODE_CLEARANCE = 2
|
||||
const LABEL_LINE_CLEARANCE = 2
|
||||
const LABEL_PADDING = 1
|
||||
const LABEL_TERMINAL_CLEARANCE = 1
|
||||
|
||||
export interface FlowchartEdgeLabelLayout {
|
||||
lines: string[]
|
||||
@@ -63,6 +64,11 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
|
||||
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
|
||||
}
|
||||
|
||||
const slot = insetSpan(segmentSpan(segment), LABEL_TERMINAL_CLEARANCE)
|
||||
if (spanCapacity(slot) >= labelHeight) {
|
||||
return clampPoint(point(segment.from.x + 1, centeredSpanStart(slot, labelHeight)))
|
||||
}
|
||||
|
||||
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
|
||||
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
flowchartVerticalBranchLabelGap,
|
||||
} from "./labels.js"
|
||||
import type { FlowchartDiagramRenderOptions } from "./options.js"
|
||||
import { routeFlowchartEdges } from "./routing.js"
|
||||
import { avoidFlowchartFrameBorders, routeFlowchartEdges } from "./routing.js"
|
||||
import type {
|
||||
FlowchartDiagram,
|
||||
FlowchartDirection,
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
FlowchartNode,
|
||||
FlowchartNodeBounds,
|
||||
FlowchartNodeSize,
|
||||
FlowchartPoint,
|
||||
FlowchartSubgraphBounds,
|
||||
} from "./types.js"
|
||||
|
||||
@@ -364,7 +365,8 @@ function layoutRankedNodes(
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): Map<string, FlowchartNodeBounds> {
|
||||
targetWidth?: number,
|
||||
): { bounds: Map<string, FlowchartNodeBounds>; wrapped: boolean } {
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
const ranks = rankNodes(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
@@ -406,6 +408,7 @@ function layoutRankedNodes(
|
||||
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
|
||||
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
|
||||
const bounds = new Map<string, FlowchartNodeBounds>()
|
||||
let wrapped = false
|
||||
|
||||
if (horizontal) {
|
||||
const columnWidths = rankKeys.map((rank) =>
|
||||
@@ -442,68 +445,141 @@ function layoutRankedNodes(
|
||||
x += columnWidth + (horizontalGaps[rankIndex] ?? 0)
|
||||
}
|
||||
} else {
|
||||
const rowHeights = rankKeys.map((rank) =>
|
||||
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.height)),
|
||||
)
|
||||
const rowWidths = rankKeys.map((rank) => {
|
||||
const rankBands = rankKeys.map((rank) => {
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
return (
|
||||
const roomyNodeGap = verticalNodeGap(rank)
|
||||
const naturalWidth =
|
||||
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
|
||||
Math.max(0, nodes.length - 1) * verticalNodeGap(rank)
|
||||
Math.max(0, nodes.length - 1) * roomyNodeGap
|
||||
const labeledEdges = diagram.edges.filter(
|
||||
(edge) => edge.label && (normalizedRanks.get(edge.from) === rank || normalizedRanks.get(edge.to) === rank),
|
||||
)
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rowWidths)
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankKeys[rankIndex]!
|
||||
const nodes = ranksByIndex.get(rank)!
|
||||
const rowHeight = rowHeights[rankIndex]!
|
||||
const nodeGap = verticalNodeGap(rank)
|
||||
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
|
||||
const needsLabelLanes =
|
||||
labeledEdges.length > 1 &&
|
||||
labeledEdges.some((edge) => {
|
||||
const targets = new Set(
|
||||
labeledEdges.filter((candidate) => candidate.from === edge.from).map((candidate) => candidate.to),
|
||||
)
|
||||
const sources = new Set(
|
||||
labeledEdges.filter((candidate) => candidate.to === edge.to).map((candidate) => candidate.from),
|
||||
)
|
||||
const grouped = (ids: readonly string[]) =>
|
||||
!diagram.subgraphs?.length ||
|
||||
diagram.subgraphs.some((subgraph) => ids.every((id) => subgraph.nodeIds.includes(id)))
|
||||
return (
|
||||
(targets.size > 1 && grouped([edge.from, ...targets])) ||
|
||||
(sources.size > 1 && grouped([edge.to, ...sources]))
|
||||
)
|
||||
})
|
||||
const nodeGap =
|
||||
targetWidth !== undefined && naturalWidth > targetWidth && !needsLabelLanes ? minNodeGap : roomyNodeGap
|
||||
const bands: { nodes: FlowchartNode[]; width: number; height: number }[] = []
|
||||
for (const node of nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((rowHeight - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + nodeGap
|
||||
const current = bands.at(-1)
|
||||
const width = current ? current.width + nodeGap + size.width : size.width
|
||||
if (current && targetWidth !== undefined && width > targetWidth) {
|
||||
wrapped = true
|
||||
bands.push({ nodes: [node], width: size.width, height: size.height })
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
bands.push({ nodes: [node], width: size.width, height: size.height })
|
||||
continue
|
||||
}
|
||||
current.nodes.push(node)
|
||||
current.width = width
|
||||
current.height = Math.max(current.height, size.height)
|
||||
}
|
||||
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
|
||||
return { bands, nodeGap }
|
||||
})
|
||||
const canvasWidth = Math.max(1, ...rankBands.flatMap((rank) => rank.bands.map((band) => band.width)))
|
||||
let y = 0
|
||||
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
|
||||
const rank = rankBands[rankIndex]!
|
||||
for (const [bandIndex, band] of rank.bands.entries()) {
|
||||
let x = Math.floor((canvasWidth - band.width) / 2)
|
||||
for (const node of band.nodes) {
|
||||
const size = sizes.get(node.id)!
|
||||
const top = y + Math.floor((band.height - size.height) / 2)
|
||||
bounds.set(node.id, {
|
||||
id: node.id,
|
||||
...size,
|
||||
left: x,
|
||||
top,
|
||||
centerX: x + Math.floor(size.width / 2),
|
||||
centerY: top + Math.floor(size.height / 2),
|
||||
})
|
||||
x += size.width + rank.nodeGap
|
||||
}
|
||||
y += band.height + (bandIndex < rank.bands.length - 1 ? minNodeGap : 0)
|
||||
}
|
||||
y += verticalGaps[rankIndex] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
return bounds
|
||||
return { bounds, wrapped }
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
sizes: ReadonlyMap<string, FlowchartNodeSize>,
|
||||
minNodeGap: number,
|
||||
requestedMinRankGap: number,
|
||||
): void {
|
||||
targetWidth?: number,
|
||||
): boolean {
|
||||
let wrapped = false
|
||||
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
|
||||
if (!subgraph.direction || subgraph.direction === diagram.direction) continue
|
||||
const nodeIds = new Set(subgraph.nodeIds)
|
||||
const nodes = diagram.nodes.filter((node) => nodeIds.has(node.id))
|
||||
if (nodes.length === 0) continue
|
||||
const childSubgraphs = (diagram.subgraphs ?? []).filter((child) => child.parentId === subgraph.id)
|
||||
const coveredNodeIds = new Set(childSubgraphs.flatMap((child) => [...collectSubgraphNodeIds(diagram, child.id)]))
|
||||
const items = [
|
||||
...childSubgraphs.flatMap((child) => {
|
||||
const nodeIds = [...collectSubgraphNodeIds(diagram, child.id)]
|
||||
const content = boundsFromChildren(nodeIds.flatMap((id) => nodeBounds.get(id) ?? []))
|
||||
const bounds = content ? subgraphBoundFromChildren(child.id, child.label, [content]) : undefined
|
||||
return bounds ? [{ id: `subgraph:${child.id}`, nodeIds, bounds, childId: child.id }] : []
|
||||
}),
|
||||
...subgraph.nodeIds.flatMap((id) => {
|
||||
if (coveredNodeIds.has(id)) return []
|
||||
const bounds = nodeBounds.get(id)
|
||||
return bounds ? [{ id, nodeIds: [id], bounds, childId: undefined }] : []
|
||||
}),
|
||||
]
|
||||
if (items.length === 0) continue
|
||||
|
||||
const currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
|
||||
const currentBounds = boundsFromChildren(items.map((item) => item.bounds))
|
||||
if (!currentBounds) continue
|
||||
|
||||
const itemByEndpoint = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
for (const nodeId of item.nodeIds) itemByEndpoint.set(nodeId, item.id)
|
||||
if (item.childId) itemByEndpoint.set(item.childId, item.id)
|
||||
}
|
||||
const nodes = items.map((item): FlowchartNode => ({ id: item.id, label: item.id, shape: "box" }))
|
||||
const localDiagram: FlowchartDiagram = {
|
||||
direction: subgraph.direction,
|
||||
nodes,
|
||||
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
|
||||
edges: diagram.edges.flatMap((edge) => {
|
||||
const from = itemByEndpoint.get(edge.from)
|
||||
const to = itemByEndpoint.get(edge.to)
|
||||
return from && to && from !== to ? [{ ...edge, from, to }] : []
|
||||
}),
|
||||
subgraphs: [],
|
||||
}
|
||||
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
|
||||
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
|
||||
const itemSizes = new Map(
|
||||
items.map((item) => [item.id, { width: item.bounds.width, height: item.bounds.height, lines: [item.id] }]),
|
||||
)
|
||||
const localLayout = layoutRankedNodes(
|
||||
localDiagram,
|
||||
subgraph.direction,
|
||||
itemSizes,
|
||||
Math.max(minNodeGap, SUBGRAPH_PADDING_X * 2 + 1),
|
||||
requestedMinRankGap,
|
||||
targetWidth,
|
||||
)
|
||||
const localBounds = localLayout.bounds
|
||||
wrapped ||= localLayout.wrapped
|
||||
const localExtent = boundsFromChildren([...localBounds.values()])
|
||||
if (!localExtent) continue
|
||||
|
||||
@@ -512,11 +588,66 @@ function layoutLocalSubgraphDirections(
|
||||
const dx = targetLeft - localExtent.left
|
||||
const dy = targetTop - localExtent.top
|
||||
|
||||
for (const [nodeId, bound] of localBounds) {
|
||||
translateBounds(bound, dx, dy)
|
||||
nodeBounds.set(nodeId, bound)
|
||||
const translations = new Map<string, { dx: number; dy: number }>()
|
||||
for (const item of items) {
|
||||
const bound = localBounds.get(item.id)!
|
||||
const itemDx = bound.left + dx - item.bounds.left
|
||||
const itemDy = bound.top + dy - item.bounds.top
|
||||
for (const nodeId of item.nodeIds) translations.set(nodeId, { dx: itemDx, dy: itemDy })
|
||||
}
|
||||
|
||||
let groupOffset = { x: 0, y: 0 }
|
||||
if (targetWidth !== undefined) {
|
||||
const localNodeIds = new Set(translations.keys())
|
||||
const external = [...nodeBounds.entries()].filter(([id]) => !localNodeIds.has(id)).map(([, bound]) => bound)
|
||||
const overlaps = (offset: FlowchartPoint) =>
|
||||
[...translations].some(([id, translation]) => {
|
||||
const bound = nodeBounds.get(id)!
|
||||
const left = bound.left + translation.dx + offset.x
|
||||
const top = bound.top + translation.dy + offset.y
|
||||
return external.some(
|
||||
(other) =>
|
||||
left < other.left + other.width + minNodeGap &&
|
||||
left + bound.width + minNodeGap > other.left &&
|
||||
top < other.top + other.height + minNodeGap &&
|
||||
top + bound.height + minNodeGap > other.top,
|
||||
)
|
||||
})
|
||||
if (overlaps(groupOffset)) {
|
||||
const vertical = !isHorizontalDirection(diagram.direction)
|
||||
const sign = diagram.direction === "RL" || diagram.direction === "BT" ? -1 : 1
|
||||
let found = false
|
||||
search: for (let distance = 1; distance < 1_000; distance++) {
|
||||
const candidates = vertical
|
||||
? [
|
||||
{ x: 0, y: sign * distance },
|
||||
{ x: 0, y: -sign * distance },
|
||||
{ x: distance, y: 0 },
|
||||
{ x: -distance, y: 0 },
|
||||
]
|
||||
: [
|
||||
{ x: sign * distance, y: 0 },
|
||||
{ x: -sign * distance, y: 0 },
|
||||
{ x: 0, y: distance },
|
||||
{ x: 0, y: -distance },
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (overlaps(candidate)) continue
|
||||
groupOffset = candidate
|
||||
found = true
|
||||
break search
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error(`Subgraph ${subgraph.id} has no collision-free responsive position`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nodeId, translation] of translations) {
|
||||
const nodeBound = nodeBounds.get(nodeId)
|
||||
if (nodeBound) translateBounds(nodeBound, translation.dx + groupOffset.x, translation.dy + groupOffset.y)
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
|
||||
@@ -601,6 +732,7 @@ function separateTopLevelItems(
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
gap: number,
|
||||
targetWidth?: number,
|
||||
): boolean {
|
||||
const hasLocalDirection = (diagram.subgraphs ?? []).some(
|
||||
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
|
||||
@@ -734,6 +866,35 @@ function separateTopLevelItems(
|
||||
crossCursor = start + shift + size + gap
|
||||
}
|
||||
}
|
||||
if (targetWidth !== undefined && !horizontal) {
|
||||
const intersects = (left: (typeof items)[number], right: (typeof items)[number]): boolean =>
|
||||
[...left.nodeIds].some((leftId) => {
|
||||
const leftBounds = nodeBounds.get(leftId)!
|
||||
return [...right.nodeIds].some((rightId) => {
|
||||
const rightBounds = nodeBounds.get(rightId)!
|
||||
return (
|
||||
leftBounds.left <= rightBounds.left + rightBounds.width - 1 &&
|
||||
leftBounds.left + leftBounds.width - 1 >= rightBounds.left &&
|
||||
leftBounds.top <= rightBounds.top + rightBounds.height - 1 &&
|
||||
leftBounds.top + leftBounds.height - 1 >= rightBounds.top
|
||||
)
|
||||
})
|
||||
})
|
||||
for (let rightIndex = 1; rightIndex < items.length; rightIndex++) {
|
||||
const right = items[rightIndex]!
|
||||
for (let leftIndex = 0; leftIndex < rightIndex; leftIndex++) {
|
||||
const left = items[leftIndex]!
|
||||
if (!intersects(left, right)) continue
|
||||
const leftBounds = boundsFromChildren([...left.nodeIds].map((id) => nodeBounds.get(id)!))!
|
||||
const rightBounds = boundsFromChildren([...right.nodeIds].map((id) => nodeBounds.get(id)!))!
|
||||
const shift = reversed
|
||||
? leftBounds.top - gap - (rightBounds.top + rightBounds.height)
|
||||
: leftBounds.top + leftBounds.height + gap - rightBounds.top
|
||||
moved ||= shift !== 0
|
||||
moveItem(right, 0, shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
@@ -771,6 +932,7 @@ function layoutFlowchartWithDirection(
|
||||
sourceDiagram: FlowchartDiagram,
|
||||
options: FlowchartDiagramRenderOptions,
|
||||
direction: FlowchartDirection,
|
||||
responsiveFallback = false,
|
||||
): FlowchartLayout {
|
||||
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
|
||||
const horizontal = isHorizontalDirection(direction)
|
||||
@@ -786,29 +948,65 @@ function layoutFlowchartWithDirection(
|
||||
: DEFAULT_MIN_VERTICAL_RANK_GAP,
|
||||
)
|
||||
const sizes = new Map(diagram.nodes.map((node) => [node.id, nodeSize(node)]))
|
||||
const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
|
||||
layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
|
||||
const targetWidth =
|
||||
!horizontal && options.layoutMaxWidth !== undefined && Number.isFinite(options.layoutMaxWidth)
|
||||
? Math.max(1, Math.trunc(options.layoutMaxWidth))
|
||||
: undefined
|
||||
const ranked = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap, targetWidth)
|
||||
const bounds = ranked.bounds
|
||||
const responsive = layoutLocalSubgraphDirections(diagram, bounds, minNodeGap, requestedMinRankGap, targetWidth)
|
||||
const directionAligned = responsiveFallback || responsive || ranked.wrapped
|
||||
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
|
||||
let routes: FlowchartEdgeRoute[]
|
||||
if (subgraphs.length === 0) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
} else {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
const moved = separateTopLevelItems(
|
||||
diagram,
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
Math.max(1, Math.floor(requestedMinRankGap / 2)),
|
||||
targetWidth,
|
||||
)
|
||||
if (moved) {
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
undefined,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
}
|
||||
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
|
||||
routes = routeFlowchartEdges(
|
||||
diagram,
|
||||
bounds,
|
||||
(edge) => edgeDirection(diagram, edge),
|
||||
subgraphBounds,
|
||||
targetWidth,
|
||||
directionAligned,
|
||||
)
|
||||
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
|
||||
avoidFlowchartFrameBorders(routes, bounds, subgraphBounds)
|
||||
}
|
||||
freezeRouteLabelPoints(routes)
|
||||
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
|
||||
@@ -834,5 +1032,5 @@ export function layoutFlowchartDiagram(
|
||||
if (!isHorizontalDirection(direction) || maxWidth === undefined || !Number.isFinite(maxWidth)) return layout
|
||||
if (layout.width <= Math.max(1, Math.trunc(maxWidth))) return layout
|
||||
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD")
|
||||
return layoutFlowchartWithDirection(sourceDiagram, options, direction === "RL" ? "BT" : "TD", true)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export interface FlowchartDiagramRenderOptions {
|
||||
borderStyle?: BorderStyle
|
||||
minNodeGap?: number
|
||||
minRankGap?: number
|
||||
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
|
||||
/** Target rendered width. Oversized horizontal layouts fold vertically and broad vertical ranks wrap. */
|
||||
layoutMaxWidth?: number
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
lane,
|
||||
oppositeSide,
|
||||
orthogonalPath,
|
||||
orthogonalPathPoints,
|
||||
pathThrough,
|
||||
pathViaLane,
|
||||
segmentBetween,
|
||||
segmentSpan,
|
||||
sideForDirection,
|
||||
snapCoordinate,
|
||||
shiftPoint,
|
||||
@@ -138,11 +140,11 @@ function horizontalEdgePath(
|
||||
})
|
||||
}
|
||||
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
|
||||
function selfEdgePath(bounds: FlowchartNodeBounds, laneOffset = 0): FlowchartPoint[] {
|
||||
const start = boundsSidePoint(bounds, "right")
|
||||
const end = boundsSidePoint(bounds, "bottom")
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE
|
||||
const bottomLaneY = bounds.top + bounds.height + 1
|
||||
const rightLaneX = bounds.left + bounds.width + BUS_CLEARANCE + laneOffset
|
||||
const bottomLaneY = bounds.top + bounds.height + 1 + laneOffset
|
||||
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
|
||||
}
|
||||
|
||||
@@ -522,6 +524,8 @@ function routeVerticalFanIn(
|
||||
function routeParallelEdges(
|
||||
diagram: FlowchartDiagram,
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
|
||||
directionAligned: boolean,
|
||||
handled: Set<FlowchartEdge>,
|
||||
routes: FlowchartEdgeRoute[],
|
||||
): void {
|
||||
@@ -530,8 +534,18 @@ function routeParallelEdges(
|
||||
if (edges.length < 2) continue
|
||||
const from = bounds.get(edges[0]!.from)
|
||||
const to = bounds.get(edges[0]!.to)
|
||||
if (!from || !to || from.id === to.id) continue
|
||||
const parallelAxis = parallelLaneAxis(from, to)
|
||||
if (!from || !to) continue
|
||||
if (from.id === to.id) {
|
||||
let laneOffset = 0
|
||||
for (const edge of edges) {
|
||||
routes.push({ edge, points: selfEdgePath(from, laneOffset) })
|
||||
handled.add(edge)
|
||||
laneOffset++
|
||||
}
|
||||
continue
|
||||
}
|
||||
const parallelAxis =
|
||||
directionAligned && isVerticalDirection(directionForEdge(edges[0]!)) ? "x" : parallelLaneAxis(from, to)
|
||||
let previousRoute: FlowchartEdgeRoute | undefined
|
||||
for (const edge of edges) {
|
||||
const height = labelHeight(edge)
|
||||
@@ -539,7 +553,7 @@ function routeParallelEdges(
|
||||
parallelAxis === "x"
|
||||
? previousRoute
|
||||
? rightRenderExtent(previousRoute) + NODE_CLEARANCE
|
||||
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x)
|
||||
: Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + (directionAligned ? 1 : 0)
|
||||
: previousRoute
|
||||
? Math.max(...previousRoute.points.map((point) => point.y)) + (height > 1 ? height + 1 : 1)
|
||||
: Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + (height > 1 ? height : 0)
|
||||
@@ -795,6 +809,69 @@ function routeIntersectsLabels(route: FlowchartEdgeRoute, labels: readonly Flowc
|
||||
)
|
||||
}
|
||||
|
||||
function pathsIntersect(left: readonly FlowchartPoint[], right: readonly FlowchartPoint[]): boolean {
|
||||
const occupied = new Set(orthogonalPathPoints(left).map((point) => `${point.x}:${point.y}`))
|
||||
return orthogonalPathPoints(right).some((point) => occupied.has(`${point.x}:${point.y}`))
|
||||
}
|
||||
|
||||
function endpointDisjoint(left: FlowchartEdge, right: FlowchartEdge): boolean {
|
||||
return left.from !== right.from && left.from !== right.to && left.to !== right.from && left.to !== right.to
|
||||
}
|
||||
|
||||
function endpointConflictsWithRoutes(route: FlowchartEdgeRoute, otherRoutes: readonly FlowchartEdgeRoute[]): boolean {
|
||||
const source = route.points[0]
|
||||
const target = route.points.at(-1)
|
||||
if (!source || !target) return false
|
||||
return otherRoutes.some((other) => {
|
||||
const otherSource = other.points[0]
|
||||
const otherTarget = other.points.at(-1)
|
||||
return (
|
||||
(otherSource && target.x === otherSource.x && target.y === otherSource.y) ||
|
||||
(otherTarget && source.x === otherTarget.x && source.y === otherTarget.y)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pathRunsAlongFrame(points: readonly FlowchartPoint[], bounds: FlowchartSubgraphBounds): boolean {
|
||||
const right = bounds.left + bounds.width - 1
|
||||
const bottom = bounds.top + bounds.height - 1
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = segmentBetween(points[index - 1]!, points[index]!)
|
||||
if (!segment) continue
|
||||
const span = segmentSpan(segment)
|
||||
if (
|
||||
segment.axis === "x" &&
|
||||
(segment.from.y === bounds.top || segment.from.y === bottom) &&
|
||||
Math.min(span.end, right) > Math.max(span.start, bounds.left)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
segment.axis === "y" &&
|
||||
(segment.from.x === bounds.left || segment.from.x === right) &&
|
||||
Math.min(span.end, bottom) > Math.max(span.start, bounds.top)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function subgraphTitleBounds(bounds: FlowchartSubgraphBounds): {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
} {
|
||||
const lines = splitDiagramLines(bounds.label)
|
||||
return {
|
||||
left: bounds.left + 2,
|
||||
top: bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length,
|
||||
width: Math.max(...lines.map((line) => diagramTextWidth(` ${line} `))),
|
||||
height: lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
function avoidNodeObstacles(
|
||||
route: FlowchartEdgeRoute,
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
@@ -815,10 +892,23 @@ function avoidNodeObstacles(
|
||||
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
|
||||
return pathIntersectsBounds(candidate.points, bound, allowedContact)
|
||||
})
|
||||
const intersectsStructuralObstacle = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
intersectsNode(candidate) ||
|
||||
allSubgraphBounds.some(
|
||||
(bound) =>
|
||||
pathRunsAlongFrame(candidate.points, bound) ||
|
||||
(bound.label.length > 0 && pathIntersectsBounds(candidate.points, subgraphTitleBounds(bound))),
|
||||
)
|
||||
const intersectsRoutingObstacle = (candidate: FlowchartEdgeRoute): boolean =>
|
||||
intersectsStructuralObstacle(candidate) ||
|
||||
endpointConflictsWithRoutes(candidate, otherRoutes) ||
|
||||
otherRoutes.some(
|
||||
(other) => endpointDisjoint(candidate.edge, other.edge) && pathsIntersect(candidate.points, other.points),
|
||||
)
|
||||
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
|
||||
const label = candidate.edge.label ? flowchartRouteLabelLayout(candidate, diagramTextWidth) : undefined
|
||||
return (
|
||||
intersectsNode(candidate) ||
|
||||
intersectsRoutingObstacle(candidate) ||
|
||||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
@@ -839,19 +929,19 @@ function avoidNodeObstacles(
|
||||
const rightBusXs = [
|
||||
...new Set([
|
||||
rightBusX,
|
||||
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + BUS_CLEARANCE)),
|
||||
...otherLabels.map((label) => Math.max(rightBusX, label.point.x + label.width - 1 + NODE_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const leftBusXs = [
|
||||
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - BUS_CLEARANCE))]),
|
||||
...new Set([leftBusX, ...otherLabels.map((label) => Math.min(leftBusX, label.point.x - NODE_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const topBusYs = [
|
||||
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - BUS_CLEARANCE))]),
|
||||
...new Set([topBusY, ...otherLabels.map((label) => Math.min(topBusY, label.point.y - NODE_CLEARANCE))]),
|
||||
].sort((left, right) => right - left)
|
||||
const bottomBusYs = [
|
||||
...new Set([
|
||||
bottomBusY,
|
||||
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + BUS_CLEARANCE)),
|
||||
...otherLabels.map((label) => Math.max(bottomBusY, label.point.y + label.height - 1 + NODE_CLEARANCE)),
|
||||
]),
|
||||
].sort((left, right) => left - right)
|
||||
const busLimit = Math.max(1, Math.floor(Math.sqrt(ROUTING_CANDIDATE_BUDGET / 4)))
|
||||
@@ -953,7 +1043,7 @@ function avoidNodeObstacles(
|
||||
if (from.id === to.id)
|
||||
return (
|
||||
shortest(selfLoops, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(selfLoops, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(selfLoops, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
const currentTargetSide = sideForOutsidePoint(to, route.points.at(-1)!)
|
||||
@@ -1002,8 +1092,8 @@ function avoidNodeObstacles(
|
||||
shortest(sameSides, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
@@ -1012,8 +1102,8 @@ function avoidNodeObstacles(
|
||||
shortest(preservedTargets, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
attachments.find((candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsObstacle(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsNode(candidate)) ??
|
||||
shortest(attachments, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
shortest(preservedSources, (candidate) => !intersectsStructuralObstacle(candidate)) ??
|
||||
route
|
||||
)
|
||||
}
|
||||
@@ -1023,6 +1113,8 @@ function avoidLabelOverlap(
|
||||
otherRoutes: readonly FlowchartEdgeRoute[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
|
||||
targetWidth?: number,
|
||||
includeLabelWidth = true,
|
||||
): FlowchartEdgeRoute {
|
||||
if (!route.edge.label) return route
|
||||
const nodeBounds = [...bounds.values()]
|
||||
@@ -1040,7 +1132,14 @@ function avoidLabelOverlap(
|
||||
{ left: sourcePoint.x, top: sourcePoint.y, width: 1, height: 1 },
|
||||
]
|
||||
})
|
||||
const hasParallelRoute = otherRoutes.some(
|
||||
(other) => other.edge.from === route.edge.from && other.edge.to === route.edge.to,
|
||||
)
|
||||
const intersectsObstacle = (label: FlowchartEdgeLabelLayout): boolean =>
|
||||
(targetWidth !== undefined &&
|
||||
(hasParallelRoute || !includeLabelWidth
|
||||
? label.point.x > targetWidth
|
||||
: label.point.x + label.width > targetWidth)) ||
|
||||
nodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
|
||||
frameBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
|
||||
labelIntersectsLabels(label, otherLabels) ||
|
||||
@@ -1094,6 +1193,15 @@ function avoidLabelOverlap(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetWidth !== undefined && includeLabelWidth && current.point.x + current.width > targetWidth) {
|
||||
const x = Math.max(0, targetWidth - current.width)
|
||||
for (let distance = 0; distance < 100; distance++) {
|
||||
for (const y of distance === 0 ? [current.point.y] : [current.point.y - distance, current.point.y + distance]) {
|
||||
if (y < 0 || intersectsObstacle({ ...current, point: { x, y } })) continue
|
||||
return { ...route, labelPoint: { x, y } }
|
||||
}
|
||||
}
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
@@ -1102,6 +1210,8 @@ export function routeFlowchartEdges(
|
||||
bounds: Map<string, FlowchartNodeBounds>,
|
||||
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
|
||||
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
targetWidth?: number,
|
||||
directionAligned = false,
|
||||
): FlowchartEdgeRoute[] {
|
||||
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
|
||||
const handled = new Set<FlowchartEdge>()
|
||||
@@ -1110,7 +1220,7 @@ export function routeFlowchartEdges(
|
||||
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
|
||||
: undefined
|
||||
|
||||
routeParallelEdges(routedDiagram, bounds, handled, routes)
|
||||
routeParallelEdges(routedDiagram, bounds, directionForEdge, directionAligned, handled, routes)
|
||||
|
||||
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
|
||||
const horizontalEdges = routedDiagram.edges.filter(
|
||||
@@ -1145,11 +1255,109 @@ export function routeFlowchartEdges(
|
||||
for (let index = routes.length - 1; index >= 0; index--) {
|
||||
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
|
||||
}
|
||||
const subgraphs = diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
const containers = (id: string) => {
|
||||
const ids = new Set<string>()
|
||||
let current = subgraphs.find((subgraph) => subgraph.nodeIds.includes(id))
|
||||
while (current) {
|
||||
ids.add(current.id)
|
||||
current = current.parentId ? subgraphById.get(current.parentId) : undefined
|
||||
}
|
||||
return ids
|
||||
}
|
||||
const groupedLabelEdge = (edge: FlowchartEdge) => {
|
||||
const fromContainers = containers(edge.from)
|
||||
if (![...containers(edge.to)].some((id) => fromContainers.has(id))) return false
|
||||
const targets = new Set(
|
||||
routedDiagram.edges
|
||||
.filter((candidate) => candidate.label && candidate.from === edge.from)
|
||||
.map((candidate) => candidate.to),
|
||||
)
|
||||
const sources = new Set(
|
||||
routedDiagram.edges
|
||||
.filter((candidate) => candidate.label && candidate.to === edge.to)
|
||||
.map((candidate) => candidate.from),
|
||||
)
|
||||
return targets.size > 1 || sources.size > 1
|
||||
}
|
||||
return routes.reduce<FlowchartEdgeRoute[]>((resolved, route, index) => {
|
||||
return [...resolved, avoidLabelOverlap(route, [...resolved, ...routes.slice(index + 1)], bounds, subgraphBounds)]
|
||||
const grouped = groupedLabelEdge(route.edge)
|
||||
return [
|
||||
...resolved,
|
||||
avoidLabelOverlap(
|
||||
route,
|
||||
[...resolved, ...routes.slice(index + 1)],
|
||||
bounds,
|
||||
subgraphBounds,
|
||||
targetWidth !== undefined && subgraphs.length > 0 && grouped ? Math.max(1, targetWidth - 5) : targetWidth,
|
||||
subgraphs.length === 0 || grouped,
|
||||
),
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function avoidFlowchartFrameBorders(
|
||||
routes: readonly FlowchartEdgeRoute[],
|
||||
bounds: ReadonlyMap<string, FlowchartNodeBounds>,
|
||||
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
|
||||
): void {
|
||||
const inside = (node: FlowchartNodeBounds, frame: FlowchartSubgraphBounds) =>
|
||||
node.left >= frame.left &&
|
||||
node.top >= frame.top &&
|
||||
node.left + node.width <= frame.left + frame.width &&
|
||||
node.top + node.height <= frame.top + frame.height
|
||||
|
||||
for (const route of routes) {
|
||||
const source = bounds.get(route.edge.from)
|
||||
const target = bounds.get(route.edge.to)
|
||||
for (const frame of subgraphBounds.values()) {
|
||||
const inward = Boolean(source && target && inside(source, frame) && inside(target, frame))
|
||||
const right = frame.left + frame.width - 1
|
||||
const bottom = frame.top + frame.height - 1
|
||||
const points: FlowchartPoint[] = [route.points[0]!]
|
||||
for (let index = 1; index < route.points.length; index++) {
|
||||
const from = route.points[index - 1]!
|
||||
const to = route.points[index]!
|
||||
const segment = segmentBetween(from, to)
|
||||
if (!segment) continue
|
||||
const span = segmentSpan(segment)
|
||||
const horizontalSide =
|
||||
segment.axis === "x" && Math.min(span.end, right) > Math.max(span.start, frame.left)
|
||||
? segment.from.y === frame.top
|
||||
? "top"
|
||||
: segment.from.y === bottom
|
||||
? "bottom"
|
||||
: undefined
|
||||
: undefined
|
||||
const verticalSide =
|
||||
segment.axis === "y" && Math.min(span.end, bottom) > Math.max(span.start, frame.top)
|
||||
? segment.from.x === frame.left
|
||||
? "left"
|
||||
: segment.from.x === right
|
||||
? "right"
|
||||
: undefined
|
||||
: undefined
|
||||
if (!horizontalSide && !verticalSide) {
|
||||
points.push(to)
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = horizontalSide
|
||||
? horizontalSide === "top"
|
||||
? frame.top + (inward ? 1 : -1)
|
||||
: bottom + (inward ? -1 : 1)
|
||||
: verticalSide === "left"
|
||||
? frame.left + (inward ? 1 : -1)
|
||||
: right + (inward ? -1 : 1)
|
||||
if (horizontalSide) points.push({ x: from.x, y: offset }, { x: to.x, y: offset }, to)
|
||||
else points.push({ x: offset, y: from.y }, { x: offset, y: to.y }, to)
|
||||
}
|
||||
route.points = pathThrough(points)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
|
||||
if (sourcePoint.x < bounds.left) return "left"
|
||||
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type DiagramRgb,
|
||||
} from "../core/color/style.js"
|
||||
|
||||
export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
|
||||
export type FlowchartBaseCellStyle = "node" | "nodeBorder" | "database" | "databaseBorder" | "edge" | "label" | "group"
|
||||
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
||||
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
||||
@@ -22,7 +22,9 @@ export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetad
|
||||
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
||||
export const DEFAULT_THEME_RGB = {
|
||||
node: [228, 239, 232],
|
||||
nodeBorder: [141, 163, 151],
|
||||
database: [228, 239, 232],
|
||||
databaseBorder: [141, 163, 151],
|
||||
edge: [134, 225, 200],
|
||||
label: [134, 225, 200],
|
||||
group: [76, 99, 89],
|
||||
@@ -35,16 +37,20 @@ export function resolveFlowchartStyleColors(
|
||||
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
||||
): FlowchartStyleColors {
|
||||
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
||||
const nodeBorder = colors.nodeBorder ?? rgba(DEFAULT_THEME_RGB.nodeBorder)
|
||||
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
||||
const databaseBorder = colors.databaseBorder ?? rgba(DEFAULT_THEME_RGB.databaseBorder)
|
||||
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
||||
return {
|
||||
node,
|
||||
nodeBorder,
|
||||
database,
|
||||
databaseBorder,
|
||||
edge,
|
||||
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
||||
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, node, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, database, edge),
|
||||
...createColorRampTheme(NODE_EDGE_FADE_STYLES, nodeBorder, edge),
|
||||
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, databaseBorder, edge),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { auditAllFixtures, auditFixture, summarizeAudits, worstAudits } from "./test/layout-audit/harness.js"
|
||||
import { layoutFixtures } from "./test/layout-audit/fixtures.js"
|
||||
|
||||
test("audits deterministic flowchart and state layout families", () => {
|
||||
const fixtures = layoutFixtures()
|
||||
const flowcharts = fixtures.filter((fixture) => fixture.kind === "flowchart")
|
||||
const states = fixtures.filter((fixture) => fixture.kind === "state")
|
||||
expect(flowcharts.length).toBeGreaterThanOrEqual(100)
|
||||
expect(states.length).toBeGreaterThanOrEqual(100)
|
||||
expect(new Set(fixtures.map((fixture) => fixture.id)).size).toBe(fixtures.length)
|
||||
|
||||
const startedAt = performance.now()
|
||||
const audits = auditAllFixtures()
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
const violations = audits.flatMap((audit) =>
|
||||
audit.violations.map((violation) => `${audit.fixture.id} @${audit.viewport}: ${violation}`),
|
||||
)
|
||||
const summary = summarizeAudits(audits)
|
||||
|
||||
expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
|
||||
expect(violations).toEqual([])
|
||||
expect(elapsedMs).toBeLessThan(35_000)
|
||||
for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
|
||||
expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
|
||||
}
|
||||
for (const id of ["state/chain/lr-long", "state/chain/rl-long"]) {
|
||||
const audit = audits.find((candidate) => candidate.fixture.id === id)!
|
||||
expect(audit.viewport).toBe(120)
|
||||
expect([audit.metrics.width, audit.metrics.height, audit.metrics.overflow]).toEqual([84, 41, 0])
|
||||
}
|
||||
expect(
|
||||
audits
|
||||
.filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
|
||||
.map((audit) => audit.viewport),
|
||||
).toEqual([60, 80, 120])
|
||||
expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
|
||||
expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
|
||||
expect(summary.total.bends.max).toBeLessThanOrEqual(30)
|
||||
expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
|
||||
expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
|
||||
expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
|
||||
expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
|
||||
expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
|
||||
expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
|
||||
expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
|
||||
expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
|
||||
expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
|
||||
expect(summary.state.crossings.total).toBe(0)
|
||||
|
||||
for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
|
||||
(family) => family[0]!,
|
||||
)) {
|
||||
const first = auditFixture(fixture, 80)
|
||||
const second = auditFixture(fixture, 80)
|
||||
expect(second.output).toBe(first.output)
|
||||
expect(second.metrics).toEqual(first.metrics)
|
||||
expect(second.violations).toEqual(first.violations)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[layout-audit] ${fixtures.length} sources, ${audits.length} runs, ${elapsedMs.toFixed(0)}ms`,
|
||||
JSON.stringify({
|
||||
summary,
|
||||
worst: {
|
||||
area: worstAudits(audits, "area", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.area]),
|
||||
bends: worstAudits(audits, "bends", 3).map((audit) => [audit.fixture.id, audit.viewport, audit.metrics.bends]),
|
||||
crossings: worstAudits(audits, "crossings", 3).map((audit) => [
|
||||
audit.fixture.id,
|
||||
audit.viewport,
|
||||
audit.metrics.crossings,
|
||||
]),
|
||||
overflow: worstAudits(audits, "overflow", 3).map((audit) => [
|
||||
audit.fixture.id,
|
||||
audit.viewport,
|
||||
audit.metrics.overflow,
|
||||
]),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 40_000)
|
||||
@@ -51,7 +51,7 @@ interface PreparedDiagram {
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
/** Use terminal-optimized diagram spacing. Defaults to true. */
|
||||
compact?: boolean
|
||||
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
|
||||
/** Fold responsive horizontal diagrams that exceed this width. Defaults to 120 columns. */
|
||||
layoutMaxWidth?: number
|
||||
/** Gantt-specific terminal rendering options. */
|
||||
gantt?: Omit<GanttDiagramRenderOptions, "layoutMaxWidth">
|
||||
@@ -141,7 +141,9 @@ function prepareDiagram(
|
||||
grid,
|
||||
resolveFlowchartStyleColors({
|
||||
node: color(colors.primary),
|
||||
nodeBorder: color(colors.muted),
|
||||
database: color(colors.primary),
|
||||
databaseBorder: color(colors.muted),
|
||||
edge: color(colors.secondary),
|
||||
label: color(colors.text),
|
||||
group: color(colors.muted),
|
||||
@@ -215,8 +217,8 @@ function prepareDiagram(
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
|
||||
const size = grid.getTextSize({ trimBottom: true })
|
||||
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source), { layoutMaxWidth })
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
source,
|
||||
|
||||
@@ -3,7 +3,7 @@ import stringWidth from "string-width"
|
||||
import { spatialPathClaim } from "../core/spatial.js"
|
||||
import { expectDiagram } from "../test/diagram.js"
|
||||
import { renderStateDiagram } from "./diagram.js"
|
||||
import { drawStateDiagramGrid } from "./drawing.js"
|
||||
import { createStateDiagramDrawing, drawStateDiagramGrid } from "./drawing.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { prepareVisibleStateDiagram } from "./visible-model.js"
|
||||
@@ -60,6 +60,35 @@ function expectCompleteStateDiagram(source: string, output = renderStateDiagram(
|
||||
}
|
||||
}
|
||||
|
||||
type ResponsiveStateLabelProfile = "short" | "long" | "unicode"
|
||||
|
||||
function responsiveStateChain(direction: "LR" | "RL", profile: ResponsiveStateLabelProfile): string {
|
||||
const stateLabel = (id: string) => {
|
||||
if (profile === "long") return `${id} deliberate state with a long descriptive label`
|
||||
if (profile === "unicode") return `${id} 東京<br/>résumé 🚀`
|
||||
return `${id} node`
|
||||
}
|
||||
const transitionLabel = (id: string) => {
|
||||
if (profile === "long") return `${id} transition carrying detailed context`
|
||||
if (profile === "unicode") return `${id} 東京<br/>✓ prêt`
|
||||
return `${id} edge`
|
||||
}
|
||||
const ids = ["A", "B", "C", "D", "E"]
|
||||
return [
|
||||
"stateDiagram-v2",
|
||||
`direction ${direction}`,
|
||||
...ids.map((id) => `state "${stateLabel(id)}" as ${id}`),
|
||||
"[*] --> A",
|
||||
...ids.slice(0, -1).map((id, index) => `${id} --> ${ids[index + 1]}: ${transitionLabel(`E0${index + 1}`)}`),
|
||||
"E --> [*]",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function renderedStateDimensions(output: string) {
|
||||
const lines = output.split("\n")
|
||||
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
|
||||
}
|
||||
|
||||
describe("StateDiagram", () => {
|
||||
test("detects and parses Mermaid state diagrams", () => {
|
||||
const diagram = parseMermaidStateDiagram(`
|
||||
@@ -179,6 +208,95 @@ stateDiagram-v2
|
||||
expect(output).toContain("◀")
|
||||
})
|
||||
|
||||
test("renders reverse vertical direction from bottom to top", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction BT
|
||||
A --> B`
|
||||
const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
|
||||
|
||||
expect(drawing.layout.bounds.get("A")!.top).toBeGreaterThan(drawing.layout.bounds.get("B")!.top)
|
||||
expect(drawing.grid.toString({ trimTop: true, trimBottom: true })).toContain("▲")
|
||||
})
|
||||
|
||||
test.each(
|
||||
(["LR", "RL"] as const).flatMap((direction) =>
|
||||
(["short", "long", "unicode"] as const).flatMap((profile) =>
|
||||
([60, 80, 120] as const).map((layoutMaxWidth) => [direction, profile, layoutMaxWidth] as const),
|
||||
),
|
||||
),
|
||||
)("folds responsive %s %s chains at %d columns", (direction, profile, layoutMaxWidth) => {
|
||||
const source = responsiveStateChain(direction, profile)
|
||||
const horizontal = renderStateDiagram(source)
|
||||
const responsive = renderStateDiagram(source, { layoutMaxWidth })
|
||||
const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
|
||||
|
||||
expect(renderedStateDimensions(horizontal).width).toBeGreaterThan(layoutMaxWidth)
|
||||
expect(responsive).toBe(vertical)
|
||||
expect(renderedStateDimensions(responsive).width).toBeLessThan(renderedStateDimensions(horizontal).width)
|
||||
for (const content of ["A", "B", "C", "D", "E", "E01", "E02", "E03", "E04"]) {
|
||||
expect(responsive).toContain(content)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["LR", "RL"] as const)("keeps the narrower %s orientation for broad ranks", (direction) => {
|
||||
const source = `stateDiagram-v2
|
||||
direction ${direction}
|
||||
${Array.from({ length: 8 }, (_, index) => ` A --> B${index}`).join("\n")}`
|
||||
const horizontal = renderStateDiagram(source)
|
||||
const vertical = renderStateDiagram(source, { direction: direction === "RL" ? "BT" : "TB" })
|
||||
const responsive = renderStateDiagram(source, { layoutMaxWidth: 60 })
|
||||
|
||||
expect(renderedStateDimensions(horizontal).width).toBeLessThan(renderedStateDimensions(vertical).width)
|
||||
expect(responsive).toBe(horizontal)
|
||||
})
|
||||
|
||||
test("falls back before allocating an oversized horizontal canvas", () => {
|
||||
const ids = Array.from({ length: 301 }, (_, index) => `S${index}`)
|
||||
const label = "transition label carrying enough context to make the horizontal canvas too large"
|
||||
const source = `stateDiagram-v2
|
||||
direction LR
|
||||
${ids
|
||||
.slice(0, -1)
|
||||
.map((id, index) => ` ${id} --> ${ids[index + 1]}: ${label}`)
|
||||
.join("\n")}`
|
||||
const output = renderStateDiagram(source, { layoutMaxWidth: 80 })
|
||||
|
||||
expect(output).toContain("S0")
|
||||
expect(output).toContain("S300")
|
||||
expect(renderedStateDimensions(output).width).toBeLessThanOrEqual(stringWidth(label) + 8)
|
||||
})
|
||||
|
||||
test.each(["TB", "TD", "BT"] as const)("preserves explicit %s layouts under a narrow width target", (direction) => {
|
||||
const source = `stateDiagram-v2
|
||||
direction ${direction}
|
||||
A --> B: next`
|
||||
|
||||
expect(renderStateDiagram(source, { layoutMaxWidth: 1 })).toBe(renderStateDiagram(source))
|
||||
})
|
||||
|
||||
test("preserves horizontal layouts that fit or have no finite width target", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction LR
|
||||
A --> B`
|
||||
const output = renderStateDiagram(source)
|
||||
|
||||
expect(renderStateDiagram(source, { layoutMaxWidth: 120 })).toBe(output)
|
||||
expect(renderStateDiagram(source, { layoutMaxWidth: Number.POSITIVE_INFINITY })).toBe(output)
|
||||
})
|
||||
|
||||
test("treats a single irreducibly wide state as soft overflow", () => {
|
||||
const label = "界".repeat(40)
|
||||
const output = renderStateDiagram(
|
||||
`stateDiagram-v2
|
||||
direction LR
|
||||
state "${label}" as Wide`,
|
||||
{ layoutMaxWidth: 60 },
|
||||
)
|
||||
|
||||
expect(renderedStateDimensions(output).width).toBeGreaterThan(60)
|
||||
expect(output).toContain(label)
|
||||
})
|
||||
|
||||
test("does not mutate a parsed diagram when rendering with a direction override", () => {
|
||||
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
@@ -249,24 +367,21 @@ stateDiagram-v2
|
||||
|
||||
for (const line of labelLines) expect(output.split(line)).toHaveLength(2)
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"
|
||||
create from base image ╭─────────╮
|
||||
" create from base image ╭─────────╮
|
||||
●───────────────────────▶│ Running │
|
||||
╰──┬──────╯ 💥 sandbox dies BEFORE hook fires
|
||||
▲ │ ▲ (crash, our bug, race)
|
||||
╭────────┼─┴───┼───────╮
|
||||
╰──┬──────╯
|
||||
▲ │ ▲ 💥 sandbox dies BEFORE hook fires
|
||||
╭────────┼─┴───┼───────╮(crash, our bug, race)
|
||||
▼ ╭────┼─────╯ ▼
|
||||
╭──────┴──╮ │ ╭──────╮
|
||||
│ Dormant │ │ │ Lost │
|
||||
╰─────────╯ │ ╰───┬──╯
|
||||
│ │
|
||||
📸 suspend hook fires │ │
|
||||
(WE must call it on idle)│ │
|
||||
📸 suspend hook fires │ │ wake from LAST snapshot
|
||||
(WE must call it on idle)│ │ ⚠ files since then GONE
|
||||
╰───────────────╯
|
||||
wake from snapshot image
|
||||
(apt installs restored!)
|
||||
wake from LAST snapshot
|
||||
⚠ files since then GONE"
|
||||
(apt installs restored!)"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -505,6 +620,14 @@ stateDiagram-v2
|
||||
expect(output.split("\n").filter((line) => line.trim())).toHaveLength(3)
|
||||
})
|
||||
|
||||
test.each(["LR", "RL"] as const)("trims leading rows from standalone %s choices", (direction) => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction ${direction}
|
||||
state Decision <<choice>>`)
|
||||
|
||||
expect(output).toBe("◆")
|
||||
})
|
||||
|
||||
test("renders parallel transitions without losing labels", () => {
|
||||
const horizontal = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
@@ -683,6 +806,135 @@ stateDiagram-v2
|
||||
}
|
||||
})
|
||||
|
||||
test("grows parallel vertical diagrams by the maximum label width rather than their sum", () => {
|
||||
const render = (labels: readonly string[]) =>
|
||||
renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
${labels.map((label) => ` A --> B: ${label}`).join("\n")}`)
|
||||
const shortLabels = ["one", "two", "three"]
|
||||
const longLabels = [
|
||||
"alpha route label that is deliberately long",
|
||||
"beta route label that is deliberately long",
|
||||
"gamma route label that is deliberately long",
|
||||
]
|
||||
const width = (output: string) => Math.max(...output.split("\n").map((line) => stringWidth(line)))
|
||||
const labelGrowth =
|
||||
Math.max(...longLabels.map((label) => stringWidth(label))) -
|
||||
Math.max(...shortLabels.map((label) => stringWidth(label)))
|
||||
|
||||
expect(width(render(longLabels)) - width(render(shortLabels))).toBeLessThanOrEqual(labelGrowth + 2)
|
||||
})
|
||||
|
||||
test("keeps audited parallel labels clear of frames and rails", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: alpha route label that is deliberately long
|
||||
A --> B: beta route label that is deliberately long
|
||||
A --> B: gamma route label that is deliberately long`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮ alpha route label that is deliberately long
|
||||
│ A ├───┬──╮
|
||||
╰─┬─╯ │ │
|
||||
│ │ │ gamma route label that is deliberately long
|
||||
│ │ │ beta route label that is deliberately long
|
||||
│ │ │
|
||||
▼ │ │
|
||||
╭───╮ │ │
|
||||
│ B │◀──┴──╯
|
||||
╰───╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps audited repeated self-transition lanes distinct and readable", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction LR
|
||||
A --> A: one
|
||||
A --> A: two
|
||||
A --> A: three`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭───╮
|
||||
│ A │
|
||||
╰─┬─╯
|
||||
│ ▲ ▲ ▲
|
||||
├──╯ │ │
|
||||
│ │ │
|
||||
│ one │ │
|
||||
├──────╯ │
|
||||
│ │
|
||||
│ two │ three
|
||||
╰──────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("expands composites around self-transition labels without engulfing external states", () => {
|
||||
const source = `stateDiagram-v2
|
||||
direction LR
|
||||
state Outer {
|
||||
A --> A: loop-0
|
||||
A --> A: loop-1
|
||||
A --> A: loop-2
|
||||
}
|
||||
A --> C`
|
||||
const drawing = createStateDiagramDrawing(parseMermaidStateDiagram(source))
|
||||
const outer = drawing.layout.compositeBounds.get("Outer")!
|
||||
const external = drawing.layout.bounds.get("C")!
|
||||
const output = drawing.grid.toString({ trimTop: true, trimBottom: true })
|
||||
|
||||
expect(
|
||||
external.left < outer.left + outer.width &&
|
||||
external.left + external.width > outer.left &&
|
||||
external.top < outer.top + outer.height &&
|
||||
external.top + external.height > outer.top,
|
||||
).toBe(false)
|
||||
for (const plan of drawing.transitionPlans.filter(
|
||||
(plan) => plan.route.transition.from === plan.route.transition.to,
|
||||
)) {
|
||||
expect(plan.label).toBeDefined()
|
||||
expect(plan.label!.x).toBeGreaterThan(outer.left)
|
||||
expect(plan.label!.y).toBeGreaterThan(outer.top)
|
||||
expect(plan.label!.x + Math.max(...plan.label!.lines.map((line) => stringWidth(line)))).toBeLessThan(
|
||||
outer.left + outer.width,
|
||||
)
|
||||
expect(plan.label!.y + plan.label!.lines.length).toBeLessThan(outer.top + outer.height)
|
||||
}
|
||||
for (const label of ["loop-0", "loop-1", "loop-2"]) expect(output.match(new RegExp(label, "g"))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("keeps audited nested note connectors direct and inside every frame", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
state Outer {
|
||||
state Inner {
|
||||
A --> B: down
|
||||
B --> A: up
|
||||
note right of B: note
|
||||
}
|
||||
}`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"╭─ Outer ───────────────╮
|
||||
│ │
|
||||
│ ╭─ Inner ───────────╮ │
|
||||
│ │ │ │
|
||||
│ │ ╭───╮ │ │
|
||||
│ │▶│ A │ │ │
|
||||
│ ││╰─┬─╯ │ │
|
||||
│ ││ │ │ │
|
||||
│ ││ │ down │ │
|
||||
│ ││ │ │ │
|
||||
│ ││ ▼ │ │
|
||||
│ ││╭───╮ ╔══════╗ │ │
|
||||
│ │╰┤ B │════╣ note ║ │ │
|
||||
│ │ ╰───╯ ╚══════╝ │ │
|
||||
│ │up │ │
|
||||
│ ╰───────────────────╯ │
|
||||
│ │
|
||||
╰───────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
test("keeps explicit choices visible in choice-only cycles", () => {
|
||||
const output = renderStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
|
||||
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from "../core/canvas.js"
|
||||
import { directionBetween, orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
|
||||
import {
|
||||
diagramArrowHead,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createStateDiagramLayout,
|
||||
expandCompositeBoundsForFeedback,
|
||||
expandCompositeBoundsForInternalTransitions,
|
||||
separateExternalBoundsFromComposites,
|
||||
translateStateDiagramLayout,
|
||||
type StateDiagramBoxBounds as BoxBounds,
|
||||
type StateDiagramNoteBounds as StateNoteBounds,
|
||||
@@ -67,23 +68,11 @@ function makeGrid(width: number, height: number): StateGrid {
|
||||
})
|
||||
}
|
||||
|
||||
function setCell(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
char: string,
|
||||
style?: StateCellStyle,
|
||||
): void {
|
||||
function setCell(grid: StateGrid, x: number, y: number, char: string, style?: StateCellStyle): void {
|
||||
grid.setCell(x, y, char, style)
|
||||
}
|
||||
|
||||
function setText(
|
||||
grid: StateGrid,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
style?: StateCellStyle,
|
||||
): void {
|
||||
function setText(grid: StateGrid, x: number, y: number, text: string, style?: StateCellStyle): void {
|
||||
grid.setText(x, y, text, style)
|
||||
}
|
||||
|
||||
@@ -108,12 +97,7 @@ function drawBox(
|
||||
})
|
||||
}
|
||||
|
||||
function drawStateFrame(
|
||||
grid: StateGrid,
|
||||
bounds: BoxBounds,
|
||||
chars: BorderCharacters,
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
|
||||
}
|
||||
|
||||
@@ -125,6 +109,10 @@ function drawContainerFrame(
|
||||
style: StateCellStyle,
|
||||
): void {
|
||||
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
|
||||
drawContainerLabel(grid, bounds, label, style)
|
||||
}
|
||||
|
||||
function drawContainerLabel(grid: StateGrid, bounds: BoxBounds, label: string, style: StateCellStyle): void {
|
||||
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
|
||||
}
|
||||
|
||||
@@ -190,9 +178,7 @@ function drawTransitionRenderPlan(
|
||||
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
|
||||
}
|
||||
if (plan.label) {
|
||||
plan.label.lines.forEach((line, index) =>
|
||||
setText(grid, plan.label!.x, plan.label!.y + index, line, "label"),
|
||||
)
|
||||
plan.label.lines.forEach((line, index) => setText(grid, plan.label!.x, plan.label!.y + index, line, "label"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +196,49 @@ function drawTransitionJunctionPlans(
|
||||
}
|
||||
|
||||
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
|
||||
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
|
||||
const diagram = prepareVisibleStateDiagram(directedDiagram)
|
||||
return createStateDiagramDrawing(sourceDiagram, options).grid
|
||||
}
|
||||
|
||||
export function createStateDiagramDrawing(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}) {
|
||||
const direction = options.direction ?? sourceDiagram.direction
|
||||
if (direction !== "LR" && direction !== "RL") {
|
||||
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
|
||||
}
|
||||
if (options.layoutMaxWidth === undefined || !Number.isFinite(options.layoutMaxWidth)) {
|
||||
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
|
||||
}
|
||||
const fallbackDirection = direction === "RL" ? "BT" : "TB"
|
||||
const maxWidth = Math.max(1, Math.trunc(options.layoutMaxWidth))
|
||||
const drawing = (() => {
|
||||
try {
|
||||
return createStateDiagramDrawingWithDirection(sourceDiagram, options, direction)
|
||||
} catch (error) {
|
||||
if (error instanceof DiagramCanvasSizeError) return undefined
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
if (!drawing) return createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
|
||||
if (drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width <= maxWidth) {
|
||||
return drawing
|
||||
}
|
||||
const fallback = createStateDiagramDrawingWithDirection(sourceDiagram, options, fallbackDirection)
|
||||
if (
|
||||
fallback.grid.getTextSize({ trimTop: true, trimBottom: true }).width >=
|
||||
drawing.grid.getTextSize({ trimTop: true, trimBottom: true }).width
|
||||
) {
|
||||
return drawing
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function createStateDiagramDrawingWithDirection(
|
||||
sourceDiagram: StateDiagram,
|
||||
options: StateDiagramRenderOptions,
|
||||
direction: StateDiagram["direction"],
|
||||
) {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction },
|
||||
)
|
||||
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
|
||||
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
|
||||
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
|
||||
@@ -226,21 +253,24 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State
|
||||
const feedbackLaneY = maxY + 3
|
||||
const feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
|
||||
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
|
||||
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
|
||||
feedbackTopY,
|
||||
noteBounds,
|
||||
searchBudget,
|
||||
})
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
let transitionPlans: StateTransitionRenderPlan[] = []
|
||||
const separationAttempts = diagram.states.length + diagram.composites.length + 1
|
||||
for (let attempt = 0; attempt < separationAttempts; attempt++) {
|
||||
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, {
|
||||
feedbackTopY,
|
||||
noteBounds,
|
||||
searchBudget,
|
||||
})
|
||||
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
|
||||
if (!separateExternalBoundsFromComposites(diagram, layout)) break
|
||||
if (attempt === separationAttempts - 1) throw new Error("State composite separation did not converge")
|
||||
}
|
||||
const connectorPoints = noteBounds.flatMap((bound) => bound.connector?.points ?? [])
|
||||
const contentLeft = Math.min(
|
||||
0,
|
||||
...[...bounds.values(), ...noteBounds].map((bound) => bound.left),
|
||||
...connectorPoints.map((point) => point.x),
|
||||
...transitionPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.x),
|
||||
...(plan.label ? [plan.label.x] : []),
|
||||
]),
|
||||
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.x), ...(plan.label ? [plan.label.x] : [])]),
|
||||
)
|
||||
const contentTop = Math.min(
|
||||
0,
|
||||
@@ -305,10 +335,15 @@ export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: State
|
||||
|
||||
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans)
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (bound) drawContainerLabel(grid, bound, composite.label, "composite")
|
||||
}
|
||||
|
||||
for (const noteBound of noteBounds) {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
if (target) drawNote(grid, noteBound, target)
|
||||
}
|
||||
|
||||
return grid
|
||||
return { grid, diagram, layout, transitionPlans }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { spatialPathClaim } from "../core/spatial.js"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import type { StateDiagram } from "./types.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
import { createStateDiagramLayout, expandCompositeBoundsForInternalTransitions } from "./layout.js"
|
||||
import { stateDiagramNoteConnector } from "./note.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
import { createStateTransitionRenderPlans } from "./routing.js"
|
||||
@@ -192,4 +192,75 @@ describe("StateDiagramLayout", () => {
|
||||
)
|
||||
expect(plans.every((plan) => plan.path.every(([x, y]) => !noteCells.has(`${x}:${y}`)))).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["LR", -1],
|
||||
["RL", 1],
|
||||
] as const)("keeps a reciprocal pair on the %s axis", (direction, expectedSign) => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction ${direction}
|
||||
A --> B: forward
|
||||
B --> A: backward`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const a = layout.bounds.get("A")!
|
||||
const b = layout.bounds.get("B")!
|
||||
|
||||
expect(a.centerY).toBe(b.centerY)
|
||||
expect(Math.sign(a.centerX - b.centerX)).toBe(expectedSign)
|
||||
})
|
||||
|
||||
test.each(["TB", "TD"] as const)(
|
||||
"contains nested internal feedback with strict margins in %s diagrams",
|
||||
(direction) => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction ${direction}
|
||||
state Outer {
|
||||
state Inner {
|
||||
A --> B: down
|
||||
B --> A: up
|
||||
note right of B: nested note
|
||||
}
|
||||
}`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30, { noteBounds: layout.noteBounds })
|
||||
expandCompositeBoundsForInternalTransitions(diagram, layout.compositeBounds, plans)
|
||||
const inner = layout.compositeBounds.get("Inner")!
|
||||
const outer = layout.compositeBounds.get("Outer")!
|
||||
const note = layout.noteBounds[0]!
|
||||
|
||||
for (const composite of [inner, outer]) {
|
||||
for (const plan of plans) {
|
||||
expect(
|
||||
plan.path.every(
|
||||
([x, y]) =>
|
||||
x > composite.left &&
|
||||
x < composite.left + composite.width - 1 &&
|
||||
y > composite.top &&
|
||||
y < composite.top + composite.height - 1,
|
||||
),
|
||||
).toBe(true)
|
||||
}
|
||||
expect(
|
||||
note.connector!.points.every(
|
||||
(point) =>
|
||||
point.x > composite.left &&
|
||||
point.x < composite.left + composite.width - 1 &&
|
||||
point.y > composite.top &&
|
||||
point.y < composite.top + composite.height - 1,
|
||||
),
|
||||
).toBe(true)
|
||||
}
|
||||
expect(new Set(note.connector!.points.map((point) => point.y))).toEqual(
|
||||
new Set([layout.bounds.get("B")!.centerY]),
|
||||
)
|
||||
expect(inner.left - outer.left).toBeGreaterThanOrEqual(2)
|
||||
expect(inner.top - outer.top).toBeGreaterThanOrEqual(2)
|
||||
expect(outer.left + outer.width - (inner.left + inner.width)).toBeGreaterThanOrEqual(2)
|
||||
expect(outer.top + outer.height - (inner.top + inner.height)).toBeGreaterThanOrEqual(2)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -131,7 +131,8 @@ function computeMainPath(diagram: StateDiagram): string[] {
|
||||
const fromParent = statesById.get(current)?.parentId
|
||||
const toParent = statesById.get(transition.to)?.parentId
|
||||
return Boolean(fromParent && toParent && fromParent !== toParent)
|
||||
})
|
||||
}) ??
|
||||
(path.length === 1 && candidates.length === 1 ? candidates[0] : undefined)
|
||||
if (!next) break
|
||||
path.push(next.to)
|
||||
visited.add(next.to)
|
||||
@@ -319,7 +320,12 @@ function findNoteConnector(
|
||||
const isFree = (point: DiagramPoint): boolean =>
|
||||
point.x >= 0 &&
|
||||
!search.blocked.has(`${point.x}:${point.y}`) &&
|
||||
!(point.x >= bounds.left && point.x < bounds.left + bounds.width && point.y >= bounds.top && point.y < bounds.top + bounds.height)
|
||||
!(
|
||||
point.x >= bounds.left &&
|
||||
point.x < bounds.left + bounds.width &&
|
||||
point.y >= bounds.top &&
|
||||
point.y < bounds.top + bounds.height
|
||||
)
|
||||
if (!isFree(end) || !isFree(goal)) return undefined
|
||||
|
||||
for (const start of starts.filter(isFree)) {
|
||||
@@ -335,14 +341,7 @@ function findNoteConnector(
|
||||
const minY = Math.min(target.top, bounds.top, search.minY) - margin
|
||||
const maxX = Math.max(target.left + target.width, bounds.left + bounds.width, search.maxX) + margin
|
||||
const maxY = Math.max(target.top + target.height, bounds.top + bounds.height, search.maxY) + margin
|
||||
const path = findStateManhattanPath(
|
||||
starts,
|
||||
goal,
|
||||
search,
|
||||
{ minX: 0, minY, maxX, maxY },
|
||||
budget,
|
||||
isFree,
|
||||
)
|
||||
const path = findStateManhattanPath(starts, goal, search, { minX: 0, minY, maxX, maxY }, budget, isFree)
|
||||
return path ? { connectorY, points: [...path, end] } : undefined
|
||||
}
|
||||
|
||||
@@ -375,13 +374,27 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
|
||||
)
|
||||
if (descendantNotes.length === 0) continue
|
||||
|
||||
const childBounds = [bound, ...descendantNotes]
|
||||
const noteTop = Math.min(...childBounds.map((child) => child.top), bound.top)
|
||||
const noteBottom = Math.max(...childBounds.map((child) => child.top + child.height), bound.top + bound.height)
|
||||
const left = Math.min(...childBounds.map((child) => child.left)) - 2
|
||||
const top = noteTop < bound.top ? noteTop - 1 : bound.top
|
||||
const right = Math.max(...childBounds.map((child) => child.left + child.width)) + 2
|
||||
const bottom = noteBottom > bound.top + bound.height ? noteBottom + 1 : bound.top + bound.height
|
||||
const connectorPoints = descendantNotes.flatMap((note) => note.connector?.points ?? [])
|
||||
const left = Math.min(
|
||||
bound.left,
|
||||
...descendantNotes.map((note) => note.left - 2),
|
||||
...connectorPoints.map((point) => point.x - 1),
|
||||
)
|
||||
const top = Math.min(
|
||||
bound.top,
|
||||
...descendantNotes.map((note) => note.top - 1),
|
||||
...connectorPoints.map((point) => point.y - 1),
|
||||
)
|
||||
const right = Math.max(
|
||||
bound.left + bound.width,
|
||||
...descendantNotes.map((note) => note.left + note.width + 2),
|
||||
...connectorPoints.map((point) => point.x + 2),
|
||||
)
|
||||
const bottom = Math.max(
|
||||
bound.top + bound.height,
|
||||
...descendantNotes.map((note) => note.top + note.height + 1),
|
||||
...connectorPoints.map((point) => point.y + 2),
|
||||
)
|
||||
|
||||
bound.left = left
|
||||
bound.top = top
|
||||
@@ -392,13 +405,112 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
|
||||
}
|
||||
}
|
||||
|
||||
function expandCompositeBoundsForInternalRouting(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
if (diagram.direction === "LR" || diagram.direction === "RL") return
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
|
||||
for (const composite of [...diagram.composites].reverse()) {
|
||||
const bound = layout.compositeBounds.get(composite.id)
|
||||
if (!bound) continue
|
||||
const internal = diagram.transitions.filter(
|
||||
(transition) =>
|
||||
transition.from !== transition.to &&
|
||||
innermostCommonCompositeId(transition, statesById, compositesById) === composite.id,
|
||||
)
|
||||
const endpointOccurrences = new Map<string, number>()
|
||||
const sideRoutes = internal.filter((transition) => {
|
||||
const from = layout.bounds.get(transition.from)
|
||||
const to = layout.bounds.get(transition.to)
|
||||
if (!from || !to) return false
|
||||
const key = `${transition.from}\u0000${transition.to}`
|
||||
const occurrence = endpointOccurrences.get(key) ?? 0
|
||||
endpointOccurrences.set(key, occurrence + 1)
|
||||
const fromParent = statesById.get(transition.from)?.parentId
|
||||
const toParent = statesById.get(transition.to)?.parentId
|
||||
return occurrence > 0 || from.centerY > to.centerY || fromParent !== toParent
|
||||
})
|
||||
if (sideRoutes.length === 0) continue
|
||||
const childRight = Math.max(
|
||||
...diagram.states.flatMap((state) => {
|
||||
if (!belongsToComposite(state.id, composite.id, statesById, compositesById)) return []
|
||||
const child = layout.bounds.get(state.id)
|
||||
return child ? [child.left + child.width] : []
|
||||
}),
|
||||
...diagram.composites.flatMap((childComposite) => {
|
||||
if (childComposite.parentId !== composite.id) return []
|
||||
const child = layout.compositeBounds.get(childComposite.id)
|
||||
return child ? [child.left + child.width] : []
|
||||
}),
|
||||
)
|
||||
const labelWidth = Math.max(...sideRoutes.map((transition) => measureStateTransitionLabel(transition.label).width))
|
||||
const right = childRight + labelWidth + sideRoutes.length * 3 + 6
|
||||
if (right <= bound.left + bound.width) continue
|
||||
bound.width = right - bound.left
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
}
|
||||
|
||||
enforceCompositeMargins(diagram, layout.compositeBounds)
|
||||
}
|
||||
|
||||
function innermostCommonCompositeId(
|
||||
transition: StateDiagramTransition,
|
||||
statesById: Map<string, StateDiagramState>,
|
||||
compositesById: Map<string, StateDiagramCompositeState>,
|
||||
): string | undefined {
|
||||
const containers = (id: string) => {
|
||||
const ids: string[] = []
|
||||
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
||||
while (parentId) {
|
||||
ids.push(parentId)
|
||||
parentId = compositesById.get(parentId)?.parentId
|
||||
}
|
||||
return ids
|
||||
}
|
||||
const target = new Set(containers(transition.to))
|
||||
return containers(transition.from).find((id) => target.has(id))
|
||||
}
|
||||
|
||||
function enforceCompositeMargins(diagram: StateDiagram, compositeBounds: Map<string, StateDiagramBoxBounds>): void {
|
||||
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
|
||||
for (const composite of diagram.composites) {
|
||||
if (!composite.parentId) continue
|
||||
const children = compositesByParent.get(composite.parentId) ?? []
|
||||
children.push(composite)
|
||||
compositesByParent.set(composite.parentId, children)
|
||||
}
|
||||
|
||||
const expand = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
|
||||
const bound = compositeBounds.get(composite.id)
|
||||
if (!bound) return undefined
|
||||
const children = (compositesByParent.get(composite.id) ?? [])
|
||||
.map(expand)
|
||||
.filter((child): child is StateDiagramBoxBounds => Boolean(child))
|
||||
if (children.length === 0) return bound
|
||||
const left = Math.min(bound.left, ...children.map((child) => child.left - 2))
|
||||
const top = Math.min(bound.top, ...children.map((child) => child.top - 2))
|
||||
const right = Math.max(bound.left + bound.width, ...children.map((child) => child.left + child.width + 2))
|
||||
const bottom = Math.max(bound.top + bound.height, ...children.map((child) => child.top + child.height + 2))
|
||||
bound.left = left
|
||||
bound.top = top
|
||||
bound.width = right - left
|
||||
bound.height = bottom - top
|
||||
bound.centerX = left + Math.floor(bound.width / 2)
|
||||
bound.centerY = top + Math.floor(bound.height / 2)
|
||||
return bound
|
||||
}
|
||||
|
||||
for (const composite of diagram.composites.filter((candidate) => !candidate.parentId)) expand(composite)
|
||||
}
|
||||
|
||||
function boundsIntersect(left: StateDiagramBoxBounds, right: StateDiagramBoxBounds): boolean {
|
||||
return intersects(left.left, left.top, left.width, left.height, right, 0)
|
||||
}
|
||||
|
||||
function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): void {
|
||||
export function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: StateDiagramLayout): boolean {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
let shifted = false
|
||||
|
||||
for (const composite of diagram.composites) {
|
||||
const compositeBound = layout.compositeBounds.get(composite.id)
|
||||
@@ -433,8 +545,10 @@ function separateExternalBoundsFromComposites(diagram: StateDiagram, layout: Sta
|
||||
}
|
||||
|
||||
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
|
||||
shifted = true
|
||||
}
|
||||
}
|
||||
return shifted
|
||||
}
|
||||
|
||||
function finalizeLayout(
|
||||
@@ -445,6 +559,7 @@ function finalizeLayout(
|
||||
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
|
||||
addCompositeBounds(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
expandCompositeBoundsForInternalRouting(diagram, layout)
|
||||
if (diagram.notes.length > 0) {
|
||||
const allBounds = [...layout.bounds.values()]
|
||||
placeStateDiagramNotesAroundTransitions(
|
||||
@@ -464,6 +579,7 @@ function finalizeLayout(
|
||||
)
|
||||
}
|
||||
expandCompositeBoundsForNotes(diagram, layout)
|
||||
expandCompositeBoundsForInternalRouting(diagram, layout)
|
||||
separateExternalBoundsFromComposites(diagram, layout)
|
||||
normalizeLayout(layout)
|
||||
return layout
|
||||
@@ -479,9 +595,10 @@ export function createStateDiagramLayout(
|
||||
}
|
||||
|
||||
const ranks = computeRanks(diagram)
|
||||
const maxRank = Math.max(0, ...ranks.values())
|
||||
const byRank = new Map<number, StateDiagramState[]>()
|
||||
for (const state of diagram.states) {
|
||||
const rank = ranks.get(state.id) ?? 0
|
||||
const rank = diagram.direction === "BT" ? maxRank - (ranks.get(state.id) ?? 0) : (ranks.get(state.id) ?? 0)
|
||||
const list = byRank.get(rank) ?? []
|
||||
list.push(state)
|
||||
byRank.set(rank, list)
|
||||
@@ -491,9 +608,13 @@ export function createStateDiagramLayout(
|
||||
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
|
||||
const bounds = new Map<string, StateDiagramBoxBounds>()
|
||||
const outgoingLabelRows = new Map<string, number>()
|
||||
const selfTransitionCounts = new Map<string, number>()
|
||||
for (const transition of diagram.transitions) {
|
||||
const rows = measureStateTransitionLabel(transition.label).height
|
||||
outgoingLabelRows.set(transition.from, Math.max(outgoingLabelRows.get(transition.from) ?? 0, rows))
|
||||
if (transition.from === transition.to) {
|
||||
selfTransitionCounts.set(transition.from, (selfTransitionCounts.get(transition.from) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const singleColumnCenter = Math.max(
|
||||
@@ -524,8 +645,12 @@ export function createStateDiagramLayout(
|
||||
x += size.width + options.minStateGap + 8
|
||||
}
|
||||
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
|
||||
const selfTransitionRows = states.reduce(
|
||||
(rows, state) => Math.max(rows, (selfTransitionCounts.get(state.id) ?? 0) * 3 + 1),
|
||||
0,
|
||||
)
|
||||
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
|
||||
y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
|
||||
y += rowHeight + Math.max(4, labelRows + 3, selfTransitionRows) + pseudoStateApproachClearance
|
||||
}
|
||||
|
||||
return finalizeLayout(diagram, emptyLayout(bounds, sizes), budget)
|
||||
@@ -805,21 +930,21 @@ function placeStateDiagramNotesAroundTransitions(
|
||||
size,
|
||||
),
|
||||
),
|
||||
)
|
||||
.flat()
|
||||
).flat()
|
||||
const findPlacement = (candidateSpace: SpatialIndex, limit: number) => {
|
||||
const connectorSearch = createStateSearchSpace(candidateSpace, (role) => (role === "label" ? 1 : 0))
|
||||
for (const bound of candidateBounds.slice(0, limit)) {
|
||||
if (bound.left < 0) continue
|
||||
const owner = `note:${index}`
|
||||
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 }))
|
||||
continue
|
||||
if (!candidateSpace.isFree(spatialRectClaim(`${owner}:body`, owner, "body", bound), { clearance: 1 })) continue
|
||||
const connector = findNoteConnector(connectorSearch, bound, target, budget)
|
||||
if (connector) return { bound: { ...bound, connector }, connector }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const placement =
|
||||
findPlacement(space, 1) ??
|
||||
findPlacement(noteSpace, 1) ??
|
||||
findPlacement(space, MAX_STRICT_NOTE_PLACEMENTS) ??
|
||||
findPlacement(reserved, candidateBounds.length) ??
|
||||
outsideNotePlacement(noteSpace, note, index, target, size)
|
||||
@@ -857,10 +982,7 @@ function outsideNotePlacement(
|
||||
size,
|
||||
)
|
||||
const alignedNoteX = position === "left" ? aligned.left + aligned.width : aligned.left - 1
|
||||
const alignedConnectorY = Math.max(
|
||||
aligned.top + 1,
|
||||
Math.min(target.centerY, aligned.top + aligned.height - 2),
|
||||
)
|
||||
const alignedConnectorY = Math.max(aligned.top + 1, Math.min(target.centerY, aligned.top + aligned.height - 2))
|
||||
const alignedTargetX = position === "left" ? target.left - 1 : target.left + target.width
|
||||
const alignedConnector = {
|
||||
connectorY: alignedConnectorY,
|
||||
@@ -975,16 +1097,30 @@ export function expandCompositeBoundsForInternalTransitions(
|
||||
belongsToComposite(plan.route.transition.from, composite.id, statesById, compositesById) &&
|
||||
belongsToComposite(plan.route.transition.to, composite.id, statesById, compositesById),
|
||||
)
|
||||
const occupiedYs = internalPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => cell.y),
|
||||
...(plan.label ? plan.label.lines.map((_, index) => plan.label!.y + index) : []),
|
||||
const occupied = internalPlans.flatMap((plan) => [
|
||||
...plan.cells.map((cell) => ({ x: cell.x, y: cell.y })),
|
||||
...(plan.label
|
||||
? plan.label.lines.flatMap((line, row) =>
|
||||
Array.from({ length: diagramTextWidth(line) }, (_, column) => ({
|
||||
x: plan.label!.x + column,
|
||||
y: plan.label!.y + row,
|
||||
})),
|
||||
)
|
||||
: []),
|
||||
])
|
||||
if (occupiedYs.length === 0) continue
|
||||
if (occupied.length === 0) continue
|
||||
|
||||
const top = Math.min(bound.top, Math.min(...occupiedYs) - 1)
|
||||
const bottom = Math.max(bound.top + bound.height, Math.max(...occupiedYs) + 2)
|
||||
const left = Math.min(bound.left, Math.min(...occupied.map((point) => point.x)) - 1)
|
||||
const top = Math.min(bound.top, Math.min(...occupied.map((point) => point.y)) - 1)
|
||||
const right = Math.max(bound.left + bound.width, Math.max(...occupied.map((point) => point.x)) + 2)
|
||||
const bottom = Math.max(bound.top + bound.height, Math.max(...occupied.map((point) => point.y)) + 2)
|
||||
bound.left = left
|
||||
bound.top = top
|
||||
bound.width = right - left
|
||||
bound.height = bottom - top
|
||||
bound.centerX = bound.left + Math.floor(bound.width / 2)
|
||||
bound.centerY = bound.top + Math.floor(bound.height / 2)
|
||||
}
|
||||
|
||||
enforceCompositeMargins(diagram, compositeBounds)
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ const STATE_RE = /^state\s+"([^"]+)"\s+as\s+(\S+)$/i
|
||||
const COMPOSITE_STATE_RE = /^state\s+(?:"([^"]+)"\s+as\s+)?(\S+)\s*\{$/i
|
||||
const CHOICE_STATE_RE = /^state\s+(\S+)\s+<<choice>>$/i
|
||||
const TRANSITION_RE = /^(\[\*\]|[^\s:]+)\s*-->\s*(\[\*\]|[^\s:]+)(?:\s*:\s*(.*))?$/
|
||||
const DIRECTION_RE = /^direction\s+(TB|TD|LR|RL)$/i
|
||||
const DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
|
||||
const NOTE_INLINE_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*:\s*(.*)$/i
|
||||
const NOTE_START_RE = /^note\s+(left|right)\s+of\s+(\S+)\s*$/i
|
||||
const NOTE_END_RE = /^end\s+note$/i
|
||||
|
||||
function normalizeDirection(value?: string): StateDiagramDirection {
|
||||
const upper = value?.toUpperCase()
|
||||
if (upper === "TB" || upper === "TD" || upper === "LR" || upper === "RL") return upper
|
||||
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
|
||||
return DEFAULT_DIRECTION
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import type { StateCellStyle } from "./types.js"
|
||||
export type StateGrid = DiagramCanvas<StateCellStyle>
|
||||
|
||||
export function renderStateGridText(grid: StateGrid): string {
|
||||
return grid.toString({ trimBottom: true })
|
||||
return grid.toString({ trimTop: true, trimBottom: true })
|
||||
}
|
||||
|
||||
export function renderStateGridStyledText(grid: StateGrid, colors: StateStyleColors): StyledText {
|
||||
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
|
||||
trimTop: true,
|
||||
trimBottom: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { diagramTextWidth } from "../core/text.js"
|
||||
import type { StateDiagramBoxBounds } from "./layout.js"
|
||||
import { createStateDiagramLayout } from "./layout.js"
|
||||
import { parseMermaidStateDiagram } from "./parser.js"
|
||||
@@ -286,10 +287,7 @@ describe("createStateTransitionRenderPlans", () => {
|
||||
expect(
|
||||
plan.path.some(
|
||||
([x, y]) =>
|
||||
x >= sibling.left &&
|
||||
x < sibling.left + sibling.width &&
|
||||
y >= sibling.top &&
|
||||
y < sibling.top + sibling.height,
|
||||
x >= sibling.left && x < sibling.left + sibling.width && y >= sibling.top && y < sibling.top + sibling.height,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -348,6 +346,158 @@ describe("createStateTransitionRenderPlans", () => {
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test.each(["LR", "RL", "TB", "TD"] as const)(
|
||||
"keeps endpoint-disjoint %s transitions on separate cells when a detour exists",
|
||||
(direction) => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction ${direction}
|
||||
state "A" as A
|
||||
state "B" as B
|
||||
state "C" as C
|
||||
state "D" as D
|
||||
B --> D: e0
|
||||
C --> A: e1`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
|
||||
const first = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
|
||||
|
||||
expect(plans[1]!.path.every(([x, y]) => !first.has(`${x}:${y}`))).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
test("anchors labels to final repaired transition geometry", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
state "A" as A
|
||||
state "B" as B
|
||||
state "C" as C
|
||||
A --> B: e0
|
||||
A --> C: e1
|
||||
B --> A: e2`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plan = createStateTransitionRenderPlans(diagram, layout.bounds, 30).find(
|
||||
(candidate) => candidate.route.transition.label === "e2",
|
||||
)!
|
||||
const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
|
||||
const distance = Math.min(
|
||||
...plan.path.map(([pathX, pathY]) => {
|
||||
const dx =
|
||||
pathX < plan.label!.x
|
||||
? plan.label!.x - pathX
|
||||
: pathX >= plan.label!.x + width
|
||||
? pathX - (plan.label!.x + width - 1)
|
||||
: 0
|
||||
const dy =
|
||||
pathY < plan.label!.y
|
||||
? plan.label!.y - pathY
|
||||
: pathY >= plan.label!.y + plan.label!.lines.length
|
||||
? pathY - (plan.label!.y + plan.label!.lines.length - 1)
|
||||
: 0
|
||||
return dx + dy
|
||||
}),
|
||||
)
|
||||
|
||||
expect(plan.pathRepaired).toBe(true)
|
||||
expect(distance).toBeLessThanOrEqual(4)
|
||||
})
|
||||
|
||||
test.each(["LR", "RL", "TB", "TD"] as const)(
|
||||
"allocates distinct connected self-transition lanes in %s diagrams",
|
||||
(direction) => {
|
||||
for (const count of [2, 3, 4]) {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction ${direction}
|
||||
${Array.from({ length: count }, (_, index) => ` A --> A: loop-${index}`).join("\n")}`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
|
||||
|
||||
expect(new Set(plans.map((plan) => plan.path.map(([x, y]) => `${x}:${y}`).join("|"))).size).toBe(count)
|
||||
for (const plan of plans) {
|
||||
expect(
|
||||
plan.path.slice(1).every(([x, y], index) => {
|
||||
const previous = plan.path[index]!
|
||||
return Math.abs(x - previous[0]) + Math.abs(y - previous[1]) === 1
|
||||
}),
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("uses fixed-width side lanes for long parallel vertical labels", () => {
|
||||
const diagram = prepareVisibleStateDiagram(
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: alpha route label that is deliberately long
|
||||
A --> B: beta route label that is deliberately long
|
||||
A --> B: gamma route label that is deliberately long`),
|
||||
)
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const rails = createStateTransitionRoutePlans(diagram, layout.bounds, 30)
|
||||
.filter((plan) => plan.kind === "side-parallel")
|
||||
.map((plan) => plan.railX)
|
||||
|
||||
expect(rails).toHaveLength(2)
|
||||
expect(rails[1]! - rails[0]!).toBe(3)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[
|
||||
"parallel",
|
||||
`stateDiagram-v2
|
||||
direction TB
|
||||
A --> B: alpha route label that is deliberately long
|
||||
A --> B: beta route label that is deliberately long
|
||||
A --> B: gamma route label that is deliberately long`,
|
||||
3,
|
||||
],
|
||||
[
|
||||
"self",
|
||||
`stateDiagram-v2
|
||||
direction LR
|
||||
A --> A: one
|
||||
A --> A: two
|
||||
A --> A: three`,
|
||||
3,
|
||||
],
|
||||
] as const)("keeps %s labels one column clear of frames and route rails", (_, source, count) => {
|
||||
const diagram = prepareVisibleStateDiagram(parseMermaidStateDiagram(source))
|
||||
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
|
||||
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
|
||||
const routeCells = plans.flatMap((plan) => plan.path)
|
||||
|
||||
expect(plans).toHaveLength(count)
|
||||
for (const plan of plans) {
|
||||
const width = Math.max(...plan.label!.lines.map(diagramTextWidth))
|
||||
for (const [row, line] of plan.label!.lines.entries()) {
|
||||
const lineWidth = diagramTextWidth(line)
|
||||
const y = plan.label!.y + row
|
||||
expect(
|
||||
routeCells
|
||||
.filter(([, routeY]) => routeY === y)
|
||||
.every(([routeX]) => routeX < plan.label!.x - 1 || routeX > plan.label!.x + lineWidth),
|
||||
).toBe(true)
|
||||
for (const bound of layout.bounds.values()) {
|
||||
if (y < bound.top || y >= bound.top + bound.height) continue
|
||||
expect(bound.left + bound.width <= plan.label!.x - 1 || bound.left >= plan.label!.x + width + 1).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source.includes("A --> A")) {
|
||||
const arrowXs = plans
|
||||
.flatMap((plan) => plan.cells.filter((cell) => cell.arrowDirection).map((cell) => cell.x))
|
||||
.sort((left, right) => left - right)
|
||||
expect(arrowXs.slice(1).every((x, index) => x - arrowXs[index]! >= 4)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStateTransitionJunctionPlans", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BorderChars } from "@opentui/core"
|
||||
import { diagramLineGlyph } from "../core/drawing.js"
|
||||
import type { DiagramDirection } from "../core/geometry.js"
|
||||
import { orthogonalPathPoints, type DiagramDirection } from "../core/geometry.js"
|
||||
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
|
||||
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
|
||||
import type { StateDiagramBoxBounds as BoxBounds, StateDiagramNoteBounds } from "./layout.js"
|
||||
@@ -24,7 +24,7 @@ interface StateTransitionRoutePlanBase {
|
||||
}
|
||||
|
||||
export type StateTransitionRoutePlan =
|
||||
| (StateTransitionRoutePlanBase & { kind: "self" })
|
||||
| (StateTransitionRoutePlanBase & { kind: "self"; lane: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
|
||||
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
|
||||
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
|
||||
@@ -58,6 +58,7 @@ export interface StateTransitionRenderPlan {
|
||||
cells: readonly StateTransitionRenderCell[]
|
||||
path: readonly StateTransitionPathPoint[]
|
||||
label?: StateTransitionRenderLabel
|
||||
pathRepaired?: boolean
|
||||
}
|
||||
|
||||
export interface StateTransitionRenderOptions {
|
||||
@@ -350,6 +351,26 @@ function sideParallelTargetApproach(
|
||||
})
|
||||
}
|
||||
|
||||
function containingCompositeIds(diagram: StateVisibleDiagram, id: string): string[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
const ids: string[] = []
|
||||
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
||||
while (parentId) {
|
||||
ids.push(parentId)
|
||||
parentId = compositesById.get(parentId)?.parentId
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function innermostCommonComposite(
|
||||
diagram: StateVisibleDiagram,
|
||||
transition: StateVisibleTransition,
|
||||
): string | undefined {
|
||||
const target = new Set(containingCompositeIds(diagram, transition.to))
|
||||
return containingCompositeIds(diagram, transition.from).find((id) => target.has(id))
|
||||
}
|
||||
|
||||
export function createStateTransitionRoutePlans(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
@@ -358,11 +379,13 @@ export function createStateTransitionRoutePlans(
|
||||
): StateTransitionRoutePlan[] {
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const endpointOccurrences = new Map<string, number>()
|
||||
const selfOccurrences = new Map<string, number>()
|
||||
const parallelLaneGap = Math.max(
|
||||
3,
|
||||
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
|
||||
)
|
||||
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
|
||||
const sideRailLanes = new Map<string, number>()
|
||||
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
|
||||
let nextBottomRailY =
|
||||
Math.max(
|
||||
@@ -371,9 +394,24 @@ export function createStateTransitionRoutePlans(
|
||||
.filter((allocation) => allocation.side === "bottom")
|
||||
.map((allocation) => allocation.railY),
|
||||
) + parallelLaneGap
|
||||
const allocateSideRail = (label: string): number => {
|
||||
const allocateSideRail = (transition: StateVisibleTransition): number => {
|
||||
const compositeId = innermostCommonComposite(diagram, transition)
|
||||
const composite = compositeId ? bounds.get(compositeId) : undefined
|
||||
if (compositeId && composite) {
|
||||
const lane = sideRailLanes.get(compositeId) ?? 0
|
||||
sideRailLanes.set(compositeId, lane + 1)
|
||||
const descendantRight = Math.max(
|
||||
composite.left + 1,
|
||||
...diagram.states.flatMap((state) => {
|
||||
if (!containingCompositeIds(diagram, state.id).includes(compositeId)) return []
|
||||
const bound = bounds.get(state.id)
|
||||
return bound ? [bound.left + bound.width] : []
|
||||
}),
|
||||
)
|
||||
return Math.min(descendantRight + 2 + lane * 3, composite.left + composite.width - 2)
|
||||
}
|
||||
const railX = nextSideRailX
|
||||
nextSideRailX += Math.max(3, measureStateTransitionLabel(label).width + 2)
|
||||
nextSideRailX += 3
|
||||
return railX
|
||||
}
|
||||
const allocateBottomRail = (): number => {
|
||||
@@ -392,7 +430,7 @@ export function createStateTransitionRoutePlans(
|
||||
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
|
||||
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
|
||||
const sideParallel = (): StateTransitionRoutePlan => {
|
||||
const railX = allocateSideRail(transition.label)
|
||||
const railX = allocateSideRail(transition)
|
||||
return {
|
||||
...base,
|
||||
kind: "side-parallel",
|
||||
@@ -400,7 +438,11 @@ export function createStateTransitionRoutePlans(
|
||||
targetApproach: sideParallelTargetApproach(diagram, transition, from, to, bounds, railX),
|
||||
}
|
||||
}
|
||||
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
|
||||
if (transition.from === transition.to) {
|
||||
const lane = selfOccurrences.get(transition.from) ?? 0
|
||||
selfOccurrences.set(transition.from, lane + 1)
|
||||
return [{ ...base, kind: "self", lane }]
|
||||
}
|
||||
const endpointKey = `${transition.from}\u0000${transition.to}`
|
||||
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
|
||||
endpointOccurrences.set(endpointKey, parallelIndex + 1)
|
||||
@@ -449,7 +491,8 @@ export function createStateTransitionRoutePlans(
|
||||
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerY > to.centerY) {
|
||||
const verticalFeedback = diagram.direction === "BT" ? from.centerY < to.centerY : from.centerY > to.centerY
|
||||
if (verticalFeedback) {
|
||||
return [sideParallel()]
|
||||
}
|
||||
if (from.centerY === to.centerY) {
|
||||
@@ -612,33 +655,36 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
|
||||
}
|
||||
|
||||
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from: bounds, transition } = builder.route
|
||||
const { from: bounds, transition, lane } = builder.route as Extract<StateTransitionRoutePlan, { kind: "self" }>
|
||||
if (bounds.width <= 1 || bounds.height <= 1) {
|
||||
const railX = bounds.left + 4
|
||||
const railY = bounds.top + 2
|
||||
const railX = bounds.left + 4 + lane * 4
|
||||
const railY = bounds.top + 2 + lane * 2
|
||||
addHorizontalLine(builder, bounds.left + 1, railX - 1, bounds.top, 1)
|
||||
addCell(builder, { x: railX, y: bounds.top, char: "╮" })
|
||||
addCell(builder, { x: railX, y: bounds.top + 1, char: "│" })
|
||||
addVerticalLine(builder, railX, bounds.top + 1, railY - 1, 1)
|
||||
addCell(builder, { x: railX, y: railY, char: "╯" })
|
||||
addHorizontalLine(builder, railX - 1, bounds.left + 1, railY, -1)
|
||||
addCell(builder, { x: bounds.left, y: railY, char: "╰" })
|
||||
for (let y = railY - 1; y > bounds.top + 1; y--) addCell(builder, { x: bounds.left, y, char: "│" })
|
||||
addCell(builder, { x: bounds.left, y: bounds.top + 1, arrowDirection: "up" })
|
||||
addPathPoint(builder, bounds.left, bounds.top)
|
||||
if (transition.label) addLabel(builder, railX + 2, bounds.top + 1, transition.label)
|
||||
if (transition.label) addLabel(builder, railX + 2, lane === 0 ? bounds.top + 1 : railY - 1, transition.label)
|
||||
return
|
||||
}
|
||||
const sourceX = bounds.left + Math.max(2, Math.floor(bounds.width / 3))
|
||||
const bottomY = bounds.top + bounds.height - 1
|
||||
const railY = bottomY + 2
|
||||
const targetX = Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3)))
|
||||
const railY = bottomY + 2 + lane * 3
|
||||
const targetX =
|
||||
Math.max(sourceX + 3, bounds.left + Math.min(bounds.width - 3, Math.ceil((bounds.width * 2) / 3))) + lane * 4
|
||||
|
||||
addBottomDeparture(builder, bounds, sourceX)
|
||||
addCell(builder, { x: sourceX, y: bottomY + 1, char: "│" })
|
||||
addVerticalLine(builder, sourceX, bottomY + 1, railY - 1, 1)
|
||||
addCell(builder, { x: sourceX, y: railY, char: "╰" })
|
||||
for (let x = sourceX + 1; x < targetX; x++) addCell(builder, { x, y: railY, char: "─" })
|
||||
addCell(builder, { x: targetX, y: railY, char: "╯" })
|
||||
for (let y = railY - 1; y > bottomY + 1; y--) addCell(builder, { x: targetX, y, char: "│" })
|
||||
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
|
||||
if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
|
||||
if (transition.label) addLabel(builder, targetX + 2, lane === 0 ? bottomY + 1 : railY - 1, transition.label)
|
||||
}
|
||||
|
||||
function outsideBottomY(bounds: BoxBounds): number {
|
||||
@@ -732,10 +778,8 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
|
||||
}
|
||||
|
||||
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
|
||||
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } = builder.route as Extract<
|
||||
StateTransitionRoutePlan,
|
||||
{ kind: "side-parallel" }
|
||||
>
|
||||
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX, targetApproach } =
|
||||
builder.route as Extract<StateTransitionRoutePlan, { kind: "side-parallel" }>
|
||||
const startX = from.left + from.width
|
||||
const startY = from.centerY
|
||||
const endY = targetApproach === "top" ? to.top - 2 : targetApproach === "bottom" ? to.top + to.height + 1 : to.centerY
|
||||
@@ -757,7 +801,12 @@ function addSideParallelTransition(builder: StateTransitionRenderBuilder): void
|
||||
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
|
||||
if (transition.label) {
|
||||
const metrics = measureStateTransitionLabel(transition.label)
|
||||
addLabel(builder, railX + 2, Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)), transition.label)
|
||||
addLabel(
|
||||
builder,
|
||||
railX + 2,
|
||||
Math.max(0, Math.floor((startY + to.centerY - metrics.height + 1) / 2)),
|
||||
transition.label,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -938,31 +987,62 @@ function routeIntersectsUnrelatedState(
|
||||
"boundary",
|
||||
stateDiagramNoteConnector(noteBound, target).points,
|
||||
)
|
||||
return plan.path.some(([x, y]) =>
|
||||
connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX),
|
||||
)
|
||||
return plan.path.some(([x, y]) => connector.spans.some((span) => span.y === y && x >= span.fromX && x <= span.toX))
|
||||
})
|
||||
}
|
||||
|
||||
function findBodySafePath(
|
||||
start: StateTransitionPathPoint,
|
||||
end: StateTransitionPathPoint,
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
plan: StateTransitionRenderPlan,
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
): StateTransitionPathPoint[] | undefined {
|
||||
const margin = Math.max(8, bounds.size * 2)
|
||||
const compositeId = innermostCommonComposite(diagram, plan.route.transition)
|
||||
const composite = compositeId ? bounds.get(compositeId) : undefined
|
||||
const searchBounds = {
|
||||
minX: composite ? composite.left + 1 : search.minX - margin,
|
||||
minY: composite ? composite.top + 1 : Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
|
||||
maxX: composite
|
||||
? composite.left + composite.width - 2
|
||||
: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
|
||||
maxY: composite
|
||||
? composite.top + composite.height - 2
|
||||
: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
|
||||
}
|
||||
const isFree = ([x, y]: StateTransitionPathPoint) =>
|
||||
x >= searchBounds.minX &&
|
||||
x <= searchBounds.maxX &&
|
||||
y >= searchBounds.minY &&
|
||||
y <= searchBounds.maxY &&
|
||||
!search.blocked.has(`${x}:${y}`)
|
||||
const pathMinX = Math.min(...plan.path.map((point) => point[0]))
|
||||
const pathMinY = Math.min(...plan.path.map((point) => point[1]))
|
||||
const pathMaxX = Math.max(...plan.path.map((point) => point[0]))
|
||||
const pathMaxY = Math.max(...plan.path.map((point) => point[1]))
|
||||
const directCandidates: StateTransitionPathPoint[][] = [
|
||||
[start, [start[0], end[1]] as const, end],
|
||||
[start, [end[0], start[1]] as const, end],
|
||||
...Array.from({ length: 4 }, (_, index) => index + 1).flatMap((offset): StateTransitionPathPoint[][] => [
|
||||
[start, [start[0], pathMinY - offset], [end[0], pathMinY - offset], end],
|
||||
[start, [start[0], pathMaxY + offset], [end[0], pathMaxY + offset], end],
|
||||
[start, [pathMinX - offset, start[1]], [pathMinX - offset, end[1]], end],
|
||||
[start, [pathMaxX + offset, start[1]], [pathMaxX + offset, end[1]], end],
|
||||
]),
|
||||
]
|
||||
const direct = directCandidates
|
||||
.map((points) => orthogonalPathPoints(points.map(([x, y]) => ({ x, y }))).map(({ x, y }) => [x, y] as const))
|
||||
.filter((points) => points.every(isFree))
|
||||
.sort((left, right) => left.length - right.length)[0]
|
||||
if (direct) return direct
|
||||
const path = findStateManhattanPath(
|
||||
[{ x: start[0], y: start[1] }],
|
||||
{ x: end[0], y: end[1] },
|
||||
search,
|
||||
{
|
||||
minX: search.minX - margin,
|
||||
minY: Math.min(search.minY, ...plan.path.map((point) => point[1])) - margin,
|
||||
maxX: Math.max(search.maxX, ...plan.path.map((point) => point[0])) + margin,
|
||||
maxY: Math.max(search.maxY, ...plan.path.map((point) => point[1])) + margin,
|
||||
},
|
||||
searchBounds,
|
||||
budget,
|
||||
)
|
||||
return path?.map((point) => [point.x, point.y] as const)
|
||||
@@ -975,17 +1055,29 @@ function bodySafeTransitionPlan(
|
||||
noteBounds: readonly StateDiagramNoteBounds[],
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
forceRepair = false,
|
||||
): StateTransitionRenderPlan {
|
||||
if (!routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
|
||||
if (!forceRepair && !routeIntersectsUnrelatedState(plan, diagram, bounds, noteBounds)) return plan
|
||||
const sourceOutsideIndex = plan.path.findIndex((point) => !pointIsInsideBounds(point, plan.route.from))
|
||||
const targetOutsideIndex = plan.path.findLastIndex((point) => !pointIsInsideBounds(point, plan.route.to))
|
||||
if (sourceOutsideIndex < 0 || targetOutsideIndex < sourceOutsideIndex) return plan
|
||||
const safePath = findBodySafePath(plan.path[sourceOutsideIndex]!, plan.path[targetOutsideIndex]!, bounds, plan, search, budget)
|
||||
const safePath = findBodySafePath(
|
||||
plan.path[sourceOutsideIndex]!,
|
||||
plan.path[targetOutsideIndex]!,
|
||||
diagram,
|
||||
bounds,
|
||||
plan,
|
||||
search,
|
||||
budget,
|
||||
)
|
||||
if (!safePath) return alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
|
||||
|
||||
const prefix = plan.path.slice(0, sourceOutsideIndex)
|
||||
const suffix = plan.path.slice(targetOutsideIndex + 1)
|
||||
return renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
|
||||
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix)
|
||||
if (safePath.length <= plan.path.length + 4) return repaired
|
||||
const alternate = alternateBodySafeTransitionPlan(plan, diagram, bounds, noteBounds, search, budget)
|
||||
return alternate !== plan && alternate.path.length < repaired.path.length ? alternate : repaired
|
||||
}
|
||||
|
||||
function alternateBodySafeTransitionPlan(
|
||||
@@ -996,9 +1088,10 @@ function alternateBodySafeTransitionPlan(
|
||||
search: StateSearchSpace,
|
||||
budget: StateSearchBudget,
|
||||
): StateTransitionRenderPlan {
|
||||
const candidates: StateTransitionRenderPlan[] = []
|
||||
for (const source of stateRoutePorts(plan.route.from)) {
|
||||
for (const target of stateRoutePorts(plan.route.to)) {
|
||||
const safePath = findBodySafePath(source.outside, target.outside, bounds, plan, search, budget)
|
||||
const safePath = findBodySafePath(source.outside, target.outside, diagram, bounds, plan, search, budget)
|
||||
if (!safePath) continue
|
||||
const prefix = plan.route.from.width > 1 && plan.route.from.height > 1 ? [source.border] : []
|
||||
const suffix =
|
||||
@@ -1006,10 +1099,10 @@ function alternateBodySafeTransitionPlan(
|
||||
? ([[plan.route.to.left, plan.route.to.top]] as const)
|
||||
: []
|
||||
const repaired = renderBodySafeTransitionPlan(plan, safePath, prefix, suffix, source.char)
|
||||
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) return repaired
|
||||
if (!routeIntersectsUnrelatedState(repaired, diagram, bounds, noteBounds)) candidates.push(repaired)
|
||||
}
|
||||
}
|
||||
return plan
|
||||
return candidates.sort((left, right) => left.path.length - right.path.length)[0] ?? plan
|
||||
}
|
||||
|
||||
function stateRoutePorts(bounds: BoxBounds): Array<{
|
||||
@@ -1072,7 +1165,50 @@ function renderBodySafeTransitionPlan(
|
||||
cells.push({ x: point[0], y: point[1], char: diagramLineGlyph(connections, "rounded") })
|
||||
}
|
||||
|
||||
return { ...plan, cells, path: fullPath }
|
||||
return { ...plan, cells, path: fullPath, pathRepaired: true }
|
||||
}
|
||||
|
||||
function labelDistanceToPath(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
path: readonly StateTransitionPathPoint[],
|
||||
): number {
|
||||
return Math.min(
|
||||
...path.map(([pathX, pathY]) => {
|
||||
const dx = pathX < x ? x - pathX : pathX >= x + width ? pathX - (x + width - 1) : 0
|
||||
const dy = pathY < y ? y - pathY : pathY >= y + height ? pathY - (y + height - 1) : 0
|
||||
return dx + dy
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function stateTransitionLabelCandidates(
|
||||
plan: StateTransitionRenderPlan,
|
||||
width: number,
|
||||
height: number,
|
||||
): Array<{ x: number; y: number }> {
|
||||
const candidates = new Map<string, { x: number; y: number }>()
|
||||
const add = (x: number, y: number) => candidates.set(`${x}:${y}`, { x, y })
|
||||
if (
|
||||
plan.label &&
|
||||
(!plan.pathRepaired || labelDistanceToPath(plan.label.x, plan.label.y, width, height, plan.path) <= 4)
|
||||
) {
|
||||
add(plan.label.x, plan.label.y)
|
||||
}
|
||||
for (const [x, y] of plan.path) {
|
||||
add(x + 2, y - Math.floor(height / 2))
|
||||
add(x - width - 2, y - Math.floor(height / 2))
|
||||
add(x - Math.floor(width / 2), y - height - 1)
|
||||
add(x - Math.floor(width / 2), y + 2)
|
||||
}
|
||||
const preferred = plan.label ?? { x: plan.path[0]?.[0] ?? 0, y: plan.path[0]?.[1] ?? 0 }
|
||||
return [...candidates.values()].sort((left, right) => {
|
||||
const leftDistance = Math.abs(left.x - preferred.x) + Math.abs(left.y - preferred.y)
|
||||
const rightDistance = Math.abs(right.x - preferred.x) + Math.abs(right.y - preferred.y)
|
||||
return leftDistance - rightDistance
|
||||
})
|
||||
}
|
||||
|
||||
function placeStateTransitionLabels(
|
||||
@@ -1096,6 +1232,19 @@ function placeStateTransitionLabels(
|
||||
plan.path.map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
),
|
||||
...diagram.composites.flatMap((composite) => {
|
||||
const bound = bounds.get(composite.id)
|
||||
if (!bound) return []
|
||||
return [
|
||||
spatialPathClaim(`composite:${composite.id}`, `composite:${composite.id}`, "boundary", [
|
||||
{ x: bound.left, y: bound.top },
|
||||
{ x: bound.left + bound.width - 1, y: bound.top },
|
||||
{ x: bound.left + bound.width - 1, y: bound.top + bound.height - 1 },
|
||||
{ x: bound.left, y: bound.top + bound.height - 1 },
|
||||
{ x: bound.left, y: bound.top },
|
||||
]),
|
||||
]
|
||||
}),
|
||||
...noteBounds.flatMap((noteBound) => {
|
||||
const target = bounds.get(noteBound.note.target)
|
||||
return [
|
||||
@@ -1114,10 +1263,25 @@ function placeStateTransitionLabels(
|
||||
}),
|
||||
)
|
||||
|
||||
return plans.map((plan, planIndex) => {
|
||||
if (!plan.label) return plan
|
||||
const placed = new Map<number, StateTransitionRenderPlan>()
|
||||
const endpointCounts = new Map<string, number>()
|
||||
for (const plan of plans) {
|
||||
const key = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
|
||||
endpointCounts.set(key, (endpointCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
const placementOrder = [...plans.keys()].sort(
|
||||
(left, right) => Number(Boolean(plans[left]!.pathRepaired)) - Number(Boolean(plans[right]!.pathRepaired)),
|
||||
)
|
||||
for (const planIndex of placementOrder) {
|
||||
const plan = plans[planIndex]!
|
||||
if (!plan.label) {
|
||||
placed.set(planIndex, plan)
|
||||
continue
|
||||
}
|
||||
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
|
||||
const statePadding = plan.label.lines.length === 1 ? 0 : 1
|
||||
const endpointKey = `${plan.route.transition.from}\u0000${plan.route.transition.to}`
|
||||
const needsLaneClearance = (endpointCounts.get(endpointKey) ?? 0) > 1
|
||||
const statePadding = needsLaneClearance || plan.label.lines.length > 1 ? 1 : 0
|
||||
const labelClaim = (x: number, y: number) =>
|
||||
spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
|
||||
left: x,
|
||||
@@ -1131,19 +1295,35 @@ function placeStateTransitionLabels(
|
||||
clearance: {
|
||||
body: statePadding,
|
||||
label: { x: 1, y: 0 },
|
||||
route:
|
||||
plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance
|
||||
? {
|
||||
x: 1,
|
||||
y: 0,
|
||||
}
|
||||
: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let x = plan.label.x
|
||||
let y = plan.label.y
|
||||
if (!isClear(x, y)) {
|
||||
const candidates = stateTransitionLabelCandidates(plan, width, plan.label.lines.length)
|
||||
const nearby = candidates.find(
|
||||
(candidate) =>
|
||||
(!(plan.pathRepaired || plan.route.kind === "side-parallel" || needsLaneClearance) ||
|
||||
labelDistanceToPath(candidate.x, candidate.y, width, plan.label!.lines.length, plan.path) >= 2) &&
|
||||
isClear(candidate.x, candidate.y),
|
||||
)
|
||||
let x = nearby?.x ?? candidates[0]?.x ?? plan.label.x
|
||||
let y = nearby?.y ?? candidates[0]?.y ?? plan.label.y
|
||||
if (!nearby) {
|
||||
search: for (let distance = 1; distance < 500; distance++) {
|
||||
for (let dx = -distance; dx <= distance; dx++) {
|
||||
const dy = distance - Math.abs(dx)
|
||||
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
|
||||
const candidateX = x + dx
|
||||
if (!isClear(candidateX, candidateY)) continue
|
||||
const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
|
||||
if (pathDistance < 2 || pathDistance > 8) continue
|
||||
x = candidateX
|
||||
y = candidateY
|
||||
break search
|
||||
@@ -1151,10 +1331,26 @@ function placeStateTransitionLabels(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isClear(x, y)) {
|
||||
fallback: for (let distance = 1; distance < 500; distance++) {
|
||||
for (let dx = -distance; dx <= distance; dx++) {
|
||||
const dy = distance - Math.abs(dx)
|
||||
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
|
||||
const candidateX = x + dx
|
||||
if (!isClear(candidateX, candidateY)) continue
|
||||
x = candidateX
|
||||
y = candidateY
|
||||
break fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isClear(x, y)) throw new Error(`Transition ${endpointKey} has no clear label position`)
|
||||
|
||||
space = space.add(labelClaim(x, y))
|
||||
return { ...plan, label: { ...plan.label, x, y } }
|
||||
})
|
||||
placed.set(planIndex, { ...plan, label: { ...plan.label, x, y } })
|
||||
}
|
||||
return plans.map((plan, index) => placed.get(index) ?? plan)
|
||||
}
|
||||
|
||||
export function createStateTransitionRenderPlans(
|
||||
@@ -1169,15 +1365,59 @@ export function createStateTransitionRenderPlans(
|
||||
createStateTransitionRenderPlan,
|
||||
)
|
||||
if (options.repairRoutes === false) return placeStateTransitionLabels(plans, diagram, bounds, noteBounds)
|
||||
const routeSpace = createStateSearchSpace(transitionObstacles(diagram, bounds, noteBounds))
|
||||
return placeStateTransitionLabels(
|
||||
plans.map((plan) => bodySafeTransitionPlan(plan, diagram, bounds, noteBounds, routeSpace, budget)),
|
||||
diagram,
|
||||
bounds,
|
||||
noteBounds,
|
||||
const baseObstacles = transitionObstacles(diagram, bounds, noteBounds)
|
||||
const repaired: StateTransitionRenderPlan[] = []
|
||||
for (const [index, plan] of plans.entries()) {
|
||||
const disjoint = repaired.filter((previous) => transitionsHaveDisjointEndpoints(previous, plan))
|
||||
const routeObstacles = repaired.filter(
|
||||
(previous) => disjoint.includes(previous) || transitionsAreReciprocal(previous, plan),
|
||||
)
|
||||
const routeSpace = createStateSearchSpace(
|
||||
baseObstacles.add(
|
||||
...routeObstacles.map((previous, previousIndex) =>
|
||||
spatialPathClaim(
|
||||
`transition:${index}:obstacle:${previousIndex}`,
|
||||
`transition:${index}:obstacle:${previousIndex}`,
|
||||
"route",
|
||||
previous.path.map(([x, y]) => ({ x, y })),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
repaired.push(
|
||||
bodySafeTransitionPlan(
|
||||
plan,
|
||||
diagram,
|
||||
bounds,
|
||||
noteBounds,
|
||||
routeSpace,
|
||||
budget,
|
||||
disjoint.some((previous) => pathsIntersect(previous.path, plan.path)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return placeStateTransitionLabels(repaired, diagram, bounds, noteBounds)
|
||||
}
|
||||
|
||||
function transitionsHaveDisjointEndpoints(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
|
||||
const leftEndpoints = new Set([left.route.transition.from, left.route.transition.to])
|
||||
return !leftEndpoints.has(right.route.transition.from) && !leftEndpoints.has(right.route.transition.to)
|
||||
}
|
||||
|
||||
function transitionsAreReciprocal(left: StateTransitionRenderPlan, right: StateTransitionRenderPlan): boolean {
|
||||
return (
|
||||
left.route.transition.from === right.route.transition.to && left.route.transition.to === right.route.transition.from
|
||||
)
|
||||
}
|
||||
|
||||
function pathsIntersect(
|
||||
left: readonly StateTransitionPathPoint[],
|
||||
right: readonly StateTransitionPathPoint[],
|
||||
): boolean {
|
||||
const occupied = new Set(left.map(([x, y]) => `${x}:${y}`))
|
||||
return right.some(([x, y]) => occupied.has(`${x}:${y}`))
|
||||
}
|
||||
|
||||
function transitionObstacles(
|
||||
diagram: StateVisibleDiagram,
|
||||
bounds: ReadonlyMap<string, BoxBounds>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BorderStyle } from "@opentui/core"
|
||||
|
||||
export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
|
||||
export type StateDiagramDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
||||
export type StateDiagramArrowHeadStyle = "filled" | "line"
|
||||
|
||||
export interface StateDiagramState {
|
||||
@@ -41,6 +41,8 @@ export interface StateDiagramRenderOptions {
|
||||
borderStyle?: BorderStyle
|
||||
arrowHeadStyle?: StateDiagramArrowHeadStyle
|
||||
minStateGap?: number
|
||||
/** Target rendered width. Oversized horizontal layouts fold vertically. */
|
||||
layoutMaxWidth?: number
|
||||
}
|
||||
|
||||
export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import type { FlowchartDirection } from "../../flowchart/types.js"
|
||||
import type { StateDiagramDirection } from "../../state/types.js"
|
||||
|
||||
export type LayoutFixture = {
|
||||
id: string
|
||||
kind: "flowchart" | "state"
|
||||
family: string
|
||||
profile: LabelProfile
|
||||
source: string
|
||||
curated?: boolean
|
||||
}
|
||||
|
||||
type LabelProfile = "short" | "long" | "unicode"
|
||||
|
||||
const flowVariants = [
|
||||
["TB", "short"],
|
||||
["TD", "long"],
|
||||
["BT", "unicode"],
|
||||
["LR", "short"],
|
||||
["RL", "long"],
|
||||
["TB", "unicode"],
|
||||
["TD", "short"],
|
||||
["BT", "long"],
|
||||
["LR", "unicode"],
|
||||
["RL", "short"],
|
||||
["LR", "long"],
|
||||
["TD", "unicode"],
|
||||
["TB", "long"],
|
||||
["BT", "short"],
|
||||
["RL", "unicode"],
|
||||
] as const satisfies readonly (readonly [FlowchartDirection, LabelProfile])[]
|
||||
|
||||
const stateVariants = [
|
||||
["TB", "short"],
|
||||
["TD", "long"],
|
||||
["LR", "unicode"],
|
||||
["RL", "short"],
|
||||
["TB", "long"],
|
||||
["TD", "unicode"],
|
||||
["LR", "short"],
|
||||
["RL", "long"],
|
||||
["LR", "long"],
|
||||
["TD", "short"],
|
||||
["TB", "unicode"],
|
||||
["RL", "unicode"],
|
||||
["BT", "short"],
|
||||
["BT", "long"],
|
||||
["BT", "unicode"],
|
||||
] as const satisfies readonly (readonly [StateDiagramDirection, LabelProfile])[]
|
||||
|
||||
function nodeLabel(id: string, profile: LabelProfile): string {
|
||||
if (profile === "long") return `${id} deliberate deployment stage with a long descriptive label`
|
||||
if (profile === "unicode") return `${id} 東京<br/>résumé 🚀`
|
||||
return `${id} node`
|
||||
}
|
||||
|
||||
function edgeLabel(id: string, profile: LabelProfile): string {
|
||||
if (profile === "long") return `${id} transition carrying detailed deployment context`
|
||||
if (profile === "unicode") return `${id} 東京<br/>✓ prêt`
|
||||
return `${id} edge`
|
||||
}
|
||||
|
||||
function flowNode(id: string, profile: LabelProfile): string {
|
||||
return ` ${id}["${nodeLabel(id, profile)}"]`
|
||||
}
|
||||
|
||||
function flowEdge(from: string, to: string, id: string, profile: LabelProfile): string {
|
||||
return ` ${from} -->|"${edgeLabel(id, profile)}"| ${to}`
|
||||
}
|
||||
|
||||
function flowSource(
|
||||
direction: FlowchartDirection,
|
||||
profile: LabelProfile,
|
||||
nodes: readonly string[],
|
||||
edges: readonly [from: string, to: string, id: string][],
|
||||
extra: readonly string[] = [],
|
||||
): string {
|
||||
return [
|
||||
`flowchart ${direction}`,
|
||||
...nodes.map((id) => flowNode(id, profile)),
|
||||
...extra,
|
||||
...edges.map(([from, to, id]) => flowEdge(from, to, id, profile)),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const flowFamilies = {
|
||||
chain(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E", "F"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["B", "C", "E02"],
|
||||
["C", "D", "E03"],
|
||||
["D", "E", "E04"],
|
||||
["E", "F", "E05"],
|
||||
],
|
||||
)
|
||||
},
|
||||
fork(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["A", "C", "E02"],
|
||||
["A", "D", "E03"],
|
||||
["A", "E", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
join(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E"],
|
||||
[
|
||||
["A", "E", "E01"],
|
||||
["B", "E", "E02"],
|
||||
["C", "E", "E03"],
|
||||
["D", "E", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
cycle(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["B", "C", "E02"],
|
||||
["C", "D", "E03"],
|
||||
["D", "A", "E04"],
|
||||
["C", "A", "E05"],
|
||||
],
|
||||
)
|
||||
},
|
||||
crossing(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E", "F"],
|
||||
[
|
||||
["A", "C", "E01"],
|
||||
["A", "D", "E02"],
|
||||
["B", "C", "E03"],
|
||||
["B", "D", "E04"],
|
||||
["C", "E", "E05"],
|
||||
["D", "F", "E06"],
|
||||
],
|
||||
)
|
||||
},
|
||||
parallel(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["A", "B", "E02"],
|
||||
["A", "B", "E03"],
|
||||
["B", "C", "E04"],
|
||||
["B", "C", "E05"],
|
||||
],
|
||||
)
|
||||
},
|
||||
self(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "A", "E01"],
|
||||
["A", "B", "E02"],
|
||||
["B", "B", "E03"],
|
||||
["B", "C", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
subgraph(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return [
|
||||
`flowchart ${direction}`,
|
||||
` subgraph Left["Left ${nodeLabel("SG1", profile)}"]`,
|
||||
flowNode("A", profile),
|
||||
flowNode("B", profile),
|
||||
" end",
|
||||
` subgraph Right["Right ${nodeLabel("SG2", profile)}"]`,
|
||||
flowNode("C", profile),
|
||||
flowNode("D", profile),
|
||||
" end",
|
||||
flowEdge("A", "B", "E01", profile),
|
||||
flowEdge("A", "C", "E02", profile),
|
||||
flowEdge("B", "D", "E03", profile),
|
||||
flowEdge("C", "D", "E04", profile),
|
||||
].join("\n")
|
||||
},
|
||||
"nested-subgraph"(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
const local = direction === "LR" || direction === "RL" ? "TB" : "LR"
|
||||
return [
|
||||
`flowchart ${direction}`,
|
||||
` subgraph Outer["Outer ${nodeLabel("SG1", profile)}"]`,
|
||||
` direction ${local}`,
|
||||
` subgraph Inner["Inner ${nodeLabel("SG2", profile)}"]`,
|
||||
flowNode("A", profile),
|
||||
flowNode("B", profile),
|
||||
" end",
|
||||
flowNode("C", profile),
|
||||
" end",
|
||||
flowNode("D", profile),
|
||||
flowEdge("A", "B", "E01", profile),
|
||||
flowEdge("A", "C", "E02", profile),
|
||||
flowEdge("B", "D", "E03", profile),
|
||||
flowEdge("C", "D", "E04", profile),
|
||||
].join("\n")
|
||||
},
|
||||
} satisfies Record<string, (direction: FlowchartDirection, profile: LabelProfile) => string>
|
||||
|
||||
function stateDeclaration(id: string, profile: LabelProfile, indent = " "): string {
|
||||
return `${indent}state "${nodeLabel(id, profile)}" as ${id}`
|
||||
}
|
||||
|
||||
function stateTransition(from: string, to: string, id: string, profile: LabelProfile, indent = " "): string {
|
||||
return `${indent}${from} --> ${to}: ${edgeLabel(id, profile)}`
|
||||
}
|
||||
|
||||
function stateSource(
|
||||
direction: StateDiagramDirection,
|
||||
profile: LabelProfile,
|
||||
states: readonly string[],
|
||||
transitions: readonly [from: string, to: string, id: string][],
|
||||
extra: readonly string[] = [],
|
||||
): string {
|
||||
return [
|
||||
"stateDiagram-v2",
|
||||
` direction ${direction}`,
|
||||
...states.map((id) => stateDeclaration(id, profile)),
|
||||
...extra,
|
||||
...transitions.map(([from, to, id]) => stateTransition(from, to, id, profile)),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const stateFamilies = {
|
||||
chain(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["B", "C", "E02"],
|
||||
["C", "D", "E03"],
|
||||
["D", "E", "E04"],
|
||||
],
|
||||
[" [*] --> A", " E --> [*]"],
|
||||
)
|
||||
},
|
||||
fork(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["A", "C", "E02"],
|
||||
["A", "D", "E03"],
|
||||
],
|
||||
)
|
||||
},
|
||||
join(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D"],
|
||||
[
|
||||
["A", "D", "E01"],
|
||||
["B", "D", "E02"],
|
||||
["C", "D", "E03"],
|
||||
],
|
||||
)
|
||||
},
|
||||
cycle(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["B", "C", "E02"],
|
||||
["C", "D", "E03"],
|
||||
["D", "A", "E04"],
|
||||
["C", "A", "E05"],
|
||||
],
|
||||
)
|
||||
},
|
||||
crossing(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D"],
|
||||
[
|
||||
["A", "C", "E01"],
|
||||
["A", "D", "E02"],
|
||||
["B", "C", "E03"],
|
||||
["B", "D", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
parallel(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["A", "B", "E02"],
|
||||
["A", "B", "E03"],
|
||||
["B", "C", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
self(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "A", "E01"],
|
||||
["A", "B", "E02"],
|
||||
["B", "B", "E03"],
|
||||
["B", "C", "E04"],
|
||||
],
|
||||
)
|
||||
},
|
||||
choice(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "Choice", "E01"],
|
||||
["Choice", "B", "E02"],
|
||||
["Choice", "C", "E03"],
|
||||
["C", "A", "E04"],
|
||||
],
|
||||
[" state Choice <<choice>>"],
|
||||
)
|
||||
},
|
||||
notes(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return stateSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["B", "C", "E02"],
|
||||
["C", "A", "E03"],
|
||||
],
|
||||
[
|
||||
` note left of A: ${edgeLabel("N01", profile)}`,
|
||||
` note right of B: ${edgeLabel("N02", profile)}`,
|
||||
` note right of C: ${edgeLabel("N03", profile)}`,
|
||||
],
|
||||
)
|
||||
},
|
||||
composite(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return [
|
||||
"stateDiagram-v2",
|
||||
` direction ${direction}`,
|
||||
` state "${nodeLabel("Outer", profile)}" as Outer {`,
|
||||
stateDeclaration("A", profile, " "),
|
||||
stateDeclaration("B", profile, " "),
|
||||
" [*] --> A",
|
||||
stateTransition("A", "B", "E01", profile, " "),
|
||||
" B --> [*]",
|
||||
" }",
|
||||
stateDeclaration("Done", profile),
|
||||
stateTransition("Outer", "Done", "E02", profile),
|
||||
` note right of B: ${edgeLabel("N01", profile)}`,
|
||||
].join("\n")
|
||||
},
|
||||
"nested-composite"(direction: StateDiagramDirection, profile: LabelProfile) {
|
||||
return [
|
||||
"stateDiagram-v2",
|
||||
` direction ${direction}`,
|
||||
` state "${nodeLabel("Session", profile)}" as Session {`,
|
||||
" [*] --> Open",
|
||||
` state "${nodeLabel("Open", profile)}" as Open {`,
|
||||
stateDeclaration("Clean", profile, " "),
|
||||
stateDeclaration("Dirty", profile, " "),
|
||||
" [*] --> Clean",
|
||||
stateTransition("Clean", "Dirty", "E01", profile, " "),
|
||||
stateTransition("Dirty", "Clean", "E02", profile, " "),
|
||||
" Dirty --> [*]",
|
||||
" }",
|
||||
" Open --> [*]",
|
||||
" }",
|
||||
stateDeclaration("Done", profile),
|
||||
stateTransition("Session", "Done", "E03", profile),
|
||||
` note right of Dirty: ${edgeLabel("N01", profile)}`,
|
||||
].join("\n")
|
||||
},
|
||||
} satisfies Record<string, (direction: StateDiagramDirection, profile: LabelProfile) => string>
|
||||
|
||||
export const deploymentArchitectureSource = `flowchart LR
|
||||
Client[OpenCode client]
|
||||
|
||||
subgraph CF[Cloudflare]
|
||||
DNS[opencode.ai]
|
||||
Web[Console frontend Worker]
|
||||
Proxy[Console API proxy Worker]
|
||||
Infer[inference-next Worker]
|
||||
KV[Model registry KV]
|
||||
Redis[Upstash Redis]
|
||||
Logs[Axiom / Cloudflare logs]
|
||||
Lake[Pipeline to R2 data lake]
|
||||
end
|
||||
|
||||
subgraph AWS[AWS]
|
||||
EKS[EKS cluster]
|
||||
API[Console API pod<br/>1 replica]
|
||||
OTEL[OTel collector]
|
||||
ECR[ECR]
|
||||
end
|
||||
|
||||
DB[(PlanetScale)]
|
||||
Models[Anthropic / OpenAI / other providers]
|
||||
|
||||
Client -->|/inference/*| DNS --> Infer
|
||||
Client -->|/console/*| DNS --> Web
|
||||
Web -->|/console/api, /auth, etc.| Proxy
|
||||
Proxy -->|Cloudflare VPC service| API
|
||||
|
||||
Infer -->|public DATABASE_URL| DB
|
||||
Infer --> KV
|
||||
Infer --> Redis
|
||||
Infer --> Models
|
||||
Infer --> Logs
|
||||
Infer --> Lake
|
||||
|
||||
API -->|private DATABASE_AWS_URL| DB
|
||||
API --> OTEL
|
||||
ECR --> API`
|
||||
|
||||
export function layoutFixtures(): readonly LayoutFixture[] {
|
||||
const flowcharts = Object.entries(flowFamilies).flatMap(([family, source]) =>
|
||||
flowVariants.map(([direction, profile]) => ({
|
||||
id: `flowchart/${family}/${direction.toLowerCase()}-${profile}`,
|
||||
kind: "flowchart" as const,
|
||||
family,
|
||||
profile,
|
||||
source: source(direction, profile),
|
||||
})),
|
||||
)
|
||||
const states = Object.entries(stateFamilies).flatMap(([family, source]) =>
|
||||
stateVariants.map(([direction, profile]) => ({
|
||||
id: `state/${family}/${direction.toLowerCase()}-${profile}`,
|
||||
kind: "state" as const,
|
||||
family,
|
||||
profile,
|
||||
source: source(direction, profile),
|
||||
})),
|
||||
)
|
||||
return [
|
||||
...flowcharts,
|
||||
{
|
||||
id: "flowchart/deployment-architecture/curated",
|
||||
kind: "flowchart" as const,
|
||||
family: "deployment-architecture",
|
||||
profile: "short" as const,
|
||||
source: deploymentArchitectureSource,
|
||||
curated: true,
|
||||
},
|
||||
{
|
||||
id: "flowchart/grouped-fanout/curated",
|
||||
kind: "flowchart" as const,
|
||||
family: "grouped-fanout",
|
||||
profile: "short" as const,
|
||||
source: `flowchart TD
|
||||
subgraph Group
|
||||
S[Source]
|
||||
S -->|route 0 detail| N0[Node 0]
|
||||
S -->|route 1 detail| N1[Node 1]
|
||||
S -->|route 2 detail| N2[Node 2]
|
||||
S -->|route 3 detail| N3[Node 3]
|
||||
end`,
|
||||
curated: true,
|
||||
},
|
||||
...states,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { orthogonalPathPoints, segmentBetween, type DiagramPoint } from "../../core/geometry.js"
|
||||
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../../core/spatial.js"
|
||||
import { diagramTextWidth } from "../../core/text.js"
|
||||
import { splitDiagramLines } from "../../core/text-lines.js"
|
||||
import { drawFlowchartDiagramGrid } from "../../flowchart/drawing.js"
|
||||
import { layoutFlowchartDiagram } from "../../flowchart/layout.js"
|
||||
import { flowchartRouteLabelLayout } from "../../flowchart/labels.js"
|
||||
import { parseMermaidFlowchartDiagram } from "../../flowchart/parser.js"
|
||||
import { createStateDiagramDrawing } from "../../state/drawing.js"
|
||||
import type { StateDiagramBoxBounds } from "../../state/layout.js"
|
||||
import { stateDiagramNoteConnector } from "../../state/note.js"
|
||||
import { parseMermaidStateDiagram } from "../../state/parser.js"
|
||||
import type { StateTransitionRenderPlan } from "../../state/routing.js"
|
||||
import { isHiddenCompositeMarker } from "../../state/visible-model.js"
|
||||
import { layoutFixtures, type LayoutFixture } from "./fixtures.js"
|
||||
|
||||
export const auditViewports = [60, 80, 120] as const
|
||||
|
||||
export type LayoutMetrics = {
|
||||
width: number
|
||||
height: number
|
||||
area: number
|
||||
routeLength: number
|
||||
bends: number
|
||||
crossings: number
|
||||
sharedRouteCells: number
|
||||
overflow: number
|
||||
}
|
||||
|
||||
export type LayoutAudit = {
|
||||
fixture: LayoutFixture
|
||||
viewport: (typeof auditViewports)[number]
|
||||
output: string
|
||||
metrics: LayoutMetrics
|
||||
violations: string[]
|
||||
}
|
||||
|
||||
type Bounds = Pick<StateDiagramBoxBounds, "left" | "top" | "width" | "height">
|
||||
type AuditedRoute = {
|
||||
from: string
|
||||
to: string
|
||||
points: readonly DiagramPoint[]
|
||||
}
|
||||
|
||||
function finiteBounds(bounds: Bounds): boolean {
|
||||
return (
|
||||
[bounds.left, bounds.top, bounds.width, bounds.height].every(Number.isFinite) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
function boundsOverlap(left: Bounds, right: Bounds): boolean {
|
||||
return (
|
||||
left.left < right.left + right.width &&
|
||||
left.left + left.width > right.left &&
|
||||
left.top < right.top + right.height &&
|
||||
left.top + left.height > right.top
|
||||
)
|
||||
}
|
||||
|
||||
function boundsContain(outer: Bounds, inner: Bounds): boolean {
|
||||
return (
|
||||
inner.left >= outer.left &&
|
||||
inner.top >= outer.top &&
|
||||
inner.left + inner.width <= outer.left + outer.width &&
|
||||
inner.top + inner.height <= outer.top + outer.height
|
||||
)
|
||||
}
|
||||
|
||||
function pointInBounds(point: DiagramPoint, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.left &&
|
||||
point.x < bounds.left + bounds.width &&
|
||||
point.y >= bounds.top &&
|
||||
point.y < bounds.top + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
function pointTouchesBounds(point: DiagramPoint, bounds: Bounds): boolean {
|
||||
if (pointInBounds(point, bounds)) return true
|
||||
return (
|
||||
((point.x === bounds.left - 1 || point.x === bounds.left + bounds.width) &&
|
||||
point.y >= bounds.top &&
|
||||
point.y < bounds.top + bounds.height) ||
|
||||
((point.y === bounds.top - 1 || point.y === bounds.top + bounds.height) &&
|
||||
point.x >= bounds.left &&
|
||||
point.x < bounds.left + bounds.width)
|
||||
)
|
||||
}
|
||||
|
||||
function isOrthogonal(points: readonly DiagramPoint[]): boolean {
|
||||
return points.every((point, index) => {
|
||||
if (
|
||||
!Number.isFinite(point.x) ||
|
||||
!Number.isFinite(point.y) ||
|
||||
!Number.isInteger(point.x) ||
|
||||
!Number.isInteger(point.y)
|
||||
)
|
||||
return false
|
||||
const previous = points[index - 1]
|
||||
return !previous || previous.x === point.x || previous.y === point.y
|
||||
})
|
||||
}
|
||||
|
||||
function expandedPath(points: readonly DiagramPoint[]): DiagramPoint[] {
|
||||
if (!isOrthogonal(points)) return []
|
||||
return orthogonalPathPoints(points)
|
||||
}
|
||||
|
||||
function routeLength(points: readonly DiagramPoint[]): number {
|
||||
return routeSegments(points).reduce((total, segment) => total + segment.length, 0)
|
||||
}
|
||||
|
||||
function routeBends(points: readonly DiagramPoint[]): number {
|
||||
const directions = points.slice(1).flatMap((point, index) => {
|
||||
const previous = points[index]
|
||||
if (point.x === previous.x && point.y !== previous.y) return ["y"]
|
||||
if (point.y === previous.y && point.x !== previous.x) return ["x"]
|
||||
return []
|
||||
})
|
||||
return directions.slice(1).filter((axis, index) => axis !== directions[index]).length
|
||||
}
|
||||
|
||||
function routeSegments(points: readonly DiagramPoint[]) {
|
||||
return points.slice(1).flatMap((point, index) => segmentBetween(points[index]!, point) ?? [])
|
||||
}
|
||||
|
||||
function crossingCount(routes: readonly AuditedRoute[]): number {
|
||||
let count = 0
|
||||
for (const [index, route] of routes.entries()) {
|
||||
for (const other of routes.slice(index + 1)) {
|
||||
for (const segment of routeSegments(route.points)) {
|
||||
for (const otherSegment of routeSegments(other.points)) {
|
||||
if (segment.axis === otherSegment.axis) continue
|
||||
const horizontal = segment.axis === "x" ? segment : otherSegment
|
||||
const vertical = segment.axis === "y" ? segment : otherSegment
|
||||
const x = vertical.from.x
|
||||
const y = horizontal.from.y
|
||||
const horizontalMin = Math.min(horizontal.from.x, horizontal.to.x)
|
||||
const horizontalMax = Math.max(horizontal.from.x, horizontal.to.x)
|
||||
const verticalMin = Math.min(vertical.from.y, vertical.to.y)
|
||||
const verticalMax = Math.max(vertical.from.y, vertical.to.y)
|
||||
if (x <= horizontalMin || x >= horizontalMax || y <= verticalMin || y >= verticalMax) continue
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function sharedRouteCellCount(routes: readonly AuditedRoute[]): number {
|
||||
let count = 0
|
||||
const cells = routes.map((route) => new Set(expandedPath(route.points).map((point) => `${point.x}:${point.y}`)))
|
||||
for (const [index, routeCells] of cells.entries()) {
|
||||
for (const other of cells.slice(index + 1)) {
|
||||
for (const cell of routeCells) if (other.has(cell)) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function metrics(
|
||||
width: number,
|
||||
height: number,
|
||||
routes: readonly AuditedRoute[],
|
||||
viewport: (typeof auditViewports)[number],
|
||||
): LayoutMetrics {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
area: width * height,
|
||||
routeLength: routes.reduce((total, route) => total + routeLength(route.points), 0),
|
||||
bends: routes.reduce((total, route) => total + routeBends(route.points), 0),
|
||||
crossings: crossingCount(routes),
|
||||
sharedRouteCells: sharedRouteCellCount(routes),
|
||||
overflow: Math.max(0, width - viewport),
|
||||
}
|
||||
}
|
||||
|
||||
function requireOutputLines(output: string, lines: readonly string[], owner: string, violations: string[]): void {
|
||||
for (const line of lines.map((line) => line.trim()).filter(Boolean)) {
|
||||
if (!output.includes(line)) violations.push(`${owner} content missing: ${JSON.stringify(line)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoutes(
|
||||
routes: readonly AuditedRoute[],
|
||||
bounds: ReadonlyMap<string, Bounds>,
|
||||
bodyIds: readonly string[],
|
||||
violations: string[],
|
||||
): void {
|
||||
for (const [index, route] of routes.entries()) {
|
||||
if (route.points.length < 2) {
|
||||
violations.push(`route ${index} ${route.from}->${route.to} is empty`)
|
||||
continue
|
||||
}
|
||||
if (!isOrthogonal(route.points))
|
||||
violations.push(`route ${index} ${route.from}->${route.to} is not finite and orthogonal`)
|
||||
const from = bounds.get(route.from)
|
||||
const to = bounds.get(route.to)
|
||||
if (!from || !to) {
|
||||
violations.push(`route ${index} ${route.from}->${route.to} has a missing endpoint bound`)
|
||||
continue
|
||||
}
|
||||
if (!pointTouchesBounds(route.points[0], from))
|
||||
violations.push(`route ${index} does not touch source ${route.from}`)
|
||||
if (!pointTouchesBounds(route.points.at(-1)!, to))
|
||||
violations.push(`route ${index} does not touch target ${route.to}`)
|
||||
if (!isOrthogonal(route.points)) continue
|
||||
const bodySpace = SpatialIndex.empty().add(
|
||||
...bodyIds.flatMap((id) => {
|
||||
const bound = bounds.get(id)
|
||||
return id === route.from || id === route.to || !bound
|
||||
? []
|
||||
: [spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)]
|
||||
}),
|
||||
)
|
||||
const conflicts = bodySpace.conflicts(spatialPathClaim(`route:${index}`, `route:${index}`, "route", route.points))
|
||||
for (const id of new Set(conflicts.map((conflict) => conflict.existing.owner.slice("body:".length)))) {
|
||||
violations.push(`route ${index} ${route.from}->${route.to} intersects unrelated body ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateBodies(bounds: ReadonlyMap<string, Bounds>, ids: readonly string[], violations: string[]): void {
|
||||
let occupied = SpatialIndex.empty()
|
||||
for (const id of ids) {
|
||||
const bound = bounds.get(id)
|
||||
if (!bound) {
|
||||
violations.push(`missing body bound ${id}`)
|
||||
continue
|
||||
}
|
||||
if (!finiteBounds(bound)) {
|
||||
violations.push(`body ${id} has invalid bounds`)
|
||||
continue
|
||||
}
|
||||
const claim = spatialRectClaim(`body:${id}`, `body:${id}`, "body", bound)
|
||||
for (const otherId of new Set(
|
||||
occupied.conflicts(claim).map((conflict) => conflict.existing.owner.slice("body:".length)),
|
||||
)) {
|
||||
violations.push(`bodies ${id} and ${otherId} overlap`)
|
||||
}
|
||||
occupied = occupied.add(claim)
|
||||
}
|
||||
}
|
||||
|
||||
function auditFlowchart(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
|
||||
const violations: string[] = []
|
||||
const diagram = parseMermaidFlowchartDiagram(fixture.source)
|
||||
const layout = layoutFlowchartDiagram(diagram, { compact: true, layoutMaxWidth: viewport })
|
||||
const grid = drawFlowchartDiagramGrid(diagram, { compact: true, layoutMaxWidth: viewport })
|
||||
const output = grid.toString({ trimTop: true, trimBottom: true })
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
const routes = layout.routes.map((route) => ({ from: route.edge.from, to: route.edge.to, points: route.points }))
|
||||
const bodyIds = layout.diagram.nodes.map((node) => node.id)
|
||||
|
||||
if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
|
||||
violations.push("rendered grid has invalid dimensions")
|
||||
if (!Number.isFinite(layout.width) || !Number.isFinite(layout.height) || layout.width <= 0 || layout.height <= 0)
|
||||
violations.push("layout has invalid dimensions")
|
||||
if (layout.routes.length !== layout.diagram.edges.filter((edge) => !edge.orderOnly).length)
|
||||
violations.push("rendered route count does not match visible edge count")
|
||||
validateBodies(layout.bounds, bodyIds, violations)
|
||||
validateRoutes(routes, layout.bounds, bodyIds, violations)
|
||||
|
||||
for (const node of layout.diagram.nodes)
|
||||
requireOutputLines(output, layout.bounds.get(node.id)?.lines ?? [], `node ${node.id}`, violations)
|
||||
for (const route of layout.routes) {
|
||||
if (!route.edge.label) continue
|
||||
requireOutputLines(
|
||||
output,
|
||||
flowchartRouteLabelLayout(route, diagramTextWidth).lines,
|
||||
`edge ${route.edge.from}->${route.edge.to}`,
|
||||
violations,
|
||||
)
|
||||
const targets = new Set(
|
||||
layout.diagram.edges.filter((edge) => edge.label && edge.from === route.edge.from).map((edge) => edge.to),
|
||||
)
|
||||
const sources = new Set(
|
||||
layout.diagram.edges.filter((edge) => edge.label && edge.to === route.edge.to).map((edge) => edge.from),
|
||||
)
|
||||
const label = flowchartRouteLabelLayout(route, diagramTextWidth)
|
||||
if (
|
||||
fixture.family === "grouped-fanout" &&
|
||||
(targets.size > 1 || sources.size > 1) &&
|
||||
label.point.x + label.width > viewport
|
||||
) {
|
||||
violations.push(`grouped edge ${route.edge.from}->${route.edge.to} label exceeds viewport`)
|
||||
}
|
||||
}
|
||||
for (const subgraph of layout.diagram.subgraphs ?? []) {
|
||||
const bound = layout.subgraphBounds.get(subgraph.id)
|
||||
if (!bound || !finiteBounds(bound)) violations.push(`subgraph ${subgraph.id} has invalid bounds`)
|
||||
requireOutputLines(output, splitDiagramLines(subgraph.label), `subgraph ${subgraph.id}`, violations)
|
||||
for (const nodeId of subgraph.nodeIds) {
|
||||
const node = layout.bounds.get(nodeId)
|
||||
if (bound && node && !boundsContain(bound, node))
|
||||
violations.push(`subgraph ${subgraph.id} does not contain ${nodeId}`)
|
||||
}
|
||||
}
|
||||
const subgraphs = layout.diagram.subgraphs ?? []
|
||||
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
|
||||
const ancestorOf = (ancestor: string, id: string) => {
|
||||
let parentId = subgraphById.get(id)?.parentId
|
||||
while (parentId) {
|
||||
if (parentId === ancestor) return true
|
||||
parentId = subgraphById.get(parentId)?.parentId
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const [index, subgraph] of subgraphs.entries()) {
|
||||
const bound = layout.subgraphBounds.get(subgraph.id)
|
||||
if (!bound) continue
|
||||
for (const other of subgraphs.slice(index + 1)) {
|
||||
if (ancestorOf(subgraph.id, other.id) || ancestorOf(other.id, subgraph.id)) continue
|
||||
const otherBound = layout.subgraphBounds.get(other.id)
|
||||
if (otherBound && boundsOverlap(bound, otherBound)) {
|
||||
violations.push(`subgraphs ${subgraph.id} and ${other.id} overlap`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
|
||||
}
|
||||
|
||||
function stateRoute(route: StateTransitionRenderPlan): AuditedRoute {
|
||||
return {
|
||||
from: route.route.transition.from,
|
||||
to: route.route.transition.to,
|
||||
points: route.path.map(([x, y]) => ({ x, y })),
|
||||
}
|
||||
}
|
||||
|
||||
function auditState(fixture: LayoutFixture, viewport: (typeof auditViewports)[number]): LayoutAudit {
|
||||
const violations: string[] = []
|
||||
const parsed = parseMermaidStateDiagram(fixture.source)
|
||||
const drawing = createStateDiagramDrawing(parsed, { minStateGap: 5, layoutMaxWidth: viewport })
|
||||
const diagram = drawing.diagram
|
||||
const layout = drawing.layout
|
||||
const plans = drawing.transitionPlans
|
||||
const grid = drawing.grid
|
||||
const routes = plans.map(stateRoute)
|
||||
const output = grid.toString({ trimTop: true, trimBottom: true })
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
const bodyIds = diagram.states.filter((state) => !isHiddenCompositeMarker(state)).map((state) => state.id)
|
||||
|
||||
if (!Number.isFinite(size.width) || !Number.isFinite(size.height) || size.width <= 0 || size.height <= 0)
|
||||
violations.push("rendered grid has invalid dimensions")
|
||||
if (plans.length !== diagram.transitions.length)
|
||||
violations.push("rendered route count does not match visible transition count")
|
||||
validateBodies(layout.bounds, bodyIds, violations)
|
||||
validateRoutes(routes, layout.bounds, bodyIds, violations)
|
||||
if (diagram.direction === "BT" && fixture.family === "chain") {
|
||||
for (const transition of diagram.transitions) {
|
||||
if (transition.from === transition.to) continue
|
||||
const from = layout.bounds.get(transition.from)
|
||||
const to = layout.bounds.get(transition.to)
|
||||
if (from && to && from.centerY <= to.centerY) {
|
||||
violations.push(`BT transition ${transition.from}->${transition.to} does not travel upward`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of diagram.states) {
|
||||
if (isHiddenCompositeMarker(state)) continue
|
||||
requireOutputLines(output, layout.sizes.get(state.id)?.lines ?? [state.label], `state ${state.id}`, violations)
|
||||
}
|
||||
for (const plan of plans) {
|
||||
if (!plan.route.transition.label) continue
|
||||
if (!plan.label)
|
||||
violations.push(`transition ${plan.route.transition.from}->${plan.route.transition.to} has no label layout`)
|
||||
requireOutputLines(
|
||||
output,
|
||||
plan.label?.lines ?? [],
|
||||
`transition ${plan.route.transition.from}->${plan.route.transition.to}`,
|
||||
violations,
|
||||
)
|
||||
}
|
||||
|
||||
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
|
||||
const compositesById = new Map(diagram.composites.map((composite) => [composite.id, composite]))
|
||||
const descendantOf = (id: string, compositeId: string) => {
|
||||
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
|
||||
while (parentId) {
|
||||
if (parentId === compositeId) return true
|
||||
parentId = compositesById.get(parentId)?.parentId
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const composite of diagram.composites) {
|
||||
const bound = layout.compositeBounds.get(composite.id)
|
||||
if (!bound || !finiteBounds(bound)) {
|
||||
violations.push(`composite ${composite.id} has invalid bounds`)
|
||||
continue
|
||||
}
|
||||
requireOutputLines(output, splitDiagramLines(composite.label), `composite ${composite.id}`, violations)
|
||||
for (const state of diagram.states.filter(
|
||||
(state) => !isHiddenCompositeMarker(state) && descendantOf(state.id, composite.id),
|
||||
)) {
|
||||
const stateBound = layout.bounds.get(state.id)
|
||||
if (stateBound && !boundsContain(bound, stateBound))
|
||||
violations.push(`composite ${composite.id} does not contain ${state.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, note] of layout.noteBounds.entries()) {
|
||||
if (!finiteBounds(note)) violations.push(`note ${index} has invalid bounds`)
|
||||
requireOutputLines(output, note.lines, `note ${index}`, violations)
|
||||
for (const id of bodyIds) {
|
||||
const bound = layout.bounds.get(id)
|
||||
if (bound && boundsOverlap(note, bound)) violations.push(`note ${index} overlaps state ${id}`)
|
||||
}
|
||||
for (const other of layout.noteBounds.slice(index + 1)) {
|
||||
if (boundsOverlap(note, other)) violations.push(`notes ${index} and ${other.id} overlap`)
|
||||
}
|
||||
const target = layout.bounds.get(note.note.target)
|
||||
if (!target) {
|
||||
violations.push(`note ${index} has no target bound`)
|
||||
continue
|
||||
}
|
||||
for (const point of expandedPath(stateDiagramNoteConnector(note, target).points)) {
|
||||
for (const id of bodyIds) {
|
||||
if (id === note.note.target) continue
|
||||
const bound = layout.bounds.get(id)
|
||||
if (bound && pointInBounds(point, bound)) violations.push(`note ${index} connector intersects state ${id}`)
|
||||
}
|
||||
for (const other of layout.noteBounds) {
|
||||
if (other === note) continue
|
||||
if (pointInBounds(point, other)) violations.push(`note ${index} connector intersects note ${other.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fixture, viewport, output, metrics: metrics(size.width, size.height, routes, viewport), violations }
|
||||
}
|
||||
|
||||
export function auditFixture(fixture: LayoutFixture, viewport: (typeof auditViewports)[number] = 120): LayoutAudit {
|
||||
return fixture.kind === "flowchart" ? auditFlowchart(fixture, viewport) : auditState(fixture, viewport)
|
||||
}
|
||||
|
||||
export function auditAllFixtures(): LayoutAudit[] {
|
||||
return layoutFixtures().flatMap((fixture, index) => {
|
||||
if (fixture.curated) return auditViewports.map((viewport) => auditFixture(fixture, viewport))
|
||||
if (fixture.kind === "flowchart") {
|
||||
return auditFixture(fixture, auditViewports[index % auditViewports.length])
|
||||
}
|
||||
const viewport = fixture.profile === "short" ? 60 : fixture.profile === "unicode" ? 80 : 120
|
||||
return auditFixture(fixture, viewport)
|
||||
})
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], ratio: number): number {
|
||||
if (values.length === 0) return 0
|
||||
return [...values].sort((left, right) => left - right)[Math.ceil(values.length * ratio) - 1] ?? 0
|
||||
}
|
||||
|
||||
export function summarizeAudits(audits: readonly LayoutAudit[]) {
|
||||
const summarize = (selected: readonly LayoutAudit[]) => ({
|
||||
runs: selected.length,
|
||||
sources: new Set(selected.map((audit) => audit.fixture.id)).size,
|
||||
violations: selected.reduce((total, audit) => total + audit.violations.length, 0),
|
||||
area: {
|
||||
p50: percentile(
|
||||
selected.map((audit) => audit.metrics.area),
|
||||
0.5,
|
||||
),
|
||||
p95: percentile(
|
||||
selected.map((audit) => audit.metrics.area),
|
||||
0.95,
|
||||
),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.area)),
|
||||
},
|
||||
bends: {
|
||||
p95: percentile(
|
||||
selected.map((audit) => audit.metrics.bends),
|
||||
0.95,
|
||||
),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.bends)),
|
||||
},
|
||||
crossings: {
|
||||
total: selected.reduce((total, audit) => total + audit.metrics.crossings, 0),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.crossings)),
|
||||
},
|
||||
routeLength: {
|
||||
p95: percentile(
|
||||
selected.map((audit) => audit.metrics.routeLength),
|
||||
0.95,
|
||||
),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.routeLength)),
|
||||
},
|
||||
sharedRouteCells: {
|
||||
p95: percentile(
|
||||
selected.map((audit) => audit.metrics.sharedRouteCells),
|
||||
0.95,
|
||||
),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.sharedRouteCells)),
|
||||
},
|
||||
overflow: {
|
||||
p95: percentile(
|
||||
selected.map((audit) => audit.metrics.overflow),
|
||||
0.95,
|
||||
),
|
||||
max: Math.max(0, ...selected.map((audit) => audit.metrics.overflow)),
|
||||
},
|
||||
})
|
||||
return {
|
||||
total: summarize(audits),
|
||||
flowchart: summarize(audits.filter((audit) => audit.fixture.kind === "flowchart")),
|
||||
state: summarize(audits.filter((audit) => audit.fixture.kind === "state")),
|
||||
}
|
||||
}
|
||||
|
||||
export function worstAudits(audits: readonly LayoutAudit[], metric: keyof LayoutMetrics, limit = 10): LayoutAudit[] {
|
||||
return [...audits]
|
||||
.sort(
|
||||
(left, right) => right.metrics[metric] - left.metrics[metric] || left.fixture.id.localeCompare(right.fixture.id),
|
||||
)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -334,6 +334,33 @@ flowchart LR
|
||||
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
|
||||
})
|
||||
|
||||
test("folds a horizontal state diagram to the Markdown context width", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 60, height: 48 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-horizontal-state",
|
||||
content: `\`\`\`mermaid
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
[*] --> A
|
||||
A --> B: first
|
||||
B --> C: second
|
||||
C --> D: third
|
||||
D --> [*]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
|
||||
expect(diagram.scrollWidth).toBeLessThanOrEqual(60)
|
||||
expect(testRenderer.captureCharFrame()).toContain("third")
|
||||
})
|
||||
|
||||
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
@@ -357,6 +384,27 @@ stateDiagram-v2
|
||||
expect(frame).not.toContain("stateDiagram-v2")
|
||||
})
|
||||
|
||||
test("sizes a standalone state choice after trimming leading rows", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 6 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-state-choice",
|
||||
content: `\`\`\`mermaid
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state Decision <<choice>>
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
expect(markdown.getChildren()[0]?.height).toBe(1)
|
||||
expect(testRenderer.captureCharFrame()).toContain("◆")
|
||||
})
|
||||
|
||||
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
|
||||
renderer = testRenderer.renderer
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { CommandApi } from "@opencode-ai/client/effect/api"
|
||||
import type { CommandInfo } from "@opencode-ai/client"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandDraft {
|
||||
list(): readonly CommandInfo[]
|
||||
get(name: string): CommandInfo | undefined
|
||||
update(name: string, update: (command: CommandInfo) => void): void
|
||||
remove(name: string): void
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface CommandDomain extends CommandApi<unknown> {
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export interface CommandDraft {
|
||||
add(definition: CommandDefinition): void
|
||||
}
|
||||
|
||||
export interface CommandDomain extends Pick<CommandApi<unknown>, "list"> {
|
||||
readonly transform: Transform<CommandDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
|
||||
import { Effect, Schema, SchemaAST, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { define } from "../effect/plugin.js"
|
||||
import type { Context, Plugin } from "./plugin.js"
|
||||
@@ -84,12 +85,11 @@ export function fromPromise(plugin: Plugin) {
|
||||
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
|
||||
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
|
||||
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
|
||||
const scope = yield* Scope.Scope
|
||||
const context = yield* Effect.context<Scope.Scope>()
|
||||
|
||||
// Run a hook registration on the plugin scope and resolve once it is registered.
|
||||
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
|
||||
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
|
||||
Effect.runPromiseWith(context)(effect).then((registration) => ({
|
||||
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
|
||||
}))
|
||||
|
||||
@@ -149,7 +149,19 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
command: {
|
||||
list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
|
||||
transform: transform(host.command),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.command.transform((draft) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.add({
|
||||
...definition,
|
||||
execute: (input) =>
|
||||
Effect.tryPromise({ try: () => definition.execute(input), catch: (cause) => cause }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
reload: () => run(host.command.reload()),
|
||||
},
|
||||
event: {
|
||||
@@ -325,9 +337,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
}
|
||||
|
||||
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
if (!cleanup) return
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.resolve(plugin.setup(context2))),
|
||||
(cleanup) => (cleanup ? Effect.promise(() => Promise.resolve(cleanup())) : Effect.void),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { CommandApi } from "@opencode-ai/client/promise/api"
|
||||
import type { CommandInfo } from "@opencode-ai/client"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface CommandDraft {
|
||||
list(): readonly CommandInfo[]
|
||||
get(name: string): CommandInfo | undefined
|
||||
update(name: string, update: (command: CommandInfo) => void): void
|
||||
remove(name: string): void
|
||||
export interface CommandInvocation {
|
||||
readonly sessionID: Session.ID
|
||||
readonly prompt: PromptInput.Prompt
|
||||
readonly delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface CommandDomain extends CommandApi {
|
||||
export interface CommandDefinition {
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly execute: (input: CommandInvocation) => Promise<void>
|
||||
}
|
||||
|
||||
export interface CommandDraft {
|
||||
add(definition: CommandDefinition): void
|
||||
}
|
||||
|
||||
export interface CommandDomain extends Pick<CommandApi, "list"> {
|
||||
readonly transform: Transform<CommandDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
+12
-103
@@ -1915,36 +1915,15 @@
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Session.Inbox.User"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1979,28 +1958,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "CommandEvaluationError",
|
||||
"description": "CommandExecutionError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandEvaluationErrorEncoded"
|
||||
"$ref": "#/components/schemas/CommandExecutionErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
"description": "Execute a slash command callback immediately.",
|
||||
"summary": "Run command",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -2008,49 +1977,11 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"arguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
@@ -2079,19 +2010,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resume": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": ["command", "text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -11658,31 +11579,19 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["name", "template"],
|
||||
"required": ["name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CommandEvaluationErrorEncoded": {
|
||||
"CommandExecutionErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["CommandEvaluationError"]
|
||||
"enum": ["CommandExecutionError"]
|
||||
},
|
||||
"command": {
|
||||
"type": "string"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { WorktreeGroup } from "./groups/worktree.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
import { WorkspaceGroup } from "./groups/workspace.js"
|
||||
|
||||
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
|
||||
@@ -86,6 +87,7 @@ type ApiGroups<
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| typeof WorktreeGroup
|
||||
| typeof WorkspaceGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -169,6 +171,7 @@ const makeApiFromGroup = <
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
.add(WorkspaceGroup)
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
|
||||
@@ -60,6 +60,7 @@ export const groupNames = {
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.worktree": "worktree",
|
||||
"server.workspace": "workspace",
|
||||
"server.vcs": "vcs",
|
||||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
@@ -117,8 +117,8 @@ export class CommandNotFoundError extends Schema.TaggedError<CommandNotFoundErro
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class CommandEvaluationError extends Schema.TaggedError<CommandEvaluationError>()(
|
||||
"CommandEvaluationError",
|
||||
export class CommandExecutionError extends Schema.TaggedError<CommandExecutionError>()(
|
||||
"CommandExecutionError",
|
||||
{
|
||||
command: Schema.String,
|
||||
message: Schema.String,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
CommandEvaluationError,
|
||||
CommandExecutionError,
|
||||
CommandNotFoundError,
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
@@ -358,27 +358,19 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
HttpApiEndpoint.post("session.command", "/api/session/:sessionID/command", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
command: Schema.String,
|
||||
arguments: Schema.String.pipe(Schema.optional),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
files: PromptInput.Prompt.fields.files,
|
||||
agents: PromptInput.Prompt.fields.agents,
|
||||
skills: PromptInput.Prompt.fields.skills,
|
||||
...PromptInput.Prompt.fields,
|
||||
delivery: SessionInbox.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: SessionInbox.User }),
|
||||
error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.command",
|
||||
summary: "Run command",
|
||||
description:
|
||||
"Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.",
|
||||
description: "Execute a slash command callback immediately.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { UnknownError } from "../errors.js"
|
||||
|
||||
export const WorkspaceGroup = HttpApiGroup.make("server.workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.delete("workspace.destroy", "/api/workspace/:workspaceID", {
|
||||
params: { workspaceID: Workspace.ID },
|
||||
success: Workspace.DestroyResult,
|
||||
error: UnknownError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.workspace.destroy",
|
||||
summary: "Destroy workspace",
|
||||
description:
|
||||
"Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Workspace lifecycle routes." }))
|
||||
@@ -3,19 +3,13 @@ export * as Command from "./command.js"
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Agent } from "./agent.js"
|
||||
|
||||
const Updated = ephemeral({ type: "command.updated", schema: {} })
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Agent.ID.pipe(optional),
|
||||
model: Model.Ref.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Command.Info" })
|
||||
|
||||
export const Event = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemV1 } from "./filesystem-v1.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Group } from "./group.js"
|
||||
import { InstallationEvent } from "./installation-event.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { LegacyEventV1 } from "./legacy-event.js"
|
||||
@@ -56,6 +57,7 @@ const featureDefinitions = Event.inventory(
|
||||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
...Group.Event.Definitions,
|
||||
...WebSearch.Event.Definitions,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export * as Group from "./group.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { statics } from "./schema.js"
|
||||
import { Session } from "./session.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("grp_")).pipe(Schema.brand("GroupID"))
|
||||
|
||||
export const ID = IDSchema.pipe(
|
||||
statics((schema: typeof IDSchema) => ({ create: () => schema.make("grp_" + ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const SessionItem = Schema.Struct({
|
||||
type: Schema.tag("session"),
|
||||
id: Session.ID,
|
||||
})
|
||||
export interface SessionItem extends Schema.Schema.Type<typeof SessionItem> {}
|
||||
|
||||
export const TerminalItem = Schema.Struct({
|
||||
type: Schema.tag("terminal"),
|
||||
id: Pty.ID,
|
||||
})
|
||||
export interface TerminalItem extends Schema.Schema.Type<typeof TerminalItem> {}
|
||||
|
||||
export const Item = Schema.Union([SessionItem, TerminalItem]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
Schema.annotate({ identifier: "Group.Item" }),
|
||||
)
|
||||
export type Item = typeof Item.Type
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
items: Schema.Array(Item),
|
||||
}).annotate({ identifier: "Group.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
const ItemAdded = ephemeral({ type: "group.item.added", schema: { groupID: ID, item: Item } })
|
||||
const ItemRemoved = ephemeral({ type: "group.item.removed", schema: { groupID: ID, item: Item } })
|
||||
export const Event = { ItemAdded, ItemRemoved, Definitions: inventory(ItemAdded, ItemRemoved) }
|
||||
@@ -6,6 +6,7 @@ export { Credential } from "./credential.js"
|
||||
export { Event } from "./event.js"
|
||||
export { FileSystem } from "./filesystem.js"
|
||||
export { Form } from "./form.js"
|
||||
export { Group } from "./group.js"
|
||||
export { Integration } from "./integration.js"
|
||||
export { LLM } from "./llm.js"
|
||||
export { Location } from "./location.js"
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
export * as Workspace from "./workspace.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { WorkspaceEvent } from "./workspace-event.js"
|
||||
import { WorkspaceID } from "./workspace-id.js"
|
||||
|
||||
export const ID = WorkspaceID
|
||||
export type ID = WorkspaceID
|
||||
|
||||
export const DestroyResult = Schema.Struct({
|
||||
destroyed: Schema.Boolean.annotate({
|
||||
description: "True when this request transitioned the workspace from existing to destroyed.",
|
||||
}),
|
||||
}).annotate({
|
||||
identifier: "WorkspaceDestroyResult",
|
||||
description: "Reports whether this request destroyed an existing workspace.",
|
||||
})
|
||||
export interface DestroyResult extends Schema.Schema.Type<typeof DestroyResult> {}
|
||||
|
||||
export const Event = WorkspaceEvent
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Config,
|
||||
FileSystem,
|
||||
Form,
|
||||
Group,
|
||||
Integration,
|
||||
Permission,
|
||||
Project,
|
||||
@@ -64,6 +65,7 @@ describe("public event manifest", () => {
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
|
||||
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
expect(Group.Event.Definitions).toEqual([Group.Event.ItemAdded, Group.Event.ItemRemoved])
|
||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
|
||||
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Group } from "../src/group.js"
|
||||
import { Pty } from "../src/pty.js"
|
||||
import { Session } from "../src/session.js"
|
||||
|
||||
describe("Group", () => {
|
||||
test("creates branded group IDs", () => {
|
||||
expect(Group.ID.create()).toStartWith("grp_")
|
||||
expect(() => Schema.decodeUnknownSync(Group.ID)("ses_invalid")).toThrow()
|
||||
})
|
||||
|
||||
test("preserves one ordered session and terminal item list", () => {
|
||||
const group = Schema.decodeUnknownSync(Group.Info)({
|
||||
id: Group.ID.create(),
|
||||
items: [
|
||||
{ type: "session", id: Session.ID.make("ses_one") },
|
||||
{ type: "terminal", id: Pty.ID.make("pty_one") },
|
||||
{ type: "session", id: Session.ID.make("ses_two") },
|
||||
],
|
||||
})
|
||||
|
||||
expect(group.items.map((item) => item.type)).toEqual(["session", "terminal", "session"])
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(Group.Info)({
|
||||
id: group.id,
|
||||
items: [{ type: "other", id: "other_one" }],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
+62
-40
@@ -1,50 +1,72 @@
|
||||
# @opencode-ai/sdk
|
||||
|
||||
Effect-native scoped OpenCode host for in-process applications.
|
||||
|
||||
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
|
||||
In-process OpenCode host for Promise and Effect applications. The SDK executes Server's assembled HTTP router in memory, opening no listener and adding no network hop.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk"
|
||||
|
||||
await using opencode = await OpenCode.create()
|
||||
const session = await opencode.sessions.create({
|
||||
location: { directory: "/workspace" },
|
||||
})
|
||||
```
|
||||
|
||||
Pass imported Promise plugins in `plugins`, or register one later with `await opencode.plugin(plugin)`.
|
||||
|
||||
The Promise API uses the same values, errors, request options, and `AsyncIterable` streams as `@opencode-ai/client`.
|
||||
|
||||
Embedded hosts are silent by default. Set `log` to receive structured log entries:
|
||||
|
||||
```ts
|
||||
await using opencode = await OpenCode.create({
|
||||
log: {
|
||||
level: "warn",
|
||||
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations.
|
||||
|
||||
## Workerd
|
||||
|
||||
Use the Workerd entrypoint inside a Cloudflare Durable Object. Hold one host for the lifetime of the object instance rather than creating one per request.
|
||||
|
||||
```ts
|
||||
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
|
||||
import myPlugin from "./my-plugin"
|
||||
|
||||
export class OpenCodeDO {
|
||||
private readonly opencode: Promise<OpenCodeWorkerd.Interface>
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.opencode = state.blockConcurrencyWhile(() =>
|
||||
OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
config: { default_agent: "build" },
|
||||
plugins: [myPlugin],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
const opencode = await this.opencode
|
||||
return Response.json(await opencode.health.get())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`blockConcurrencyWhile` keeps every Durable Object event out until the host is ready and resets the object if initialization fails. The retained Promise gives request handlers direct access to the same host after startup. Configuration is a typed JavaScript object, and plugins are imported values bundled with the Worker.
|
||||
|
||||
## Effect
|
||||
|
||||
The Effect-native API remains available from `@opencode-ai/sdk/effect`:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk/effect"
|
||||
|
||||
const opencode = yield * OpenCode.create()
|
||||
const session = yield * opencode.sessions.get({ sessionID })
|
||||
```
|
||||
|
||||
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
|
||||
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
|
||||
|
||||
```ts
|
||||
const opencode =
|
||||
yield *
|
||||
OpenCode.create({
|
||||
log: {
|
||||
level: "warn",
|
||||
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
|
||||
|
||||
The same constructor is available as a service Layer:
|
||||
|
||||
```ts
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.sessions.get({ sessionID })
|
||||
})
|
||||
|
||||
yield * program.pipe(Effect.provide(OpenCode.layer()))
|
||||
```
|
||||
|
||||
`OpenCode.layer(options)` adapts the scoped `OpenCode.create(options)` convenience constructor for dependency injection.
|
||||
|
||||
Workspace providers are host infrastructure configured when the SDK is constructed. Workspace lifecycle operations remain on the typed facade:
|
||||
|
||||
```ts
|
||||
const opencode = yield * OpenCode.create({ workspaceProviders: { modal: modalWorkspaceProvider } })
|
||||
const workspace = yield * opencode.workspace.create({ provider: "modal" })
|
||||
yield * opencode.workspace.destroy({ workspaceID: workspace.id })
|
||||
```
|
||||
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./workerd": "./src/workerd.ts"
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./workerd": "./src/workerd.ts",
|
||||
"./workerd/effect": "./src/effect/workerd.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
@@ -28,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -7,7 +7,19 @@ import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url))
|
||||
const names = ["schema", "codemode", "ai", "util", "protocol", "client", "plugin", "core", "simulation", "server", "sdk"]
|
||||
const names = [
|
||||
"schema",
|
||||
"codemode",
|
||||
"ai",
|
||||
"util",
|
||||
"protocol",
|
||||
"client",
|
||||
"plugin",
|
||||
"core",
|
||||
"simulation",
|
||||
"server",
|
||||
"sdk",
|
||||
]
|
||||
const temporary = await mkdtemp(join(tmpdir(), "opencode-sdk-package-"))
|
||||
const archives = new Map<string, string>()
|
||||
|
||||
@@ -29,7 +41,8 @@ try {
|
||||
const unpacked = Object.keys(pkg.dependencies).filter(
|
||||
(dependency) => dependency.startsWith("@opencode-ai/") && !archives.has(dependency),
|
||||
)
|
||||
if (unpacked.length > 0) throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
|
||||
if (unpacked.length > 0)
|
||||
throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
|
||||
pkg.dependencies = Object.fromEntries(
|
||||
Object.entries(pkg.dependencies).map(([dependency, version]) => {
|
||||
const local = archives.get(dependency)
|
||||
@@ -50,7 +63,10 @@ try {
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(conditions).map(([condition, value]) => [condition, output(name, value, condition === "types")]),
|
||||
Object.entries(conditions).map(([condition, value]) => [
|
||||
condition,
|
||||
output(name, value, condition === "types"),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
@@ -86,28 +102,21 @@ try {
|
||||
join(consumer, "worker.js"),
|
||||
`import { bodyDigest } from "@opencode-ai/core/models-dev"
|
||||
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export class OpenCodeDO {
|
||||
constructor(state) {
|
||||
this.state = state
|
||||
this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
app: { version: "packed-workerd" },
|
||||
}))
|
||||
}
|
||||
|
||||
fetch() {
|
||||
async fetch() {
|
||||
if (bodyDigest("packed-workerd") !== "5fc174bf63e8dd108ebb6c53d85e7bbc4525b2f4c1c43280364cdbfd9b37aaf5") {
|
||||
throw new Error("Packed workerd SHA-256 mismatch")
|
||||
}
|
||||
const storage = this.state.storage
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* OpenCodeWorkerd.create({
|
||||
storage,
|
||||
app: { version: "packed-workerd" },
|
||||
config: { content: "{}" },
|
||||
})
|
||||
return Response.json(yield* sdk.health.get())
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
const opencode = await this.opencode
|
||||
return Response.json(await opencode.health.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +151,21 @@ try {
|
||||
} finally {
|
||||
await miniflare.dispose()
|
||||
}
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
join(consumer, "imports.mjs"),
|
||||
`const modules = await Promise.all([
|
||||
import("@opencode-ai/sdk"),
|
||||
import("@opencode-ai/sdk/effect"),
|
||||
import("@opencode-ai/sdk/workerd"),
|
||||
import("@opencode-ai/sdk/workerd/effect"),
|
||||
])
|
||||
|
||||
for (const module of modules) {
|
||||
const api = module.OpenCode ?? module.OpenCodeWorkerd
|
||||
if (typeof api?.create !== "function") throw new Error("Packed SDK entrypoint is missing create()")
|
||||
}
|
||||
`,
|
||||
),
|
||||
])
|
||||
@@ -149,6 +173,8 @@ try {
|
||||
const sdk = archives.get("@opencode-ai/sdk")
|
||||
if (!sdk) throw new Error("Packed SDK archive was not created")
|
||||
await $`npm install --ignore-scripts --no-audit --no-fund --package-lock=false ${sdk} wrangler@4.110.0`.cwd(consumer)
|
||||
await $`bun imports.mjs`.cwd(consumer)
|
||||
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
|
||||
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
|
||||
|
||||
const transpiler = new Bun.Transpiler({ loader: "js" })
|
||||
@@ -159,12 +185,12 @@ try {
|
||||
const bunGlobals = Array.from(new Set(bundled.match(/\bBun\.[A-Za-z_$][\w$]*/g) ?? []))
|
||||
if (bunGlobals.length > 0) throw new Error(`Packed workerd bundle references Bun globals: ${bunGlobals.join(", ")}`)
|
||||
const leaked = [
|
||||
...transpiler.scanImports(bundled)
|
||||
...transpiler
|
||||
.scanImports(bundled)
|
||||
.filter((imported) => imported.kind !== "dynamic-import")
|
||||
.map((imported) => imported.path),
|
||||
...Array.from(bundled.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g), (match) => match[1]),
|
||||
]
|
||||
.filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
|
||||
].filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
|
||||
if (leaked.length > 0) throw new Error(`Packed workerd bundle statically imports Bun builtins: ${leaked.join(", ")}`)
|
||||
|
||||
await $`node boot.mjs`.cwd(consumer)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Config } from "@opencode-ai/schema/config"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Workspace } from "@opencode-ai/schema/workspace"
|
||||
@@ -0,0 +1,6 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
export * as Tool from "./tool"
|
||||
|
||||
export { ClientError } from "@opencode-ai/client/effect"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
export * from "../contracts"
|
||||
@@ -0,0 +1,58 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging"
|
||||
|
||||
export type CreateOptions = EmbeddedHost.CreateOptions
|
||||
export type EmbedOptions = EmbeddedHost.EmbedOptions
|
||||
|
||||
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly sessions: OpenCodeClient["session"]
|
||||
readonly events: OpenCodeClient["event"]
|
||||
readonly workspace: {
|
||||
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
|
||||
readonly provision: (options: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => ReturnType<Workspace.Interface["provision"]>
|
||||
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
|
||||
}
|
||||
readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"]
|
||||
}
|
||||
|
||||
export const create: (
|
||||
options?: CreateOptions,
|
||||
embed?: EmbedOptions,
|
||||
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
|
||||
options: CreateOptions = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provide(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...client,
|
||||
sessions: client.session,
|
||||
events: client.event,
|
||||
workspace: {
|
||||
create: ({ provider }: { readonly provider: string }) => host.workspace.create(provider),
|
||||
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.provision(workspaceID),
|
||||
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
|
||||
},
|
||||
plugin: Object.assign(host.plugins.register, client.plugin),
|
||||
}
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
|
||||
|
||||
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
|
||||
Layer.effect(Service, create(options))
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RegistrationError } from "@opencode-ai/core/tool"
|
||||
export { Error } from "@opencode-ai/schema/tool"
|
||||
export type { Context, Info } from "@opencode-ai/schema/tool"
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as OpenCodeWorkerd from "./workerd"
|
||||
|
||||
import { Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { WorkerdProfile } from "../internal/workerd"
|
||||
import { OpenCode } from "./opencode"
|
||||
|
||||
export type Configuration = WorkerdProfile.Configuration
|
||||
|
||||
export interface CreateOptions extends WorkerdProfile.Options {
|
||||
readonly log?: OpenCode.CreateOptions["log"]
|
||||
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
|
||||
}
|
||||
|
||||
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
|
||||
const profile = WorkerdProfile.make(options)
|
||||
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
|
||||
}
|
||||
|
||||
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
|
||||
Layer.effect(OpenCode.Service, create(options))
|
||||
|
||||
export type Interface = OpenCode.Interface
|
||||
export type Requirements = Scope.Scope
|
||||
@@ -1,31 +1,6 @@
|
||||
export * as OpenCode from "./opencode"
|
||||
export * as Tool from "./tool"
|
||||
|
||||
export { ClientError } from "@opencode-ai/client/effect"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Config } from "@opencode-ai/schema/config"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Workspace } from "@opencode-ai/schema/workspace"
|
||||
export { ClientError } from "@opencode-ai/client"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
export * from "./contracts"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export * as OwnedFetch from "./fetch"
|
||||
|
||||
export function make(handler: (request: Request) => Promise<Response>, dispose: () => Promise<void>) {
|
||||
const requests = new Set<Promise<void>>()
|
||||
const shutdown = new AbortController()
|
||||
const closed = new Error("OpenCode host is closed")
|
||||
let closePromise: Promise<void> | undefined
|
||||
const fetch = Object.assign(
|
||||
(input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (closePromise) return Promise.reject(closed)
|
||||
const source = new Request(input, init)
|
||||
if (source.signal.aborted) return Promise.reject(source.signal.reason)
|
||||
const request = new Request(source, { signal: AbortSignal.any([source.signal, shutdown.signal]) })
|
||||
const lifetime = Promise.withResolvers<void>()
|
||||
const finish = () => {
|
||||
requests.delete(lifetime.promise)
|
||||
lifetime.resolve()
|
||||
}
|
||||
requests.add(lifetime.promise)
|
||||
|
||||
const handled = handler(request)
|
||||
return rejectOnAbort(handled, request.signal).then(
|
||||
(response) => trackResponse(response, request.signal, finish),
|
||||
(cause) => {
|
||||
void handled.then(finish, finish)
|
||||
throw cause
|
||||
},
|
||||
)
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
) satisfies typeof globalThis.fetch
|
||||
const close = () => {
|
||||
if (closePromise) return closePromise
|
||||
closePromise = Promise.resolve().then(async () => {
|
||||
shutdown.abort(closed)
|
||||
await Promise.allSettled(requests)
|
||||
await dispose()
|
||||
})
|
||||
return closePromise
|
||||
}
|
||||
return { fetch, close }
|
||||
}
|
||||
|
||||
function rejectOnAbort<A>(promise: Promise<A>, signal: AbortSignal): Promise<A> {
|
||||
if (signal.aborted) return Promise.reject(signal.reason)
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason)
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
resolve(value)
|
||||
},
|
||||
(cause) => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
reject(cause)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function trackResponse(response: Response, signal: AbortSignal, finish: () => void): Response {
|
||||
if (!response.body) {
|
||||
finish()
|
||||
return response
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
let done = false
|
||||
let abort = () => {}
|
||||
const complete = () => {
|
||||
if (done) return false
|
||||
done = true
|
||||
signal.removeEventListener("abort", abort)
|
||||
return true
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
abort = () => {
|
||||
if (!complete()) return
|
||||
controller.error(signal.reason)
|
||||
void reader.cancel(signal.reason).then(finish, finish)
|
||||
}
|
||||
if (signal.aborted) abort()
|
||||
else signal.addEventListener("abort", abort, { once: true })
|
||||
},
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read()
|
||||
if (done) return
|
||||
if (!next.done) {
|
||||
controller.enqueue(next.value)
|
||||
return
|
||||
}
|
||||
if (!complete()) return
|
||||
controller.close()
|
||||
finish()
|
||||
} catch (cause) {
|
||||
if (!complete()) return
|
||||
controller.error(cause)
|
||||
finish()
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
if (!complete()) return
|
||||
try {
|
||||
await reader.cancel(reason)
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
})
|
||||
return new Response(body, response)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export * as EmbeddedHost from "./host"
|
||||
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import type { ServerOptions } from "@opencode-ai/server/options"
|
||||
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { context, layer, type LogOptions } from "../logging"
|
||||
import { OwnedFetch } from "./fetch"
|
||||
|
||||
export interface CreateOptions extends Omit<ServerOptions, "hostname" | "port" | "password"> {
|
||||
readonly log?: LogOptions
|
||||
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
|
||||
}
|
||||
|
||||
/** Host hooks for embedding opencode on a non-default runtime profile. */
|
||||
export interface EmbedOptions {
|
||||
readonly overrides?: LayerNode.Replacements
|
||||
}
|
||||
|
||||
export const create = Effect.fn("EmbeddedHost.create")(function* (
|
||||
options: CreateOptions = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const { log, workspaceProviders, ...server } = options
|
||||
const runtime = ManagedRuntime.make(
|
||||
createEmbeddedRoutes(
|
||||
{
|
||||
...server,
|
||||
app: { ...server.app, name: server.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...server.database },
|
||||
},
|
||||
workspaceProviders
|
||||
? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]]
|
||||
: embed.overrides,
|
||||
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const services = yield* runtime.contextEffect
|
||||
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns the
|
||||
// fiber so recovery never delays startup but still stops with the host.
|
||||
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
|
||||
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
|
||||
context(services),
|
||||
)(Context.get(services, HttpRouter.HttpRouter).asHttpEffect())
|
||||
const transport = OwnedFetch.make(handler, runtime.dispose)
|
||||
|
||||
return {
|
||||
runtime,
|
||||
fetch: transport.fetch,
|
||||
plugins: Context.get(services, SdkPlugins.Service),
|
||||
workspace: Context.get(services, Workspace.Service),
|
||||
close: transport.close,
|
||||
}
|
||||
}).pipe(Effect.onError(() => runtime.disposeEffect))
|
||||
})
|
||||
|
||||
export type Interface = Effect.Success<ReturnType<typeof create>>
|
||||
@@ -0,0 +1,21 @@
|
||||
export * as WorkerdProfile from "./workerd"
|
||||
|
||||
import type { Config } from "@opencode-ai/schema/config"
|
||||
import { ServerWorkerd } from "@opencode-ai/server/workerd"
|
||||
|
||||
export type Configuration = Omit<typeof Config.Info.Encoded, "plugins">
|
||||
|
||||
export interface Options extends Omit<ServerWorkerd.Options, "password" | "config"> {
|
||||
readonly config?: Configuration
|
||||
}
|
||||
|
||||
export function make({ config, ...options }: Options) {
|
||||
const server = {
|
||||
...options,
|
||||
config: config === undefined ? undefined : { content: JSON.stringify(config) },
|
||||
}
|
||||
return {
|
||||
options: ServerWorkerd.serverOptions(server),
|
||||
replacements: ServerWorkerd.replacements(server),
|
||||
}
|
||||
}
|
||||
@@ -1,127 +1,8 @@
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Database } from "@opencode-ai/core/database/database"
|
||||
import type { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Config, Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import * as Logging from "./logging"
|
||||
import { PromiseSdk } from "./promise"
|
||||
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
|
||||
import type { LogOptions } from "./logging"
|
||||
|
||||
export interface CreateOptions {
|
||||
readonly app?: {
|
||||
readonly name?: string
|
||||
readonly version?: string
|
||||
readonly channel?: string
|
||||
}
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly password?: string
|
||||
readonly simulation?: boolean
|
||||
readonly database?: Database.Options
|
||||
readonly events?: { readonly persist?: boolean }
|
||||
readonly models?: ModelsDev.Options
|
||||
readonly config?: {
|
||||
readonly directory?: string
|
||||
readonly project?: boolean
|
||||
readonly file?: string
|
||||
readonly content?: string
|
||||
}
|
||||
readonly windows?: { readonly gitbash?: string }
|
||||
readonly fs?: {
|
||||
readonly filewatcher?: boolean
|
||||
readonly fff?: boolean
|
||||
}
|
||||
readonly log?: LogOptions
|
||||
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
|
||||
}
|
||||
export type CreateOptions = PromiseSdk.CreateOptions
|
||||
export type Interface = PromiseSdk.Interface
|
||||
|
||||
/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */
|
||||
export interface EmbedOptions {
|
||||
readonly overrides?: LayerNode.Replacements
|
||||
}
|
||||
|
||||
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly sessions: OpenCodeClient["session"]
|
||||
readonly events: OpenCodeClient["event"]
|
||||
readonly workspace: {
|
||||
readonly create: (options: { readonly provider: string }) => ReturnType<Workspace.Interface["create"]>
|
||||
readonly provision: (options: {
|
||||
readonly workspaceID: Workspace.ID
|
||||
}) => ReturnType<Workspace.Interface["provision"]>
|
||||
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
|
||||
}
|
||||
readonly plugin: SdkPlugins.Interface["register"] & OpenCodeClient["plugin"]
|
||||
}
|
||||
|
||||
export const create: (
|
||||
options?: CreateOptions,
|
||||
embed?: EmbedOptions,
|
||||
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
|
||||
options: CreateOptions = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const { log, workspaceProviders, ...server } = options
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
ManagedRuntime.make(
|
||||
createEmbeddedRoutes(
|
||||
{
|
||||
...server,
|
||||
app: { ...server.app, name: server.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...server.database },
|
||||
},
|
||||
workspaceProviders
|
||||
? [...(embed.overrides ?? []), [WorkspaceDriver.node, WorkspaceDriver.registryNode(workspaceProviders)]]
|
||||
: embed.overrides,
|
||||
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
|
||||
),
|
||||
),
|
||||
(runtime) => runtime.disposeEffect,
|
||||
)
|
||||
const context = yield* runtime.contextEffect
|
||||
// Unconditional, as on every runtime: the sweep is a no-op when nothing is
|
||||
// suspended (always, for the default in-memory database). Forked so the
|
||||
// returned client is never delayed; resumed drains are already logged and
|
||||
// durably recorded by the execution layer.
|
||||
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const workspace = Context.get(context, Workspace.Service)
|
||||
const router = Context.get(context, HttpRouter.HttpRouter)
|
||||
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
|
||||
Logging.context(context),
|
||||
)(router.asHttpEffect())
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)),
|
||||
)
|
||||
return {
|
||||
...client,
|
||||
sessions: client.session,
|
||||
events: client.event,
|
||||
workspace: {
|
||||
create: ({ provider }: { readonly provider: string }) => workspace.create(provider),
|
||||
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.provision(workspaceID),
|
||||
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => workspace.destroy(workspaceID),
|
||||
},
|
||||
// The embedded host contributes plugins through the ordinary discovery flow:
|
||||
// each plugin's `effect` runs inside every Location with the real
|
||||
// `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly
|
||||
// as they do for a config-discovered plugin. Define agent profiles here at
|
||||
// startup, then select one per Session with `sessions.create({ agent })`.
|
||||
plugin: Object.assign(plugins.register, client.plugin),
|
||||
}
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
|
||||
|
||||
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
|
||||
Layer.effect(Service, create(options))
|
||||
export const create = (options: CreateOptions = {}) => PromiseSdk.create(options)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user