mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 02:56:18 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5877de72 | ||
|
|
9049e26ab7 | ||
|
|
7dda1f1471 | ||
|
|
7be5a81a7b | ||
|
|
a02b0a4729 | ||
|
|
d8ce27fa29 | ||
|
|
2f740cec5d | ||
|
|
162c3fcebd | ||
|
|
71f81dc0fe | ||
|
|
de388dede4 | ||
|
|
c936acd3fe | ||
|
|
c19186ee54 | ||
|
|
563943c52e | ||
|
|
575bbd6ea1 | ||
|
|
5d9b53b2c7 | ||
|
|
4780248e84 | ||
|
|
43d4968356 | ||
|
|
0164c1c8bc | ||
|
|
6a687398eb | ||
|
|
f4cb9d06c8 | ||
|
|
3e82b1a9fd | ||
|
|
e2a7600a2a | ||
|
|
aa8c1f6dac | ||
|
|
23c3a1461c | ||
|
|
50a8539e4b | ||
|
|
7b47589225 | ||
|
|
84275c6e9d | ||
|
|
793ea52fa7 | ||
|
|
40380ad9b5 |
@@ -89,6 +89,12 @@ jobs:
|
||||
working-directory: packages/codemode
|
||||
run: bun run script/publish.ts --dry-run
|
||||
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/sdk
|
||||
run: bun run verify:package
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -595,8 +595,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1090,9 +1090,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.8",
|
||||
"@opentui/keymap": "0.5.8",
|
||||
"@opentui/solid": "0.5.8",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2218,27 +2218,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.8", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.8", "@opentui/core-darwin-x64": "0.5.8", "@opentui/core-linux-arm64": "0.5.8", "@opentui/core-linux-arm64-musl": "0.5.8", "@opentui/core-linux-x64": "0.5.8", "@opentui/core-linux-x64-musl": "0.5.8", "@opentui/core-win32-arm64": "0.5.8", "@opentui/core-win32-x64": "0.5.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GbZ+nSLYZqxj2Z5TU19Mx6IsAvVsn2+7WEXz+6OlMGoootvt3TxP3vWwfGI0jWk9qp1ftRlML1JPNEkzy+9I8g=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c9Y1FBrSnA4sKUCMETsrLYOmsMTyJae8mU9cE6M4o9rXcr3ZLPA7o9AkPA3S0+kH+kZ0o1Fn0wLejgNxvEp5mg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-oZ/6Iz1KN+4volMFKmmvziYJhMgyyJ99LfK1S+uPRyIRqzT3CESoJ58D4h04M1g0dHeHE4PvkU1p3uQKrXiP3g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-N6i/ocrsTjIq9aUQyrfJkqUo+tc4P5ZS6xz38Cm1MhtDzwBE+CrNIJdHfoJfmRLhJkYHlw6Z0ToICn8ZNj4Bpw=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-eFMB41AWODaYf8PsCx3vtTMX33tFgtSpR9tKNnUErCXlcnZ+WCRHVaJ9F6DrVNX3ir6HmgbwAmaWXvaVF+kLtg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-/2QM7/wMnML/sxchzbwgoU5tUu/7k836/kSOKMti8opjuecv1K+WWNKGXufhTNeRcXFZaba5rsCdYrf3VqnVsQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YXo+qUHYmep2uvv3ECvTeqr10aD7+lBsavYmsTLBzS5hHabbzlQ10oX/99nIRPC3Au1BMWD6d6zQP5czPx23eg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-7qBdhEAlh4tLFzW7nWLPlREtNiF6NZMDMi+4uDpUlAMhRavLr6wjcIcgfhNAF/puq06DjuZlPTwaMCgB3qOuwA=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Z76YaTKnmRDSHKdKa7iTBCXBQdInQLq7UG3qIE84nvyHCDtkhYtPWOaCzDisSgXC8Y6hJt6NiFbaNjQyxPkfpQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.8", "", { "dependencies": { "@opentui/core": "0.5.8" }, "peerDependencies": { "@opentui/react": "0.5.8", "@opentui/solid": "0.5.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-KQHKRnLZroSZIbHmGmSeDPsXi7Yykdu9x9ACmDzBFkgPGx0EIuXHqRcmrqmUaX485MQEZuCD9t6FdDsOMGyIFg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-L0NxuAU8XT+jlE5G90oA3kspqkof48b0hmzi5XLw+1gxnkxrkTb+YfKys+GzVK4UqhgwY9aW+TeDfrKbnfwCMw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
|
||||
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
|
||||
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
|
||||
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
|
||||
"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="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.8",
|
||||
"@opentui/keymap": "0.5.8",
|
||||
"@opentui/solid": "0.5.8",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: item.os,
|
||||
arch: item.arch,
|
||||
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
|
||||
})
|
||||
const opencodePtyPlugin: BunPlugin = {
|
||||
name: "opencode-pty-binary",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /persistent-pty[/\\]asset\.ts$/ }, () => ({
|
||||
loader: "js",
|
||||
contents: opencodePty
|
||||
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
|
||||
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
|
||||
: "export default undefined",
|
||||
}))
|
||||
},
|
||||
}
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
@@ -105,7 +123,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -18,6 +19,11 @@ export type NodeAsset = {
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||
})
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
const assets: NodeAsset[] = [
|
||||
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
|
||||
...(await collectFiles(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const VERSION = "0.1.4"
|
||||
const RELEASE = `https://github.com/jlongster/opencode-pty/releases/download/v${VERSION}`
|
||||
const SHA256 = {
|
||||
"aarch64-apple-darwin": "a91b790ee14a9d75d3dccf5ee40ded1326e5250d6882d5274b72e41e1455f8ec",
|
||||
"aarch64-unknown-linux-gnu": "53e28264e9bad28f1f2d4900f6ad5e04d034b900ff3f341cc57be73a5152d5a6",
|
||||
"aarch64-unknown-linux-musl": "00f018af1f3b2f6adf93c6d9b8866216c4266fe4e1d06e47a76bc001ae4aac46",
|
||||
"x86_64-apple-darwin": "12fe4c456ad7895994e6af7d67112b4f65871f41267a7a51341e70227052d114",
|
||||
"x86_64-unknown-linux-gnu": "9c03efc505ce86a6204b9bdfc03b30e2eb7d6edaca1c6435b0a839538d4c812f",
|
||||
"x86_64-unknown-linux-musl": "2c5803822d9d88f8d6e201d3afd28c3a861d679e8f8c88e68c54bbfa1c12214a",
|
||||
} as const
|
||||
|
||||
export type OpencodePtyAsset = {
|
||||
readonly source: string
|
||||
readonly version: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly platform: string
|
||||
readonly arch: string
|
||||
readonly libc?: "glibc" | "musl"
|
||||
}
|
||||
|
||||
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
|
||||
|
||||
export function resolveOpencodePty(target: Target) {
|
||||
const rustTarget = targetName(target)
|
||||
if (!rustTarget) return Promise.resolve(undefined)
|
||||
const existing = pending.get(rustTarget)
|
||||
if (existing) return existing
|
||||
const result = acquire(rustTarget).catch((error) => {
|
||||
pending.delete(rustTarget)
|
||||
throw error
|
||||
})
|
||||
pending.set(rustTarget, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
|
||||
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
|
||||
const executable = path.join(root, "opencode-pty")
|
||||
const cached = await readFile(executable).catch(() => undefined)
|
||||
if (cached)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(cached).digest("hex"),
|
||||
}
|
||||
|
||||
await mkdir(root, { recursive: true })
|
||||
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
|
||||
const response = await fetch(`${RELEASE}/${archiveName}`)
|
||||
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
|
||||
const archive = new Uint8Array(await response.arrayBuffer())
|
||||
const actual = createHash("sha256").update(archive).digest("hex")
|
||||
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
|
||||
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
|
||||
try {
|
||||
const archivePath = path.join(temporary, archiveName)
|
||||
await writeFile(archivePath, archive)
|
||||
run("tar", ["-xzf", archivePath, "-C", temporary])
|
||||
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
|
||||
const bytes = await readFile(source)
|
||||
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
|
||||
await rename(staged, executable).catch(async (error) => {
|
||||
await rm(staged, { force: true })
|
||||
if (!(await readFile(executable).catch(() => undefined))) throw error
|
||||
})
|
||||
const installed = await readFile(executable)
|
||||
return {
|
||||
source: executable,
|
||||
version: VERSION,
|
||||
sha256: createHash("sha256").update(installed).digest("hex"),
|
||||
}
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function targetName(target: Target): keyof typeof SHA256 | undefined {
|
||||
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
|
||||
if (!arch) return undefined
|
||||
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
||||
if (target.platform === "linux" && target.libc === "musl")
|
||||
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
|
||||
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function run(command: string, args: readonly string[]) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" })
|
||||
if (result.error) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Argument, Flag } from "effect/unstable/cli"
|
||||
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
|
||||
import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
})
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const ServerParams = {
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
const transport = yield* Service.ensure(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.stop,
|
||||
Effect.fn("cli.service.stop")(function* () {
|
||||
yield* Service.stop(yield* ServiceConfig.options())
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { PrintLogs } from "../commands/commands"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Updater } from "../services/updater"
|
||||
@@ -77,7 +78,11 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
|
||||
}
|
||||
|
||||
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
|
||||
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
|
||||
return Command.run(provide(commands, handlers).pipe(Command.withGlobalFlags([PrintLogs])), options) as Effect.Effect<
|
||||
void,
|
||||
unknown,
|
||||
Command.Environment
|
||||
>
|
||||
}
|
||||
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
@@ -86,6 +91,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
if (yield* PrintLogs) process.env.OPENCODE_PRINT_LOGS = "1"
|
||||
const module = yield* Effect.promise(handler.load)
|
||||
return yield* module.default(input)
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
|
||||
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
|
||||
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
|
||||
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
|
||||
|
||||
return {
|
||||
platform: targetPlatform,
|
||||
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
|
||||
fffFfiPackage,
|
||||
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
|
||||
opencodePtyAsset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,22 @@ function managedService(options: EnsureOptions) {
|
||||
reconnect: () => Service.ensure(reconnectOptions),
|
||||
restart: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
yield* Service.ensure(reconnectOptions)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
|
||||
options: EnsureOptions,
|
||||
) {
|
||||
const endpoint = yield* Service.discover({ ...options, version: undefined })
|
||||
if (!endpoint) return
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* Effect.tryPromise(() => client["server.persistentPty"].shutdown())
|
||||
})
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
|
||||
if (mismatch === "replace") return yield* Service.ensure(options)
|
||||
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
||||
|
||||
@@ -27,7 +27,7 @@ function command(password: string, options: Options) {
|
||||
// The server treats EOF on this pipe as the end of its ownership lease.
|
||||
// The OS closes it even when the TUI is killed before Effect finalizers run.
|
||||
stdin: "pipe",
|
||||
stderr: "ignore",
|
||||
stderr: process.env.OPENCODE_PRINT_LOGS === "1" ? "inherit" : "ignore",
|
||||
killSignal: "SIGTERM",
|
||||
forceKillAfter: "3 seconds",
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
|
||||
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
|
||||
input.target.platform === "darwin"
|
||||
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
|
||||
: undefined
|
||||
const opencodePtyAsset = input.target.opencodePtyAsset
|
||||
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Agent = sdk.Agent
|
||||
@@ -200,6 +201,7 @@ if (__ocIsSea()) {
|
||||
const __ocAssetRoot = __ocIsSea()
|
||||
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
|
||||
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
|
||||
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
|
||||
if (__ocIsSea()) {
|
||||
for (const __ocKey of __ocAssetKeys()) {
|
||||
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
|
||||
@@ -216,6 +218,7 @@ if (__ocIsSea()) {
|
||||
}
|
||||
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
|
||||
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
|
||||
if (__ocPersistentPty && process.platform !== "win32") __ocChmod(__ocPath.join(__ocAssetRoot, __ocPersistentPty), 0o755)
|
||||
}
|
||||
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
|
||||
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
|
||||
try {
|
||||
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
|
||||
} catch {}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -1590,6 +1586,219 @@ export interface PtyApi<E = never> {
|
||||
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupListOutput = ReadonlyArray<{
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}>
|
||||
export type ServerPersistentPtyGroupListOperation<E = never> = () => Effect.Effect<
|
||||
ServerPersistentPtyGroupListOutput,
|
||||
E
|
||||
>
|
||||
|
||||
export type ServerPersistentPtyGroupCreateInput = {
|
||||
readonly items?:
|
||||
| ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
| undefined
|
||||
}
|
||||
export type ServerPersistentPtyGroupCreateOutput = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupCreateOperation<E = never> = (
|
||||
input?: ServerPersistentPtyGroupCreateInput,
|
||||
) => Effect.Effect<ServerPersistentPtyGroupCreateOutput, E>
|
||||
|
||||
export type ServerPersistentPtyGroupGetInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type ServerPersistentPtyGroupGetOutput = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupGetOperation<E = never> = (
|
||||
input: ServerPersistentPtyGroupGetInput,
|
||||
) => Effect.Effect<ServerPersistentPtyGroupGetOutput, E>
|
||||
|
||||
export type ServerPersistentPtyGroupSetInput = {
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupSetOutput = {
|
||||
readonly id: string & Brand.Brand<"GroupID">
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: Session.ID } | { readonly type: "terminal"; readonly id: Pty.ID }
|
||||
>
|
||||
}
|
||||
export type ServerPersistentPtyGroupSetOperation<E = never> = (
|
||||
input: ServerPersistentPtyGroupSetInput,
|
||||
) => Effect.Effect<ServerPersistentPtyGroupSetOutput, E>
|
||||
|
||||
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type ServerPersistentPtyGroupRemoveOutput = void
|
||||
export type ServerPersistentPtyGroupRemoveOperation<E = never> = (
|
||||
input: ServerPersistentPtyGroupRemoveInput,
|
||||
) => Effect.Effect<ServerPersistentPtyGroupRemoveOutput, E>
|
||||
|
||||
export type ServerPersistentPtyListInput = { readonly groupID: string & Brand.Brand<"GroupID"> }
|
||||
export type ServerPersistentPtyListOutput = ReadonlyArray<{
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}>
|
||||
export type ServerPersistentPtyListOperation<E = never> = (
|
||||
input: ServerPersistentPtyListInput,
|
||||
) => Effect.Effect<ServerPersistentPtyListOutput, E>
|
||||
|
||||
export type ServerPersistentPtyCreateInput = {
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number } | undefined
|
||||
}
|
||||
export type ServerPersistentPtyCreateOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyCreateOperation<E = never> = (
|
||||
input: ServerPersistentPtyCreateInput,
|
||||
) => Effect.Effect<ServerPersistentPtyCreateOutput, E>
|
||||
|
||||
export type ServerPersistentPtyShutdownOutput = void
|
||||
export type ServerPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<ServerPersistentPtyShutdownOutput, E>
|
||||
|
||||
export type ServerPersistentPtyGetInput = { readonly ptyID: Pty.ID }
|
||||
export type ServerPersistentPtyGetOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyGetOperation<E = never> = (
|
||||
input: ServerPersistentPtyGetInput,
|
||||
) => Effect.Effect<ServerPersistentPtyGetOutput, E>
|
||||
|
||||
export type ServerPersistentPtyUpdateInput = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly attachmentID?: string | undefined
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}
|
||||
export type ServerPersistentPtyUpdateOutput = {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type ServerPersistentPtyUpdateOperation<E = never> = (
|
||||
input: ServerPersistentPtyUpdateInput,
|
||||
) => Effect.Effect<ServerPersistentPtyUpdateOutput, E>
|
||||
|
||||
export type ServerPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
|
||||
export type ServerPersistentPtySnapshotOutput = {
|
||||
readonly info: {
|
||||
readonly id: Pty.ID
|
||||
readonly title: string
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly status: "running" | "exited"
|
||||
readonly pid: number
|
||||
readonly exitCode?: number | undefined
|
||||
readonly groupID: string & Brand.Brand<"GroupID">
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
readonly text: string
|
||||
readonly checkpoint: globalThis.Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
export type ServerPersistentPtySnapshotOperation<E = never> = (
|
||||
input: ServerPersistentPtySnapshotInput,
|
||||
) => Effect.Effect<ServerPersistentPtySnapshotOutput, E>
|
||||
|
||||
export type ServerPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
|
||||
export type ServerPersistentPtyRemoveOutput = void
|
||||
export type ServerPersistentPtyRemoveOperation<E = never> = (
|
||||
input: ServerPersistentPtyRemoveInput,
|
||||
) => Effect.Effect<ServerPersistentPtyRemoveOutput, E>
|
||||
|
||||
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: Pty.ID }
|
||||
export type ServerPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
|
||||
export type ServerPersistentPtyConnectTokenOperation<E = never> = (
|
||||
input: ServerPersistentPtyConnectTokenInput,
|
||||
) => Effect.Effect<ServerPersistentPtyConnectTokenOutput, E>
|
||||
|
||||
export type ServerPersistentPtyConnectInput = { readonly ptyID: Pty.ID }
|
||||
export type ServerPersistentPtyConnectOutput = boolean
|
||||
export type ServerPersistentPtyConnectOperation<E = never> = (
|
||||
input: ServerPersistentPtyConnectInput,
|
||||
) => Effect.Effect<ServerPersistentPtyConnectOutput, E>
|
||||
|
||||
export interface ServerPersistentPtyApi<E = never> {
|
||||
readonly group: {
|
||||
readonly list: ServerPersistentPtyGroupListOperation<E>
|
||||
readonly create: ServerPersistentPtyGroupCreateOperation<E>
|
||||
readonly get: ServerPersistentPtyGroupGetOperation<E>
|
||||
readonly set: ServerPersistentPtyGroupSetOperation<E>
|
||||
readonly remove: ServerPersistentPtyGroupRemoveOperation<E>
|
||||
}
|
||||
readonly list: ServerPersistentPtyListOperation<E>
|
||||
readonly create: ServerPersistentPtyCreateOperation<E>
|
||||
readonly shutdown: ServerPersistentPtyShutdownOperation<E>
|
||||
readonly get: ServerPersistentPtyGetOperation<E>
|
||||
readonly update: ServerPersistentPtyUpdateOperation<E>
|
||||
readonly snapshot: ServerPersistentPtySnapshotOperation<E>
|
||||
readonly remove: ServerPersistentPtyRemoveOperation<E>
|
||||
readonly connectToken: ServerPersistentPtyConnectTokenOperation<E>
|
||||
readonly connect: ServerPersistentPtyConnectOperation<E>
|
||||
}
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1699,6 +1908,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
|
||||
}
|
||||
@@ -1813,9 +2032,11 @@ export interface AppApi<E = never> {
|
||||
readonly skill: SkillApi<E>
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly "server.persistentPty": ServerPersistentPtyApi<E>
|
||||
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>
|
||||
|
||||
@@ -192,6 +192,32 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ServerPersistentPtyGroupListOutput,
|
||||
ServerPersistentPtyGroupCreateInput,
|
||||
ServerPersistentPtyGroupCreateOutput,
|
||||
ServerPersistentPtyGroupGetInput,
|
||||
ServerPersistentPtyGroupGetOutput,
|
||||
ServerPersistentPtyGroupSetInput,
|
||||
ServerPersistentPtyGroupSetOutput,
|
||||
ServerPersistentPtyGroupRemoveInput,
|
||||
ServerPersistentPtyGroupRemoveOutput,
|
||||
ServerPersistentPtyListInput,
|
||||
ServerPersistentPtyListOutput,
|
||||
ServerPersistentPtyCreateInput,
|
||||
ServerPersistentPtyCreateOutput,
|
||||
ServerPersistentPtyShutdownOutput,
|
||||
ServerPersistentPtyGetInput,
|
||||
ServerPersistentPtyGetOutput,
|
||||
ServerPersistentPtyUpdateInput,
|
||||
ServerPersistentPtyUpdateOutput,
|
||||
ServerPersistentPtySnapshotInput,
|
||||
ServerPersistentPtySnapshotOutput,
|
||||
ServerPersistentPtyRemoveInput,
|
||||
ServerPersistentPtyRemoveOutput,
|
||||
ServerPersistentPtyConnectTokenInput,
|
||||
ServerPersistentPtyConnectTokenOutput,
|
||||
ServerPersistentPtyConnectInput,
|
||||
ServerPersistentPtyConnectOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
@@ -214,6 +240,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -440,21 +468,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) =>
|
||||
@@ -1185,6 +1206,155 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
|
||||
connect: { token: EndpointPtyConnectToken(raw) },
|
||||
})
|
||||
|
||||
const EndpointServerPersistentPtyGroupList = (raw: RawClient["server.persistentPty"]) => () =>
|
||||
preserveEffect<ServerPersistentPtyGroupListOutput>()(
|
||||
raw["persistentPty.group.list"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyGroupCreate =
|
||||
(raw: RawClient["server.persistentPty"]) => (input?: ServerPersistentPtyGroupCreateInput) =>
|
||||
preserveEffect<ServerPersistentPtyGroupCreateOutput>()(
|
||||
raw["persistentPty.group.create"]({ payload: { items: input?.["items"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyGroupGet =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupGetInput) =>
|
||||
preserveEffect<ServerPersistentPtyGroupGetOutput>()(
|
||||
raw["persistentPty.group.get"]({ params: { groupID: input["groupID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyGroupSet =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupSetInput) =>
|
||||
preserveEffect<ServerPersistentPtyGroupSetOutput>()(
|
||||
raw["persistentPty.group.set"]({
|
||||
params: { groupID: input["groupID"] },
|
||||
payload: { items: input["items"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyGroupRemove =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGroupRemoveInput) =>
|
||||
preserveEffect<ServerPersistentPtyGroupRemoveOutput>()(
|
||||
raw["persistentPty.group.remove"]({ params: { groupID: input["groupID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyList =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyListInput) =>
|
||||
preserveEffect<ServerPersistentPtyListOutput>()(
|
||||
raw["persistentPty.list"]({ params: { groupID: input["groupID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyCreate =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyCreateInput) =>
|
||||
preserveEffect<ServerPersistentPtyCreateOutput>()(
|
||||
raw["persistentPty.create"]({
|
||||
params: { groupID: input["groupID"] },
|
||||
payload: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyShutdown = (raw: RawClient["server.persistentPty"]) => () =>
|
||||
preserveEffect<ServerPersistentPtyShutdownOutput>()(
|
||||
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyGet =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyGetInput) =>
|
||||
preserveEffect<ServerPersistentPtyGetOutput>()(
|
||||
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyUpdate =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyUpdateInput) =>
|
||||
preserveEffect<ServerPersistentPtyUpdateOutput>()(
|
||||
raw["persistentPty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
payload: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtySnapshot =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtySnapshotInput) =>
|
||||
preserveEffect<ServerPersistentPtySnapshotOutput>()(
|
||||
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyRemove =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyRemoveInput) =>
|
||||
preserveEffect<ServerPersistentPtyRemoveOutput>()(
|
||||
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyConnectToken =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyConnectTokenInput) =>
|
||||
preserveEffect<ServerPersistentPtyConnectTokenOutput>()(
|
||||
raw["persistentPty.connectToken"]({ params: { ptyID: input["ptyID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointServerPersistentPtyConnect =
|
||||
(raw: RawClient["server.persistentPty"]) => (input: ServerPersistentPtyConnectInput) =>
|
||||
preserveEffect<ServerPersistentPtyConnectOutput>()(
|
||||
raw["persistentPty.connect"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupServerPersistentPty = (raw: RawClient["server.persistentPty"]) => ({
|
||||
group: {
|
||||
list: EndpointServerPersistentPtyGroupList(raw),
|
||||
create: EndpointServerPersistentPtyGroupCreate(raw),
|
||||
get: EndpointServerPersistentPtyGroupGet(raw),
|
||||
set: EndpointServerPersistentPtyGroupSet(raw),
|
||||
remove: EndpointServerPersistentPtyGroupRemove(raw),
|
||||
},
|
||||
list: EndpointServerPersistentPtyList(raw),
|
||||
create: EndpointServerPersistentPtyCreate(raw),
|
||||
shutdown: EndpointServerPersistentPtyShutdown(raw),
|
||||
get: EndpointServerPersistentPtyGet(raw),
|
||||
update: EndpointServerPersistentPtyUpdate(raw),
|
||||
snapshot: EndpointServerPersistentPtySnapshot(raw),
|
||||
remove: EndpointServerPersistentPtyRemove(raw),
|
||||
connectToken: EndpointServerPersistentPtyConnectToken(raw),
|
||||
connect: EndpointServerPersistentPtyConnect(raw),
|
||||
})
|
||||
|
||||
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
|
||||
preserveEffect<ShellListOutput>()(
|
||||
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1278,6 +1448,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)),
|
||||
@@ -1365,9 +1542,11 @@ const adaptClient = (raw: RawClient) => ({
|
||||
skill: adaptGroupSkill(raw["server.skill"]),
|
||||
event: adaptGroupEvent(raw["server.event"]),
|
||||
pty: adaptGroupPty(raw["server.pty"]),
|
||||
"server.persistentPty": adaptGroupServerPersistentPty(raw["server.persistentPty"]),
|
||||
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"]),
|
||||
|
||||
@@ -188,6 +188,32 @@ import type {
|
||||
PtyRemoveOutput,
|
||||
PtyConnectTokenInput,
|
||||
PtyConnectTokenOutput,
|
||||
ServerPersistentPtyGroupListOutput,
|
||||
ServerPersistentPtyGroupCreateInput,
|
||||
ServerPersistentPtyGroupCreateOutput,
|
||||
ServerPersistentPtyGroupGetInput,
|
||||
ServerPersistentPtyGroupGetOutput,
|
||||
ServerPersistentPtyGroupSetInput,
|
||||
ServerPersistentPtyGroupSetOutput,
|
||||
ServerPersistentPtyGroupRemoveInput,
|
||||
ServerPersistentPtyGroupRemoveOutput,
|
||||
ServerPersistentPtyListInput,
|
||||
ServerPersistentPtyListOutput,
|
||||
ServerPersistentPtyCreateInput,
|
||||
ServerPersistentPtyCreateOutput,
|
||||
ServerPersistentPtyShutdownOutput,
|
||||
ServerPersistentPtyGetInput,
|
||||
ServerPersistentPtyGetOutput,
|
||||
ServerPersistentPtyUpdateInput,
|
||||
ServerPersistentPtyUpdateOutput,
|
||||
ServerPersistentPtySnapshotInput,
|
||||
ServerPersistentPtySnapshotOutput,
|
||||
ServerPersistentPtyRemoveInput,
|
||||
ServerPersistentPtyRemoveOutput,
|
||||
ServerPersistentPtyConnectTokenInput,
|
||||
ServerPersistentPtyConnectTokenOutput,
|
||||
ServerPersistentPtyConnectInput,
|
||||
ServerPersistentPtyConnectOutput,
|
||||
ShellListInput,
|
||||
ShellListOutput,
|
||||
ShellCreateInput,
|
||||
@@ -210,6 +236,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
WorkspaceDestroyInput,
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -631,28 +659,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>(
|
||||
{
|
||||
@@ -1622,6 +1646,175 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
"server.persistentPty": {
|
||||
group: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input?: ServerPersistentPtyGroupCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty-group`,
|
||||
body: { items: input?.["items"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
get: (input: ServerPersistentPtyGroupGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
set: (input: ServerPersistentPtyGroupSetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGroupSetOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
body: { items: input["items"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ServerPersistentPtyGroupRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyGroupRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
list: (input: ServerPersistentPtyListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input: ServerPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/pty-group/${encodeURIComponent(input.groupID)}/terminal`,
|
||||
body: {
|
||||
command: input["command"],
|
||||
args: input["args"],
|
||||
cwd: input["cwd"],
|
||||
title: input["title"],
|
||||
env: input["env"],
|
||||
size: input["size"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
shutdown: (requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyShutdownOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/persistent-pty/shutdown`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: ServerPersistentPtyGetInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyGetOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
update: (input: ServerPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyUpdateOutput }>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
body: { attachmentID: input["attachmentID"], size: input["size"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: ServerPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtySnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: ServerPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
connectToken: (input: ServerPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: ServerPersistentPtyConnectTokenOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
connect: (input: ServerPersistentPtyConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerPersistentPtyConnectOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [403, 404, 503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
shell: {
|
||||
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
|
||||
request<ShellListOutput>(
|
||||
@@ -1770,6 +1963,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>(
|
||||
|
||||
@@ -311,6 +311,8 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin
|
||||
|
||||
export type FileSystemEntry = { path: string; type: "file" | "directory" }
|
||||
|
||||
export type CommandInfo = { name: string; description?: string }
|
||||
|
||||
export type SkillInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -338,6 +340,8 @@ export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
|
||||
export type GroupItem = { type: "session"; id: string } | { type: "terminal"; id: string }
|
||||
|
||||
export type SessionStatus =
|
||||
| { type: "idle" }
|
||||
| {
|
||||
@@ -351,6 +355,21 @@ export type SessionStatus =
|
||||
|
||||
export type PtyTicketConnectToken = { ticket: string; expires_in: number }
|
||||
|
||||
export type PersistentPtyInfo = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
args: Array<string>
|
||||
cwd: string
|
||||
status: "running" | "exited"
|
||||
pid: number
|
||||
exitCode?: number
|
||||
groupID: string
|
||||
foregroundProcess: string | null
|
||||
size: { cols: number; rows: number }
|
||||
output: { head: number; tail: number }
|
||||
}
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -378,6 +397,8 @@ export type WorktreeDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type WorktreeInfo = { directory: string }
|
||||
|
||||
export type WorkspaceDestroyResult = { destroyed: boolean }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
@@ -391,15 +412,6 @@ export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type CommandInfo = {
|
||||
name: string
|
||||
template: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
@@ -1505,6 +1517,26 @@ export type FormMultiselectField1 = {
|
||||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type GroupItemAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.added"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
}
|
||||
|
||||
export type GroupItemRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "group.item.removed"
|
||||
location?: LocationRef
|
||||
data: { groupID: string; item: GroupItem }
|
||||
}
|
||||
|
||||
export type GroupInfo = { id: string; items: Array<GroupItem> }
|
||||
|
||||
export type SessionStatusUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1514,6 +1546,13 @@ export type SessionStatusUpdated = {
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
|
||||
export type PersistentPtySnapshot = {
|
||||
info: PersistentPtyInfo
|
||||
text: string
|
||||
checkpoint: string
|
||||
cursor: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type WorktreeList = Array<WorktreeDirectory>
|
||||
@@ -2140,6 +2179,8 @@ export type V2Event =
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| GroupItemAdded
|
||||
| GroupItemRemoved
|
||||
| WebsearchUpdated
|
||||
| SessionStatusUpdated
|
||||
| SessionIdle
|
||||
@@ -2220,13 +2261,13 @@ export type CommandNotFoundError = {
|
||||
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
|
||||
|
||||
export type CommandEvaluationError = {
|
||||
readonly _tag: "CommandEvaluationError"
|
||||
export type CommandExecutionError = {
|
||||
readonly _tag: "CommandExecutionError"
|
||||
readonly command: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError"
|
||||
export const isCommandExecutionError = (value: unknown): value is CommandExecutionError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError"
|
||||
|
||||
export type SkillNotFoundError = {
|
||||
readonly _tag: "SkillNotFoundError"
|
||||
@@ -3637,35 +3678,9 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"]
|
||||
|
||||
export type SessionCommandInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["id"]
|
||||
readonly command: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3681,14 +3696,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["command"]
|
||||
readonly arguments?: {
|
||||
readonly id?: string | null
|
||||
readonly text: {
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3704,60 +3715,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["arguments"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["model"]
|
||||
}["text"]
|
||||
readonly files?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3773,14 +3734,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["files"]
|
||||
readonly agents?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3796,14 +3753,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["agents"]
|
||||
readonly skills?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3819,14 +3772,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["skills"]
|
||||
readonly delivery?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3842,34 +3791,10 @@ export type SessionCommandInput = {
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["delivery"]
|
||||
readonly resume?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: ("steer" | "queue") | null
|
||||
readonly resume?: boolean | null
|
||||
}["resume"]
|
||||
}
|
||||
|
||||
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
|
||||
export type SessionCommandOutput = void
|
||||
|
||||
export type SessionSkillInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
@@ -5591,6 +5516,133 @@ export type PtyConnectTokenOutput = {
|
||||
data: PtyTicketConnectToken
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupListOutput = { data: Array<GroupInfo> }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupCreateInput = {
|
||||
readonly items?: {
|
||||
readonly items?:
|
||||
| ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
|
||||
>
|
||||
| undefined
|
||||
}["items"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupCreateOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupGetInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyGroupGetOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupSetInput = {
|
||||
readonly groupID: { readonly groupID: string }["groupID"]
|
||||
readonly items: {
|
||||
readonly items: ReadonlyArray<
|
||||
{ readonly type: "session"; readonly id: string } | { readonly type: "terminal"; readonly id: string }
|
||||
>
|
||||
}["items"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyGroupSetOutput = { data: GroupInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyGroupRemoveInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyGroupRemoveOutput = void
|
||||
|
||||
export type ServerPersistentPtyListInput = { readonly groupID: { readonly groupID: string }["groupID"] }
|
||||
|
||||
export type ServerPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
|
||||
|
||||
export type ServerPersistentPtyCreateInput = {
|
||||
readonly groupID: { readonly groupID: string }["groupID"]
|
||||
readonly command: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["command"]
|
||||
readonly args: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["args"]
|
||||
readonly cwd: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["cwd"]
|
||||
readonly title: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["title"]
|
||||
readonly env: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["env"]
|
||||
readonly size?: {
|
||||
readonly command: string
|
||||
readonly args: ReadonlyArray<string>
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: { readonly [x: string]: string }
|
||||
readonly size?: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyShutdownOutput = void
|
||||
|
||||
export type ServerPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtyUpdateInput = {
|
||||
readonly ptyID: { readonly ptyID: string }["ptyID"]
|
||||
readonly attachmentID?: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["attachmentID"]
|
||||
readonly size: {
|
||||
readonly attachmentID?: string
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
}["size"]
|
||||
}
|
||||
|
||||
export type ServerPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
|
||||
|
||||
export type ServerPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
|
||||
|
||||
export type ServerPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyRemoveOutput = void
|
||||
|
||||
export type ServerPersistentPtyConnectTokenInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
|
||||
|
||||
export type ServerPersistentPtyConnectInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
|
||||
|
||||
export type ServerPersistentPtyConnectOutput = boolean
|
||||
|
||||
export type ShellListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5752,6 +5804,10 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspaceID: string }["workspaceID"] }
|
||||
|
||||
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#persistent-pty-binary": {
|
||||
"workerd": "./src/persistent-pty/binary.workerd.ts",
|
||||
"bun": "./src/persistent-pty/binary.bun.ts",
|
||||
"node": "./src/persistent-pty/binary.node.ts",
|
||||
"default": "./src/persistent-pty/binary.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
|
||||
@@ -32,3 +32,38 @@ const result = await Bun.build({
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
|
||||
|
||||
// Bun's Node target eagerly creates its shared require helper, so every split
|
||||
// entry evaluates import.meta.url even when it never requires a module. Keep
|
||||
// the helper lazy until Bun stops hoisting it into workerd-reachable chunks.
|
||||
// https://github.com/oven-sh/bun/issues/12615
|
||||
const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);"
|
||||
const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier);
|
||||
__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);`
|
||||
const rewritten = await Promise.all(
|
||||
result.outputs.map(async (output) => {
|
||||
if (!output.path.endsWith(".js")) return false
|
||||
const source = await output.text()
|
||||
|
||||
const generatedUses = source
|
||||
.replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "")
|
||||
.replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "")
|
||||
.replace(eagerRequire, "")
|
||||
if (/\bnew\s+__require\s*\(/.test(generatedUses))
|
||||
throw new Error(`Unsupported generated require constructor in ${output.path}`)
|
||||
const unsupported = generatedUses
|
||||
.replace(/\b__require\.resolve\s*\(/g, "")
|
||||
.replace(/\b__require\s*\(/g, "")
|
||||
if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`)
|
||||
|
||||
if (!source.includes(eagerRequire)) return false
|
||||
if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire))
|
||||
throw new Error(`Multiple eager require helpers in ${output.path}`)
|
||||
const rewrittenSource = source.replace(eagerRequire, lazyRequire)
|
||||
if (rewrittenSource.includes(eagerRequire)) throw new Error(`Failed to rewrite eager require helper in ${output.path}`)
|
||||
await Bun.write(output.path, rewrittenSource)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (rewritten.filter(Boolean).length !== 1)
|
||||
throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable")
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -572,7 +572,7 @@ function cacheKey(source: string) {
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return new Bun.CryptoHasher("sha256").update(text).digest("hex")
|
||||
return Hash.sha256(text)
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PersistentPty } from "./persistent-pty/index.js"
|
||||
export { Group } from "./persistent-pty/group.js"
|
||||
@@ -0,0 +1,3 @@
|
||||
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
|
||||
|
||||
export default asset
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import asset from "./asset.js"
|
||||
|
||||
export async function resolveBinary(bin: string) {
|
||||
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
|
||||
if (!asset) return "opencode-pty"
|
||||
return install(bin, asset)
|
||||
}
|
||||
|
||||
export async function install(
|
||||
bin: string,
|
||||
input: { readonly path: string; readonly version: string; readonly sha256: string },
|
||||
) {
|
||||
const root = path.join(bin, "opencode-pty")
|
||||
await privateDirectory(root)
|
||||
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
|
||||
await privateDirectory(directory)
|
||||
const destination = path.join(directory, "opencode-pty")
|
||||
if (await exists(destination, input.sha256)) return destination
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
|
||||
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
|
||||
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
|
||||
try {
|
||||
const file = await open(temporary, "wx", 0o700)
|
||||
try {
|
||||
await file.writeFile(bytes)
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
await chmod(temporary, 0o755)
|
||||
await rename(temporary, destination).catch(async (error) => {
|
||||
if (!(await exists(destination, input.sha256))) throw error
|
||||
})
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
return validate(destination, input.sha256)
|
||||
}
|
||||
|
||||
async function privateDirectory(directory: string) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
const info = await lstat(directory)
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
|
||||
await chmod(directory, 0o700)
|
||||
}
|
||||
|
||||
async function exists(file: string, expected: string) {
|
||||
try {
|
||||
await validate(file, expected)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(file: string, expected?: string) {
|
||||
const info = await lstat(file)
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
||||
if (uid !== undefined && info.uid !== uid)
|
||||
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
|
||||
if (expected && sha256(await readFile(file)) !== expected)
|
||||
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
|
||||
await chmod(file, 0o755)
|
||||
return file
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex")
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function resolveBinary() {
|
||||
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function resolveBinary(): Promise<string> {
|
||||
throw new Error("Persistent PTYs are unavailable in this runtime")
|
||||
}
|
||||
@@ -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] })
|
||||
@@ -0,0 +1,754 @@
|
||||
export * as PersistentPty from "./index.js"
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Group } from "./group.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { resolveBinary } from "#persistent-pty-binary"
|
||||
|
||||
const ProtocolVersion = 6
|
||||
const MaxFrameBytes = 8 * 1024 * 1024
|
||||
|
||||
const Lifecycle = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("running") }),
|
||||
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
|
||||
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
|
||||
])
|
||||
|
||||
const WireTerminal = Schema.Struct({
|
||||
id: Schema.Number,
|
||||
pid: Schema.NullOr(Schema.Number),
|
||||
title: Schema.String,
|
||||
foreground_process: Schema.NullOr(Schema.String),
|
||||
group_id: Schema.String,
|
||||
command: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
lifecycle: Lifecycle,
|
||||
output_head: Schema.Number,
|
||||
output_tail: Schema.Number,
|
||||
})
|
||||
|
||||
const Registration = Schema.Struct({
|
||||
instance_id: Schema.String,
|
||||
pid: Schema.Number,
|
||||
protocol: Schema.Number,
|
||||
socket: Schema.String,
|
||||
token: Schema.String,
|
||||
})
|
||||
|
||||
const Response = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("pong"),
|
||||
instance_id: Schema.String,
|
||||
pid: Schema.Number,
|
||||
protocol: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
|
||||
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
|
||||
Schema.Struct({ type: Schema.Literal("ok") }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
terminal: WireTerminal,
|
||||
text: Schema.String,
|
||||
checkpoint_base64: Schema.String,
|
||||
cursor_x: Schema.Number,
|
||||
cursor_y: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("attached"),
|
||||
terminal: WireTerminal,
|
||||
role: Schema.Literals(["controller", "observer"]),
|
||||
generation: Schema.Number,
|
||||
requested_offset: Schema.Number,
|
||||
available_offset: Schema.Number,
|
||||
end_offset: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
replay_base64: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("resized"),
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
generation: Schema.Number,
|
||||
checkpoint_base64: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("exited"),
|
||||
exit_code: Schema.NullOr(Schema.Number),
|
||||
final_offset: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("controller_changed"),
|
||||
attachment_id: Schema.NullOr(Schema.String),
|
||||
generation: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("title_changed"), title: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("foreground_process_changed"), process: Schema.NullOr(Schema.String) }),
|
||||
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
|
||||
])
|
||||
|
||||
type WireTerminal = typeof WireTerminal.Type
|
||||
type WireResponse = typeof Response.Type
|
||||
type Registration = typeof Registration.Type
|
||||
|
||||
export type Role = "controller" | "observer"
|
||||
|
||||
export type Info = Pty.Info & {
|
||||
readonly groupID: Group.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
|
||||
| {
|
||||
readonly type: "resized"
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
readonly generation: number
|
||||
readonly checkpoint: Uint8Array
|
||||
}
|
||||
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
|
||||
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
|
||||
| { readonly type: "title_changed"; readonly title: string }
|
||||
| { readonly type: "foreground_process_changed"; readonly process: string | null }
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
readonly role: Role
|
||||
readonly generation: number
|
||||
readonly replay: {
|
||||
readonly requestedOffset: number
|
||||
readonly availableOffset: number
|
||||
readonly endOffset: number
|
||||
readonly truncated: boolean
|
||||
readonly data: Uint8Array
|
||||
}
|
||||
readonly activate: () => void
|
||||
readonly detach: () => void
|
||||
}
|
||||
|
||||
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", {
|
||||
ptyID: Pty.ID,
|
||||
}) {}
|
||||
|
||||
export class GroupNotFoundError extends Schema.TaggedError<GroupNotFoundError>()(
|
||||
"PersistentPty.GroupNotFoundError",
|
||||
{ groupID: Group.ID },
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (groupID?: Group.ID) => Effect.Effect<Info[], UnavailableError>
|
||||
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
|
||||
readonly create: (
|
||||
groupID: Group.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) => Effect.Effect<Info, GroupNotFoundError | UnavailableError>
|
||||
readonly write: (
|
||||
id: Pty.ID,
|
||||
data: string,
|
||||
attachmentID?: string,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly resize: (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly control: (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly input: (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
|
||||
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
|
||||
readonly shutdown: () => Effect.Effect<void, UnavailableError>
|
||||
readonly attach: (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const groups = yield* Group.Service
|
||||
const database = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
let binary: Promise<string> | undefined
|
||||
const client = new Client(runtimeDirectory(databasePath(database.db)), () => (binary ??= resolveBinary(global.bin)))
|
||||
const removing = new Set<Pty.ID>()
|
||||
|
||||
const list = Effect.fn("PersistentPty.list")(function* (groupID?: Group.ID) {
|
||||
const response = yield* optionalRequest(client, { op: "list" })
|
||||
if (!response) return []
|
||||
if (response.type !== "terminals") return yield* unexpected(response)
|
||||
return response.terminals
|
||||
.map(toInfo)
|
||||
.filter((terminal) => groupID === undefined || terminal.groupID === groupID)
|
||||
})
|
||||
|
||||
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
|
||||
const found = (yield* list()).find((terminal) => terminal.id === id)
|
||||
if (!found) return yield* new NotFoundError({ ptyID: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
groupID: Group.ID,
|
||||
input: {
|
||||
readonly command: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const group = yield* groups.get(groupID)
|
||||
if (!group) return yield* new GroupNotFoundError({ groupID })
|
||||
const response = yield* request(client, {
|
||||
op: "create",
|
||||
program: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
group_id: groupID,
|
||||
env: input.env,
|
||||
cols: input.cols ?? 80,
|
||||
rows: input.rows ?? 24,
|
||||
}, true)
|
||||
if (response.type !== "created") return yield* unexpected(response)
|
||||
const terminal = toInfo(response.terminal)
|
||||
yield* groups.set(
|
||||
Group.Info.make({
|
||||
id: group.id,
|
||||
items: group.items.concat({ type: "terminal", id: terminal.id }),
|
||||
}),
|
||||
)
|
||||
return terminal
|
||||
})
|
||||
|
||||
const write = Effect.fn("PersistentPty.write")(function* (
|
||||
id: Pty.ID,
|
||||
data: string,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "write",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resize = Effect.fn("PersistentPty.resize")(function* (
|
||||
id: Pty.ID,
|
||||
cols: number,
|
||||
rows: number,
|
||||
attachmentID?: string,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "resize",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID ?? null,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const control = Effect.fn("PersistentPty.control")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "control",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const input = Effect.fn("PersistentPty.input")(function* (
|
||||
id: Pty.ID,
|
||||
attachmentID: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
data: Uint8Array,
|
||||
) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, {
|
||||
op: "input",
|
||||
id: fromID(id),
|
||||
attachment_id: attachmentID,
|
||||
cols,
|
||||
rows,
|
||||
data_base64: Buffer.from(data).toString("base64"),
|
||||
})
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
|
||||
yield* get(id)
|
||||
const response = yield* request(client, { op: "snapshot", id: fromID(id) })
|
||||
if (response.type !== "snapshot") return yield* unexpected(response)
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
text: response.text,
|
||||
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
|
||||
cursor: { x: response.cursor_x, y: response.cursor_y },
|
||||
}
|
||||
})
|
||||
|
||||
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
|
||||
const terminal = yield* get(id)
|
||||
const response = yield* request(client, { op: "terminate", id: fromID(id) })
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
const group = yield* groups.get(terminal.groupID)
|
||||
if (!group) return undefined
|
||||
yield* groups.set(
|
||||
Group.Info.make({
|
||||
id: group.id,
|
||||
items: group.items.filter((item) => item.type !== "terminal" || item.id !== id),
|
||||
}),
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
|
||||
const response = yield* Effect.tryPromise({ try: () => client.shutdown(), catch: unavailable })
|
||||
if (!response) return
|
||||
if (response.type !== "ok") return yield* unexpected(response)
|
||||
})
|
||||
|
||||
const removeVisibleExit = (id: Pty.ID) => {
|
||||
if (removing.has(id)) return
|
||||
removing.add(id)
|
||||
runFork(
|
||||
remove(id).pipe(
|
||||
Effect.catchTags({
|
||||
"PersistentPty.NotFoundError": () => Effect.void,
|
||||
"PersistentPty.UnavailableError": (error) =>
|
||||
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => removing.delete(id))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.subscribe(fromID(id), {
|
||||
...input,
|
||||
onEvent: (event) => {
|
||||
if (event.type === "exited") removeVisibleExit(id)
|
||||
input.onEvent(event)
|
||||
},
|
||||
}),
|
||||
catch: (error) => unavailable(error),
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Group.node, Database.node, Global.node] })
|
||||
|
||||
class Client {
|
||||
private registration?: Promise<Registration>
|
||||
|
||||
constructor(
|
||||
private readonly directory: string,
|
||||
private readonly binary: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
request(value: object, start = false): Promise<WireResponse> {
|
||||
return this.connect(start)
|
||||
.then((registration) => oneShot(registration, value))
|
||||
.catch((error) => {
|
||||
if (!(error instanceof ConnectError)) throw error
|
||||
this.registration = undefined
|
||||
if (!start) throw error
|
||||
return this.connect(true).then((registration) => oneShot(registration, value))
|
||||
})
|
||||
}
|
||||
|
||||
requestIfRunning(value: object) {
|
||||
return this.request(value).catch(() => undefined)
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
const response = await this.requestIfRunning({ op: "shutdown" })
|
||||
this.registration = undefined
|
||||
if (!response) return
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
const running = await discover(this.directory).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!running) return response
|
||||
await setTimeout(50)
|
||||
}
|
||||
throw new Error("opencode-pty did not stop")
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
id: number,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
): Promise<Attachment> {
|
||||
const registration = await this.connect(false)
|
||||
const socket = net.createConnection(registration.socket)
|
||||
const frames = decoder(socket)
|
||||
await connected(socket)
|
||||
socket.write(
|
||||
encode({
|
||||
token: registration.token,
|
||||
request: {
|
||||
op: "subscribe",
|
||||
id,
|
||||
offset: input.cursor,
|
||||
attachment_id: input.attachmentID,
|
||||
role: input.role,
|
||||
takeover: input.takeover ?? false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const initial = await frames.next()
|
||||
if (initial.done) throw new Error("opencode-pty closed before attachment")
|
||||
const response = decode(initial.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
|
||||
let detached = false
|
||||
const pump = async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
if (frame[0] === 0) {
|
||||
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
|
||||
input.onEvent({
|
||||
type: "output",
|
||||
start: Number(frame.readBigUInt64BE(1)),
|
||||
end: Number(frame.readBigUInt64BE(9)),
|
||||
data: frame.subarray(17),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const event = decode(frame)
|
||||
if (event.type === "resized")
|
||||
input.onEvent({
|
||||
type: "resized",
|
||||
cols: event.cols,
|
||||
rows: event.rows,
|
||||
generation: event.generation,
|
||||
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
|
||||
})
|
||||
if (event.type === "controller_changed")
|
||||
input.onEvent({
|
||||
type: "controller_changed",
|
||||
attachmentID: event.attachment_id ?? undefined,
|
||||
generation: event.generation,
|
||||
})
|
||||
if (event.type === "title_changed") input.onEvent({ type: "title_changed", title: event.title })
|
||||
if (event.type === "foreground_process_changed")
|
||||
input.onEvent({ type: "foreground_process_changed", process: event.process })
|
||||
if (event.type === "exited") {
|
||||
input.onEvent({
|
||||
type: "exited",
|
||||
exitCode: event.exit_code ?? undefined,
|
||||
finalOffset: event.final_offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!detached) input.onEnd()
|
||||
}
|
||||
}
|
||||
let activated = false
|
||||
return {
|
||||
info: toInfo(response.terminal),
|
||||
role: response.role,
|
||||
generation: response.generation,
|
||||
replay: {
|
||||
requestedOffset: response.requested_offset,
|
||||
availableOffset: response.available_offset,
|
||||
endOffset: response.end_offset,
|
||||
truncated: response.truncated,
|
||||
data: Buffer.from(response.replay_base64, "base64"),
|
||||
},
|
||||
activate() {
|
||||
if (activated || detached) return
|
||||
activated = true
|
||||
void pump().catch(() => {})
|
||||
},
|
||||
detach() {
|
||||
if (detached) return
|
||||
detached = true
|
||||
socket.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private connect(start: boolean) {
|
||||
this.registration ??= start ? ensure(this.directory, this.binary) : discover(this.directory)
|
||||
return this.registration.catch((error) => {
|
||||
this.registration = undefined
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const request = (client: Client, value: object, start = false) =>
|
||||
Effect.tryPromise({ try: () => client.request(value, start), catch: (error) => unavailable(error) })
|
||||
|
||||
const optionalRequest = (client: Client, value: object) =>
|
||||
Effect.promise(() => client.requestIfRunning(value))
|
||||
|
||||
const unexpected = (response: WireResponse) =>
|
||||
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
|
||||
|
||||
const unavailable = (error: unknown) =>
|
||||
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
function databasePath(db: Database.Interface["db"]) {
|
||||
const client: unknown = db.$client
|
||||
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
|
||||
return undefined
|
||||
const config = client.config
|
||||
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
|
||||
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
|
||||
return path.resolve(config.filename)
|
||||
}
|
||||
|
||||
const runtimeDirectory = (databasePath?: string) => {
|
||||
const root =
|
||||
process.env.OPENCODE_PTY_RUNTIME_DIR ??
|
||||
(process.env.XDG_RUNTIME_DIR
|
||||
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
|
||||
: path.join(
|
||||
os.tmpdir(),
|
||||
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
|
||||
))
|
||||
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
|
||||
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
|
||||
}
|
||||
|
||||
const registrationPath = (directory: string) => path.join(directory, "service.json")
|
||||
|
||||
async function ensure(directory: string, binary: () => Promise<string>) {
|
||||
const found = await discover(directory).catch(() => undefined)
|
||||
if (found) return found
|
||||
const executable = await binary()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(executable, ["daemon"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
})
|
||||
const deadline = Date.now() + 5_000
|
||||
let last: unknown
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await discover(directory)
|
||||
} catch (error) {
|
||||
last = error
|
||||
await setTimeout(50)
|
||||
}
|
||||
}
|
||||
throw last instanceof Error ? last : new Error("opencode-pty did not become ready")
|
||||
}
|
||||
|
||||
async function discover(directory: string) {
|
||||
const registration = Schema.decodeUnknownSync(Registration)(
|
||||
JSON.parse(await readFile(registrationPath(directory), "utf8")),
|
||||
)
|
||||
if (registration.protocol !== ProtocolVersion) throw new Error("opencode-pty protocol mismatch")
|
||||
const response = await oneShot(registration, { op: "ping" })
|
||||
if (
|
||||
response.type !== "pong" ||
|
||||
response.instance_id !== registration.instance_id ||
|
||||
response.pid !== registration.pid ||
|
||||
response.protocol !== ProtocolVersion
|
||||
)
|
||||
throw new Error("opencode-pty registration mismatch")
|
||||
return registration
|
||||
}
|
||||
|
||||
async function oneShot(registration: Registration, request: object) {
|
||||
const socket = net.createConnection(registration.socket)
|
||||
const frames = decoder(socket)
|
||||
await connected(socket).catch((cause) => {
|
||||
socket.destroy()
|
||||
throw new ConnectError(cause)
|
||||
})
|
||||
socket.write(encode({ token: registration.token, request }))
|
||||
const first = await frames.next()
|
||||
socket.end()
|
||||
if (first.done) throw new Error("opencode-pty closed without response")
|
||||
const response = decode(first.value)
|
||||
if (response.type === "error") throw new Error(response.message)
|
||||
return response
|
||||
}
|
||||
|
||||
class ConnectError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
function connected(socket: net.Socket) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve)
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function encode(value: unknown) {
|
||||
const payload = Buffer.from(JSON.stringify(value))
|
||||
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
|
||||
const output = Buffer.allocUnsafe(payload.length + 4)
|
||||
output.writeUInt32BE(payload.length)
|
||||
payload.copy(output, 4)
|
||||
return output
|
||||
}
|
||||
|
||||
async function* decoder(socket: net.Socket) {
|
||||
let pending = Buffer.alloc(0)
|
||||
for await (const value of socket) {
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
|
||||
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
|
||||
while (pending.length >= 4) {
|
||||
const length = pending.readUInt32BE(0)
|
||||
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
|
||||
if (pending.length < length + 4) break
|
||||
yield pending.subarray(4, length + 4)
|
||||
pending = pending.subarray(length + 4)
|
||||
}
|
||||
}
|
||||
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
|
||||
}
|
||||
|
||||
function decode(payload: Uint8Array) {
|
||||
return Schema.decodeUnknownSync(Response)(JSON.parse(Buffer.from(payload).toString("utf8")))
|
||||
}
|
||||
|
||||
function toInfo(value: WireTerminal): Info {
|
||||
const status = value.lifecycle.status
|
||||
return {
|
||||
...Pty.Info.make({
|
||||
id: toID(value.id),
|
||||
title: value.title,
|
||||
command: value.command[0] || "",
|
||||
args: value.command.slice(1),
|
||||
cwd: value.cwd,
|
||||
status: status === "running" ? "running" : "exited",
|
||||
pid: value.pid ?? 0,
|
||||
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
|
||||
}),
|
||||
groupID: Group.ID.make(value.group_id),
|
||||
foregroundProcess: value.foreground_process,
|
||||
size: { cols: value.cols, rows: value.rows },
|
||||
output: { head: value.output_head, tail: value.output_tail },
|
||||
}
|
||||
}
|
||||
|
||||
function toID(value: number) {
|
||||
return Pty.ID.make(`pty_persistent_${value}`)
|
||||
}
|
||||
|
||||
function fromID(value: Pty.ID) {
|
||||
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
|
||||
const parsed = Number(value.slice("pty_persistent_".length))
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
|
||||
return parsed
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * as SessionMessage from "./message.js"
|
||||
export * as SessionMessage from "@opencode-ai/schema/session-message"
|
||||
export * from "@opencode-ai/schema/session-message"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as SessionSchema from "./schema.js"
|
||||
export * as SessionSchema from "@opencode-ai/schema/session"
|
||||
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
|
||||
@@ -49,7 +49,12 @@ type ToolRow = {
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
type ToolSummaryRow = { status: string | null; count: number }
|
||||
type ToolSummaryRow = {
|
||||
calls: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
unfinished: number
|
||||
}
|
||||
|
||||
type ModelAggregate = {
|
||||
model: Model.Ref
|
||||
@@ -188,25 +193,38 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
(range) => {
|
||||
if (toolMode === "summary")
|
||||
return db
|
||||
.all<ToolSummaryRow>(
|
||||
.get<ToolSummaryRow>(
|
||||
sql`
|
||||
SELECT json_extract(content.value, '$.state.status') AS status, count(*) AS count
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id,
|
||||
json_each(message.data, '$.content') AS content
|
||||
WHERE message.type = 'assistant'
|
||||
AND message.time_created >= ${range.from}
|
||||
AND message.time_created < ${range.to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
AND json_extract(content.value, '$.type') = 'tool'
|
||||
${project}
|
||||
GROUP BY status
|
||||
WITH calls AS MATERIALIZED (
|
||||
SELECT json_extract(content.value, '$.state.status') AS status
|
||||
FROM ${SessionMessageTable} AS message
|
||||
JOIN ${SessionTable} AS session ON session.id = message.session_id,
|
||||
json_each(message.data, '$.content') AS content
|
||||
WHERE message.type = 'assistant'
|
||||
AND message.time_created >= ${range.from}
|
||||
AND message.time_created < ${range.to}
|
||||
AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created)
|
||||
AND json_extract(content.value, '$.type') = 'tool'
|
||||
${project}
|
||||
)
|
||||
SELECT
|
||||
count(*) AS calls,
|
||||
count(*) FILTER (WHERE status = 'completed') AS succeeded,
|
||||
count(*) FILTER (WHERE status = 'error') AS failed,
|
||||
count(*) FILTER (WHERE status IS NULL OR status NOT IN ('completed', 'error')) AS unfinished
|
||||
FROM calls
|
||||
`,
|
||||
)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => rows.forEach((row) => addToolStatus(toolTotals, row.status, row.count))),
|
||||
Effect.tap((row) =>
|
||||
Effect.sync(() => {
|
||||
if (!row) return
|
||||
toolTotals.calls += row.calls
|
||||
toolTotals.succeeded += row.succeeded
|
||||
toolTotals.failed += row.failed
|
||||
toolTotals.unfinished += row.unfinished
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createHash } from "node:crypto"
|
||||
import { chmod, lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { install } from "../src/persistent-pty/binary.bun"
|
||||
|
||||
test("installs an embedded persistent PTY executable into a content-addressed private directory", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-binary-test-"))
|
||||
try {
|
||||
const source = path.join(root, "embedded-opencode-pty")
|
||||
const bytes = Buffer.from("test opencode-pty executable")
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex")
|
||||
await writeFile(source, bytes)
|
||||
|
||||
const first = await install(path.join(root, "bin"), { path: source, version: "test", sha256 })
|
||||
const second = await install(path.join(root, "bin"), { path: source, version: "test", sha256 })
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(await readFile(first)).toEqual(bytes)
|
||||
expect((await lstat(first)).mode & 0o777).toBe(0o755)
|
||||
expect((await lstat(path.dirname(first))).mode & 0o777).toBe(0o700)
|
||||
|
||||
await chmod(first, 0o600)
|
||||
expect(await install(path.join(root, "bin"), { path: source, version: "test", sha256 })).toBe(first)
|
||||
expect((await lstat(first)).mode & 0o777).toBe(0o755)
|
||||
|
||||
await writeFile(first, "tampered executable")
|
||||
await expect(install(path.join(root, "bin"), { path: source, version: "test", sha256 })).rejects.toThrow(
|
||||
"Cached opencode-pty checksum mismatch",
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -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" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -113,6 +113,13 @@ describe("SessionStats", () => {
|
||||
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 2)),
|
||||
},
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_pending",
|
||||
name: "pending",
|
||||
state: SessionMessage.ToolStateRunning.make({ status: "running", input: {}, metadata: {} }),
|
||||
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)) },
|
||||
}),
|
||||
]),
|
||||
),
|
||||
messageRow(childID, 1, assistant("msg_stats_child", Date.UTC(2026, 0, 3, 10), [], "large", 2)),
|
||||
@@ -210,7 +217,7 @@ describe("SessionStats", () => {
|
||||
expect(stats.cost).toBe(Money.USD.make(6.25))
|
||||
expect(stats.tools).toMatchObject({
|
||||
mode: "detail",
|
||||
totals: { calls: 2, succeeded: 1, failed: 1, unfinished: 0 },
|
||||
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
|
||||
})
|
||||
expect(stats.activity).toEqual([
|
||||
{ date: "2026-01-02", steps: 1 },
|
||||
@@ -224,6 +231,7 @@ describe("SessionStats", () => {
|
||||
expect(stats.tools.usage).toMatchObject([
|
||||
{ name: "read", calls: 1, succeeded: 1, failed: 0, durationP50: 250 },
|
||||
{ name: "edit", calls: 1, succeeded: 0, failed: 1, durationP50: 2_000 },
|
||||
{ name: "pending", calls: 1, succeeded: 0, failed: 0, unfinished: 1 },
|
||||
])
|
||||
|
||||
const summary = yield* SessionStats.get({
|
||||
@@ -234,7 +242,7 @@ describe("SessionStats", () => {
|
||||
expect(summary.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
|
||||
expect(summary.tools).toEqual({
|
||||
mode: "summary",
|
||||
totals: { calls: 2, succeeded: 1, failed: 1, unfinished: 0 },
|
||||
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
|
||||
})
|
||||
|
||||
const withoutTools = yield* SessionStats.get({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.7",
|
||||
"@opentui/solid": ">=0.5.7",
|
||||
"@opentui/core": ">=0.5.8",
|
||||
"@opentui/solid": ">=0.5.8",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user