Compare commits

...
Author SHA1 Message Date
James Long 9e5877de72 feat(tui): add persistent terminal workspaces 2026-08-25 01:21:08 +00:00
James Long 9049e26ab7 feat(cli): embed persistent PTY service binaries 2026-08-25 01:13:00 +00:00
James Long 7dda1f1471 feat(server): add persistent PTY daemon API 2026-08-25 01:09:54 +00:00
James Long 7be5a81a7b feat(core): add persistent terminal groups 2026-08-25 01:05:38 +00:00
Kit Langton a02b0a4729 fix(core): make workspace destroy idempotent (#44769) 2026-08-24 17:27:01 -04:00
Filip d8ce27fa29 fix(github): support immutable OIDC subjects (#44778) 2026-08-24 23:23:44 +02:00
Kit Langton 2f740cec5d fix(tui): highlight shell script files (#44772) 2026-08-24 17:21:19 -04:00
opencode-agent[bot]andFilip 162c3fcebd fix(app): hide revert for child sessions (#44774)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-08-24 23:20:09 +02:00
Dax 71f81dc0fe fix(tui): simplify subagent continuation rows (#44763) 2026-08-24 16:12:56 -04:00
Dax de388dede4 feat(command): execute plugin callbacks 2026-08-24 16:01:58 -04:00
opencode-agent[bot] c936acd3fe chore: update nix node_modules hashes 2026-08-24 19:42:01 +00:00
Kit Langton c19186ee54 feat(sdk): add Promise-first embedded host (#44746) 2026-08-24 15:26:05 -04:00
Dax Raad 563943c52e feat(www): restore native API reference 2026-08-24 15:21:26 -04:00
Dax 575bbd6ea1 fix(www): update docs section navigation (#44753) 2026-08-24 14:48:26 -04:00
Aiden Cline 5d9b53b2c7 fix(ai): honor Responses parallel tool control (#44744) 2026-08-24 13:29:43 -05:00
Aiden Cline 4780248e84 fix(ai): require thinking signature with provider-aware fallback (#44713) 2026-08-24 13:13:48 -05:00
opencode-agent[bot]andBrendonovich 43d4968356 fix(app): focus terminal after opening panel (#44737)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-24 17:50:53 +00:00
Aiden Cline 0164c1c8bc fix(ai): remove obsolete reasoning summary aliases (#44733) 2026-08-24 12:42:36 -05:00
Aiden Cline 6a687398eb feat(ai): gate Chat Completions body fields by provider (#44710) 2026-08-24 12:40:56 -05:00
Kit Langton f4cb9d06c8 fix(tui): keep remotely closed tabs closed (#44715) 2026-08-24 13:06:02 -04:00
opencode-agent[bot]andrekram1-node 3e82b1a9fd fix(tui): provide location context in MCP tests (#44734)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 12:05:25 -05:00
Simon Klee e2a7600a2a tui: preserve close target after tab removal (#44720) 2026-08-24 19:04:50 +02:00
Dax Raad aa8c1f6dac fix(tui): scope MCP dialog to location 2026-08-24 12:17:12 -04:00
Kit Langton 23c3a1461c fix(merman): harden responsive diagram layouts (#44714) 2026-08-24 12:09:00 -04:00
opencode-agent[bot] 50a8539e4b chore: update nix node_modules hashes 2026-08-24 14:29:37 +00:00
Shoubhit Dash 7b47589225 refactor(core): optimize stats tool summary (#44690) 2026-08-24 19:39:04 +05:30
OpeOginniandShoubhit Dash 84275c6e9d fix(cli): restore print-logs flag for --standalone runs (#44112)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-08-24 19:32:38 +05:30
Kit Langton 793ea52fa7 fix(core): make packed SDK boot on workerd (#44703) 2026-08-24 14:02:26 +00:00
Simon Klee 40380ad9b5 tui: update OpenTUI to 0.5.8 (#44698) 2026-08-24 15:42:27 +02:00
186 changed files with 12694 additions and 1695 deletions
+6
View File
@@ -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
+16 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+9 -2
View File
@@ -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`)
+143 -7
View File
@@ -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`)
+5
View File
@@ -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 {
+1 -1
View File
@@ -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,
@@ -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 @@
}
}
]
}
}
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(
+1 -1
View File
@@ -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)
+1 -8
View File
@@ -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({
+8 -1
View File
@@ -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,
+19 -1
View File
@@ -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,
+7
View File
@@ -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) => ({
+102
View File
@@ -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"}`)
}
+6
View File
@@ -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) {
+2 -2
View File
@@ -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",
},
+8 -1
View File
@@ -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)
}),
)
+7 -1
View File
@@ -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)
}),
+2
View File
@@ -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 })
+1 -1
View File
@@ -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",
})
+39
View File
@@ -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)
}
})
-1
View File
@@ -87,7 +87,6 @@ export const planAgent = {
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
+1
View File
@@ -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,
+4
View File
@@ -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 {}
+227 -6
View File
@@ -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>
+188 -9
View File
@@ -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"]),
+216 -10
View File
@@ -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>(
+194 -138
View File
@@ -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
+16
View File
@@ -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 = {
+6
View File
@@ -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",
+35
View File
@@ -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
View File
@@ -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"
}
+109 -12
View File
@@ -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
+4 -3
View File
@@ -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) {
+1 -1
View File
@@ -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) =>
+2
View File
@@ -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")
}
+103
View File
@@ -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] })
+754
View File
@@ -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
}
+98 -7
View File
@@ -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
+15 -43
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+33 -15
View File
@@ -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,
)
+14 -7
View File
@@ -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 }
}),
)
}),
+47 -53
View File
@@ -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" })
}),
)
})
+126 -47
View File
@@ -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")
}),
},
{
File diff suppressed because one or more lines are too long
+91
View File
@@ -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 })
}
})
+43 -2
View File
@@ -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" }],
},
])
}),
)
})
+10 -2
View File
@@ -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({
+22 -1
View File
@@ -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
+3
View File
@@ -5,6 +5,9 @@
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
+26 -26
View File
@@ -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)
+14
View File
@@ -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],
}
}
+39
View File
@@ -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")
})
})
+1
View File
@@ -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"
},
+98
View File
@@ -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
}
+9 -7
View File
@@ -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,
+367 -21
View File
@@ -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)
})
})
+6
View File
@@ -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)))
}
+246 -48
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+226 -18
View File
@@ -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"
+9 -3
View File
@@ -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),
}
}
+81
View File
@@ -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)
+5 -3
View File
@@ -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,
+263 -11
View File
@@ -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
+72 -37
View File
@@ -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 }
}
+72 -1
View File
@@ -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)
},
)
})
+170 -34
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+2 -1
View File
@@ -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,
})
}
+154 -4
View File
@@ -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", () => {
+291 -51
View File
@@ -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>,
+3 -1
View File
@@ -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)
}
+48
View File
@@ -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
+2 -2
View File
@@ -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