Compare commits

..

15 Commits

Author SHA1 Message Date
Kit Langton a48e68fc30 refactor(core): remove dead AISDK hook domain 2026-08-07 23:37:19 -04:00
Kit Langton 9b7b402737 feat(server): run modal sandboxes on the vm runtime (#41177) 2026-08-07 23:21:05 -04:00
James Long 5e6370363b fix(tui): refine provider failure presentation (#41179) 2026-08-07 23:11:32 -04:00
Kit Langton dd6020656e fix(merman): tighten flowchart spacing (#41191) 2026-08-07 23:00:44 -04:00
Kit Langton 8c758e443b fix(core): reuse shared patch diff (#41186) 2026-08-07 22:50:59 -04:00
Kit Langton 0df6aed6ca fix(merman): derive neutral diagram palette (#41181) 2026-08-07 22:43:58 -04:00
Kit Langton aa05fd23b3 refactor(core): remove legacy account runtime schemas (#41173) 2026-08-07 22:40:53 -04:00
Kit Langton fd09760903 fix(tui): open sessions by exact ID (#41180) 2026-08-07 22:29:09 -04:00
Kit Langton 3a4772abb4 fix(merman): separate sibling subgraphs (#41178) 2026-08-07 22:28:12 -04:00
Kit Langton 2cf65c025a fix(merman): support undirected edges and place multiline state labels (#41171) 2026-08-07 21:49:04 -04:00
Kit Langton f3f1204802 feat(tui): render Mermaid diagrams (#41113) 2026-08-07 20:59:50 -04:00
opencode-agent[bot] b3aea1c39d feat(console): add workspace unblock endpoint (#41170)
Co-authored-by: Frank Wang <83515+fwang@users.noreply.github.com>
2026-08-07 20:59:40 -04:00
opencode-agent[bot] 79a493e7f5 fix(tui): show external worktree session labels (#41147)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-07 20:52:44 -04:00
Kit Langton 952d4d446f feat(server): add modal environment driver (#41118) 2026-08-07 19:36:55 -04:00
opencode-agent[bot] 0b84e24e65 fix(tui): standardize compact terminology (#41141) 2026-08-07 16:26:14 -04:00
99 changed files with 12252 additions and 318 deletions
+56
View File
@@ -571,6 +571,20 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.8",
@@ -679,6 +693,7 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -881,6 +896,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
@@ -1512,6 +1528,18 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1720,6 +1748,10 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1814,6 +1846,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -2064,6 +2098,8 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -3298,6 +3334,8 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3538,6 +3576,10 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4570,6 +4612,8 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4796,6 +4840,8 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4834,6 +4880,10 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5632,6 +5682,8 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6590,6 +6642,8 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6734,6 +6788,8 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -4,7 +4,12 @@ import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth"
import { ProviderShared } from "../shared"
/** AWS credentials for SigV4 signing. */
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
*/
export interface Credentials {
readonly region: string
readonly accessKeyId: string
@@ -12,8 +17,6 @@ export interface Credentials {
readonly sessionToken?: string
}
export type CredentialProvider = () => Promise<Credentials>
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -45,7 +48,7 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | CredentialProvider | undefined,
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => {
@@ -55,22 +58,12 @@ export const sigV4 = (
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
)
}
const resolved =
typeof credentials === "function"
? yield* Effect.tryPromise({
try: credentials,
catch: (error) =>
ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} credential resolution failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
: credentials
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
body: input.body,
headers: headersForSigning,
credentials: resolved,
credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
})
+3 -4
View File
@@ -4,14 +4,13 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import * as BedrockConverse from "../protocols/bedrock-converse"
import type { BedrockCredentials } from "../protocols/bedrock-converse"
import type { CredentialProvider } from "../protocols/utils/bedrock-auth"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials | CredentialProvider
readonly credentials?: BedrockCredentials
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
@@ -22,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials | CredentialProvider
readonly credentials?: BedrockCredentials
readonly region?: string
readonly topP?: number
}
@@ -32,7 +31,7 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? (typeof credentials === "function" ? undefined : credentials?.region) ?? "us-east-1"
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
return BedrockConverse.route.with({
...rest,
provider: id,
+14 -2
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer } from "effect"
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import {
FetchHttpClient,
Headers,
@@ -198,8 +198,20 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
}
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
}),
),
)
const providerMessage = (status: number, body: { readonly body?: string }) => {
if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
if (body.body && body.body.length <= 500) {
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
}
return `Provider request failed with HTTP ${status}`
}
@@ -519,8 +519,14 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }],
),
),
@@ -555,7 +561,10 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
)
@@ -751,26 +760,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("resolves fresh SigV4 credentials for each request", () => {
let calls = 0
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
credentials: async () => {
calls++
return {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
return LLMClient.generate(LLMRequest.update(baseRequest, { model: signed })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.tap(() => Effect.sync(() => expect(calls).toBe(1))),
)
})
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
@@ -0,0 +1,21 @@
import type { APIEvent } from "@solidjs/start/server"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
import { Resource } from "@opencode-ai/console-resource"
import z from "zod"
const Body = z.object({ workspaceID: z.string().startsWith("wrk_") })
export async function POST(event: APIEvent) {
if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = Body.safeParse(await event.request.json().catch(() => undefined))
if (!body.success) {
return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 })
}
return Workspace.unblock(body.data.workspaceID)
.then(() => Response.json({ success: true, message: "Workspace unblocked" }))
.catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }))
}
+12
View File
@@ -103,4 +103,16 @@ export namespace Workspace {
.where(eq(WorkspaceTable.id, Actor.workspace())),
)
})
export const unblock = fn(z.string().startsWith("wrk_"), async (workspaceID) => {
await Database.transaction(async (tx) => {
const workspace = await tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0])
if (!workspace) throw new Error("Workspace not found")
await tx.update(WorkspaceTable).set({ is_blocked: false }).where(eq(WorkspaceTable.id, workspaceID))
})
})
}
+1
View File
@@ -18,6 +18,7 @@
},
"exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
-101
View File
@@ -1,101 +0,0 @@
export * as Account from "./account"
import { Schema } from "effect"
import type { HttpClientError } from "effect/unstable/http"
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID>
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({
id: ID,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
method: error.request.method,
url: error.request.url,
description: error.description,
cause: error.cause,
})
}
override get message(): string {
return [
`Could not reach ${this.method} ${this.url}.`,
`This failed before the server returned an HTTP response.`,
this.description,
`Check your network, proxy, or VPN configuration and try again.`,
]
.filter(Boolean)
.join("\n")
}
}
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode,
user: UserCode,
url: Schema.String,
server: Schema.String,
expiry: Schema.Duration,
interval: Schema.Duration,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect(),
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>
+7 -10
View File
@@ -1,24 +1,21 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Account } from "../account"
import { Timestamps } from "../database/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<Account.ID>().primaryKey(),
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<Account.ID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<Account.OrgID>(),
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text(),
})
// LEGACY
@@ -27,8 +24,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
+1 -3
View File
@@ -88,9 +88,7 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials =
mapBedrockCredentials(settings) ??
(typeof settings.credentialProvider === "function" ? settings.credentialProvider : undefined)
const credentials = mapBedrockCredentials(settings)
return {
...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
-2
View File
@@ -1,6 +1,5 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
@@ -9,7 +8,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
@@ -59,40 +59,18 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID)
}
function defaultCredentialProvider(profile: string | undefined, region: string) {
const load = import("@aws-sdk/credential-providers").then((mod) =>
mod.fromNodeProviderChain(profile ? { profile } : {}),
)
return async () => ({ ...(await (await load)()), region })
}
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue
if (
!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(
Provider.packageName(item.provider.package) ?? "",
)
)
continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
const settings = provider.settings ?? {}
const profile = typeof settings.profile === "string" ? settings.profile : process.env.AWS_PROFILE
const region = typeof settings.region === "string" ? settings.region : (process.env.AWS_REGION ?? "us-east-1")
provider.settings = {
...settings,
region,
...(typeof settings.credentialProvider === "function"
? {}
: { credentialProvider: defaultCredentialProvider(profile, region) }),
}
if (typeof settings.endpoint !== "string") return
if (typeof provider.settings?.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.settings.baseURL = settings.endpoint
provider.settings.baseURL = provider.settings.endpoint
delete provider.settings.endpoint
})
}
@@ -117,7 +95,8 @@ export const AmazonBedrockPlugin = define({
if (!bearerToken && options.credentialProvider === undefined) {
// Do not gate SDK creation on explicit AWS env vars. The default chain
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
options.credentialProvider = defaultCredentialProvider(profile, region)
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
}
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
+9 -15
View File
@@ -3,7 +3,6 @@ export * as PatchTool from "./patch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Result, Schema } from "effect"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
@@ -15,6 +14,7 @@ import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt"
import { fileDiff } from "./file-diff"
export const name = "patch"
@@ -353,22 +353,16 @@ function errorMessage(error: unknown) {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(createTwoFilesPatch(change.target.absolute, change.target.absolute, change.before, after))
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const diff = fileDiff(
change.target.absolute,
change.before,
after,
change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
)
return {
...diff,
file: target,
patch,
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
patch: trimDiff(diff.patch),
}
}
-12
View File
@@ -158,18 +158,6 @@ describe("AISDKNative", () => {
})
})
test("maps a Bedrock credential provider to the native runtime", () => {
const credentialProvider = async () => ({
region: "us-east-1",
accessKeyId: "key",
secretAccessKey: "secret",
})
for (const packageName of ["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"]) {
expect(map(packageName, { credentialProvider })?.settings).toMatchObject({ credentials: credentialProvider })
}
})
test("maps the legacy Bedrock endpoint override", () => {
expect(
map(
@@ -80,66 +80,24 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint setting to baseURL", () =>
withEnv({ AWS_PROFILE: undefined, AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
})
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toMatchObject({
baseURL: "https://bedrock.example",
region: "us-east-1",
credentialProvider: expect.any(Function),
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
})
}),
),
)
it.effect("discovers AWS credentials for the native Bedrock runtime", () =>
withEnv(
{
AWS_ACCESS_KEY_ID: "key",
AWS_SECRET_ACCESS_KEY: "secret",
AWS_SESSION_TOKEN: "session",
AWS_PROFILE: undefined,
AWS_REGION: "eu-west-1",
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
const credentialProvider = result.settings?.credentialProvider
if (typeof credentialProvider !== "function") throw new Error("Expected credential provider")
const credentials = yield* Effect.promise(() => credentialProvider())
expect(credentials).toMatchObject({
region: "eu-west-1",
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
})
}),
),
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" })
}),
)
it.effect("prefers endpoint over baseURL for SDK base URL", () =>
+25 -2
View File
@@ -215,7 +215,7 @@ describe("PatchTool", () => {
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 2,
deletions: 1,
patch: expect.stringContaining("-remove"),
},
],
@@ -248,6 +248,29 @@ describe("PatchTool", () => {
),
)
it.live("counts deleted lines with and without a trailing newline", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(directory, "trailing.txt"), "remove\n"),
fs.writeFile(path.join(directory, "unterminated.txt"), "remove"),
]),
)
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: trailing.txt\n*** Delete File: unterminated.txt\n*** End Patch"),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output.files).toMatchObject([
{ file: "trailing.txt", additions: 0, deletions: 1 },
{ file: "unterminated.txt", additions: 0, deletions: 1 },
])
}),
),
)
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
@@ -446,7 +469,7 @@ describe("PatchTool", () => {
{
file: "renamed/dir/name.txt",
status: "modified",
patch: expect.stringContaining("-old content\n+new content"),
patch: expect.stringContaining(`Index: ${source}`),
},
],
})
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/merman",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./plugin": "./src/plugin.ts"
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import stringWidth from "string-width"
import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from "./canvas.js"
describe("DiagramCanvas", () => {
test("rejects canvases that exceed the rendering budget", () => {
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
})
test("writes cells and text while clipping out-of-bounds positions", () => {
const canvas = new DiagramCanvas<"label">(5, 2)
canvas.setCell(0, 0, "A", "label")
canvas.setCell(9, 0, "X", "label")
canvas.setText(2, 1, "hey", "label")
expect(canvas.toString()).toBe("A\n hey")
})
test("uses measured character widths for text placement", () => {
const canvas = new DiagramCanvas<"label">(5, 1)
canvas.setText(0, 0, "a界b", "label")
expect(canvas.toString()).toBe("a界b")
expect(stringWidth(canvas.toString())).toBe(4)
})
test("preserves combined graphemes while placing later text", () => {
const canvas = new DiagramCanvas<"label">(4, 1)
canvas.setText(0, 0, "e\u0301x", "label")
expect(canvas.toString()).toBe("e\u0301x")
expect(stringWidth(canvas.toString())).toBe(2)
})
test("merges cells through the adapter-provided merge function", () => {
type Style = "line"
const canvas = new DiagramCanvas<Style>(3, 1, {
mergeCell: (existing, incoming): DiagramCanvasCell<Style> => ({
...incoming,
char: existing.char === "─" && incoming.char === "│" ? "┼" : incoming.char,
}),
})
canvas.setCell(1, 0, "─", "line")
canvas.setCell(1, 0, "│", "line")
expect(canvas.toString()).toBe(" ┼")
})
test("iterates style and metadata runs", () => {
interface Metadata {
stateId?: string
}
const canvas = new DiagramCanvas<"state", Metadata>(4, 1)
const runs: string[] = []
canvas.setText(0, 0, "AB", "state", { stateId: "A" })
canvas.setText(2, 0, "CD", "state", { stateId: "B" })
canvas.forEachRun(
(run) => runs.push(`${run.text}:${run.style}:${run.cell.stateId}`),
() => runs.push("newline"),
{ key: (cell) => [cell.style, cell.stateId] },
)
expect(runs).toEqual(["AB:state:A", "CD:state:B"])
})
test("can trim bottom whitespace for renderers with dynamic height", () => {
const canvas = new DiagramCanvas<"label">(3, 3)
const runs: string[] = []
canvas.setText(0, 0, "top", "label")
expect(canvas.toString()).toBe("top\n\n")
expect(canvas.toString({ trimBottom: true })).toBe("top")
expect(canvas.getTextSize()).toEqual({ width: 3, height: 3 })
expect(canvas.getTextSize({ trimBottom: true })).toEqual({ width: 3, height: 1 })
canvas.forEachRun(
(run) => runs.push(run.text),
() => runs.push("newline"),
{ trimBottom: true },
)
expect(runs).toEqual(["top"])
})
test("can trim unused leading whitespace reserved by layout", () => {
const canvas = new DiagramCanvas<"label">(3, 3)
canvas.setText(0, 2, "end", "label")
expect(canvas.toString({ trimTop: true })).toBe("end")
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
})
})
+182
View File
@@ -0,0 +1,182 @@
import stringWidth from "string-width"
import { diagramTextGraphemes } from "./text.js"
export type DiagramCanvasCell<Style extends string, Metadata extends object = object> = {
char: string
style?: Style
} & Partial<Metadata>
export interface DiagramCanvasRun<Style extends string, Metadata extends object = object> {
text: string
style: Style | undefined
cell: DiagramCanvasCell<Style, Metadata>
}
export interface DiagramCanvasOptions<Style extends string, Metadata extends object = object> {
measure?: (text: string) => number
mergeCell?: (
existing: DiagramCanvasCell<Style, Metadata>,
incoming: DiagramCanvasCell<Style, Metadata>,
) => DiagramCanvasCell<Style, Metadata>
}
export interface DiagramCanvasTextOptions {
trimTop?: boolean
trimBottom?: boolean
}
export interface DiagramCanvasTextSize {
width: number
height: number
}
export type DiagramCanvasTextMetadata<Metadata extends object> =
| Partial<Metadata>
| ((x: number, y: number) => Partial<Metadata>)
export interface DiagramCanvasRunOptions<Style extends string, Metadata extends object = object> {
key?: (cell: DiagramCanvasCell<Style, Metadata>) => readonly unknown[]
trimTop?: boolean
trimBottom?: boolean
}
const MAX_DIAGRAM_CELLS = 1_000_000
export class DiagramCanvasSizeError extends Error {
constructor(
readonly width: number,
readonly height: number,
) {
super(`Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`)
this.name = "DiagramCanvasSizeError"
}
}
function createEmptyCell<Style extends string, Metadata extends object>(): DiagramCanvasCell<Style, Metadata> {
return { char: " " } as DiagramCanvasCell<Style, Metadata>
}
function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]): boolean {
return Boolean(left && left.length === right.length && left.every((value, index) => Object.is(value, right[index])))
}
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
private readonly measure: (text: string) => number
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
constructor(
readonly width: number,
readonly height: number,
options: DiagramCanvasOptions<Style, Metadata> = {},
) {
if (width * height > MAX_DIAGRAM_CELLS) throw new DiagramCanvasSizeError(width, height)
this.measure = options.measure ?? stringWidth
this.mergeCell = options.mergeCell
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
}
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
let rowEnd = row.length
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
return rowEnd
}
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
return row
.slice(0, rowEnd)
.map((cell) => cell.char)
.join("")
}
private textRowRange(trimTop: boolean, trimBottom: boolean): { start: number; end: number } {
let start = 0
let end = this.rows.length
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
return { start, end }
}
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[y]!.length) return
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
}
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
return this.rows[y]?.[x]
}
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
let offset = 0
for (const grapheme of diagramTextGraphemes(text)) {
const width = Math.max(1, this.measure(grapheme))
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
for (let continuation = 1; continuation < width; continuation++) {
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
}
offset += width
}
}
toString(options: DiagramCanvasTextOptions = {}): string {
const lines: string[] = []
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
lines.push(this.rowText(this.rows[rowIndex]!))
}
return lines.join("\n")
}
getTextSize(options: DiagramCanvasTextOptions = {}): DiagramCanvasTextSize {
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
let width = 0
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
}
return { width, height: rows.end - rows.start }
}
forEachRun(
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
onLineEnd: () => void,
options: DiagramCanvasRunOptions<Style, Metadata> = {},
): void {
const key = options.key
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
for (let rowIndex = rows.start; rowIndex < rows.end; rowIndex++) {
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
let currentKey: readonly unknown[] | undefined
let currentText = ""
const flush = () => {
if (!currentText || !currentCell) return
onRun({ text: currentText, style: currentCell.style, cell: currentCell })
currentText = ""
}
for (let x = 0; x < rowEnd; x++) {
const cell = row[x]!
const nextKey = key?.(cell)
const sameRun = currentCell && (key ? sameKey(currentKey, nextKey!) : currentCell.style === cell.style)
if (!sameRun) {
flush()
currentCell = cell
currentKey = nextKey
}
currentText += cell.char
}
flush()
if (rowIndex < rows.end - 1) onLineEnd()
}
}
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { blendColor, createColorRampTheme, numberedStyleKeys, rgba } from "./style.js"
describe("diagram style helpers", () => {
test("converts rgb tuples and blends optional RGBA values", () => {
const black = RGBA.fromInts(0, 0, 0, 255)
const white = RGBA.fromInts(10, 20, 30, 255)
expect(rgba([1, 2, 3]).equals(RGBA.fromInts(1, 2, 3, 255))).toBe(true)
expect(blendColor(black, white, 0.5).equals(RGBA.fromInts(5, 10, 15, 255))).toBe(true)
expect(blendColor(undefined, white, 0.5)?.equals(white)).toBe(true)
expect(blendColor(undefined, undefined, 0.5)).toBeUndefined()
})
test("creates numbered style keys and color ramps", () => {
const styles = numberedStyleKeys("requestFade", [1, 2, 3] as const)
const theme = createColorRampTheme(styles, RGBA.fromInts(0, 0, 0, 255), RGBA.fromInts(12, 24, 36, 255))
expect(styles).toEqual(["requestFade1", "requestFade2", "requestFade3"])
expect(theme.requestFade1.equals(RGBA.fromInts(3, 6, 9, 255))).toBe(true)
expect(theme.requestFade3.equals(RGBA.fromInts(9, 18, 27, 255))).toBe(true)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { RGBA } from "@opentui/core"
export type DiagramRgb = readonly [number, number, number]
export type DiagramFadeStep = 1 | 2 | 3 | 4 | 5
export const DIAGRAM_FADE_STEPS = [1, 2, 3, 4, 5] as const satisfies readonly DiagramFadeStep[]
export function numberedStyleKeys<Prefix extends string, Step extends number>(
prefix: Prefix,
steps: readonly Step[],
): Array<`${Prefix}${Step}`> {
return steps.map((step) => `${prefix}${step}` as `${Prefix}${Step}`)
}
export function rgba(rgb: DiagramRgb): RGBA {
return RGBA.fromInts(rgb[0], rgb[1], rgb[2], 255)
}
export function blendColor(from: RGBA, to: RGBA, amount: number): RGBA
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined
export function blendColor(from: RGBA | undefined, to: RGBA | undefined, amount: number): RGBA | undefined {
if (!from && !to) return undefined
if (!from) return to
if (!to) return from
const [fromR, fromG, fromB, fromA] = from.toInts()
const [toR, toG, toB, toA] = to.toInts()
const mix = (left: number, right: number) => left + (right - left) * amount
return RGBA.fromInts(mix(fromR, toR), mix(fromG, toG), mix(fromB, toB), mix(fromA, toA))
}
export function colorsEqual(left?: RGBA, right?: RGBA): boolean {
if (!left || !right) return left === right
return left.equals(right)
}
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA,
to: RGBA,
): Record<Style, RGBA>
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA | undefined,
to: RGBA | undefined,
): Record<Style, RGBA | undefined>
export function createColorRampTheme<Style extends string>(
styles: readonly Style[],
from: RGBA | undefined,
to: RGBA | undefined,
): Record<Style, RGBA | undefined> {
return Object.fromEntries(
styles.map((style, index) => [style, blendColor(from, to, (index + 1) / (styles.length + 1))]),
) as Record<Style, RGBA | undefined>
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test"
import { BorderChars } from "@opentui/core"
import { DiagramCanvas } from "./canvas.js"
import {
diagramArrowHead,
drawDiagramDiamond,
drawDiagramFrame,
drawOrthogonalPath,
mergeDiagramLineGlyph,
} from "./drawing.js"
describe("diagram drawing", () => {
test("merges line glyphs with square and rounded corners", () => {
expect(mergeDiagramLineGlyph("─", "│")).toBe("┼")
expect(mergeDiagramLineGlyph("─", "│", "rounded")).toBe("┼")
expect(mergeDiagramLineGlyph("─", "╭", "rounded")).toBe("┬")
expect(mergeDiagramLineGlyph("a", "│")).toBeUndefined()
})
test("draws frames through caller-provided cell writers", () => {
const canvas = new DiagramCanvas<"frame">(8, 4)
drawDiagramFrame(
{ left: 1, top: 0, width: 6, height: 4, centerX: 4, centerY: 2 },
BorderChars.rounded,
(x, y, char) => canvas.setCell(x, y, char, "frame"),
)
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n │ │\n ╰────╯")
})
test("draws diamond frames through caller-provided cell writers", () => {
const canvas = new DiagramCanvas<"frame">(9, 5)
drawDiagramDiamond({ left: 0, top: 0, width: 9, height: 5, centerX: 4, centerY: 2 }, (x, y, char) =>
canvas.setCell(x, y, char, "frame"),
)
expect(canvas.toString({ trimBottom: true })).toBe(" ╭───╮\n╭─╯ ╰─╮\n│ │\n╰─╮ ╭─╯\n ╰───╯")
expect(canvas.toString()).not.toMatch(/[╱╲\\/]/)
})
test("draws orthogonal paths with selected corner style", () => {
const canvas = new DiagramCanvas<"edge">(7, 4)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 2 },
{ x: 6, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ cornerStyle: "rounded" },
)
expect(canvas.toString({ trimBottom: true })).toBe("───╮\n │\n ╰──")
})
test("draws orthogonal paths with heavy line style", () => {
const canvas = new DiagramCanvas<"edge">(7, 4)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 2 },
{ x: 6, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ lineStyle: "heavy" },
)
expect(canvas.toString({ trimBottom: true })).toBe("━━━┓\n ┃\n ┗━━")
})
test("draws orthogonal paths with dashed line style", () => {
const canvas = new DiagramCanvas<"edge">(9, 1)
drawOrthogonalPath(
[
{ x: 0, y: 0 },
{ x: 8, y: 0 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
{ lineStyle: "dashed" },
)
expect(canvas.toString({ trimBottom: true })).toBe("─ ─ ─ ─")
})
test("keeps container frame policy separate from edge drawing policy", () => {
const canvas = new DiagramCanvas<"group" | "edge">(8, 5, {
mergeCell: (existing, incoming) => {
if (incoming.style === "edge") return incoming
return existing.char === " " ? incoming : existing
},
})
drawDiagramFrame(
{ left: 1, top: 0, width: 6, height: 5, centerX: 4, centerY: 2 },
BorderChars.rounded,
(x, y, char) => canvas.setCell(x, y, char, "group"),
)
drawOrthogonalPath(
[
{ x: 0, y: 2 },
{ x: 7, y: 2 },
],
(x, y, char) => canvas.setCell(x, y, char, "edge"),
)
canvas.setCell(7, 2, "▶", "edge")
expect(canvas.toString({ trimBottom: true })).toBe(" ╭────╮\n │ │\n───────▶\n │ │\n ╰────╯")
})
test("selects filled and line arrow heads", () => {
expect(diagramArrowHead("right")).toBe("▶")
expect(diagramArrowHead("left", "line")).toBe("←")
})
})
+301
View File
@@ -0,0 +1,301 @@
import { BorderChars, type BorderCharacters } from "@opentui/core"
import {
directionBetween,
walkOrthogonalSegment,
type DiagramBounds,
type DiagramDirection,
type DiagramPoint,
} from "./geometry.js"
export type DiagramLineCornerStyle = "square" | "rounded"
export type DiagramLineStyle = "single" | "heavy" | "dashed"
export type DiagramArrowHeadStyle = "filled" | "line"
export interface DiagramDiamondCharacters {
topLeft: string
topRight: string
upperLeft: string
upperLeftJoin: string
upperRightJoin: string
upperRight: string
vertical: string
lowerLeft: string
lowerLeftJoin: string
lowerRightJoin: string
lowerRight: string
bottomLeft: string
bottomRight: string
horizontal: string
}
export const DIAGRAM_ARROW_HEADS = new Set(["▶", "◀", "▼", "▲", "→", "←", "↓", "↑"])
const HEAVY_LINE_GLYPHS = new Set(Object.values(BorderChars.heavy))
export const DIAGRAM_DIAMOND_CHARS = {
topLeft: "╭",
topRight: "╮",
upperLeft: "╭",
upperLeftJoin: "╯",
upperRightJoin: "╰",
upperRight: "╮",
vertical: "│",
lowerLeft: "╰",
lowerLeftJoin: "╮",
lowerRightJoin: "╭",
lowerRight: "╯",
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
} as const satisfies DiagramDiamondCharacters
export function diagramDiamondCharactersFromBorder(chars: BorderCharacters): DiagramDiamondCharacters {
return {
topLeft: chars.topLeft,
topRight: chars.topRight,
upperLeft: chars.topLeft,
upperLeftJoin: chars.bottomRight,
upperRightJoin: chars.bottomLeft,
upperRight: chars.topRight,
vertical: chars.vertical,
lowerLeft: chars.bottomLeft,
lowerLeftJoin: chars.topRight,
lowerRightJoin: chars.topLeft,
lowerRight: chars.bottomRight,
bottomLeft: chars.bottomLeft,
bottomRight: chars.bottomRight,
horizontal: chars.horizontal,
}
}
function lineDirections(char: string): readonly DiagramDirection[] | undefined {
switch (char) {
case "─":
return ["left", "right"]
case "│":
return ["up", "down"]
case "━":
return ["left", "right"]
case "┃":
return ["up", "down"]
case "┌":
case "╭":
case "┏":
return ["right", "down"]
case "┐":
case "╮":
case "┓":
return ["left", "down"]
case "└":
case "╰":
case "┗":
return ["up", "right"]
case "┘":
case "╯":
case "┛":
return ["up", "left"]
case "├":
case "┣":
return ["up", "down", "right"]
case "┤":
case "┫":
return ["up", "down", "left"]
case "┬":
case "┳":
return ["left", "right", "down"]
case "┴":
case "┻":
return ["left", "right", "up"]
case "┼":
case "╋":
return ["up", "down", "left", "right"]
default:
return undefined
}
}
export function diagramLineGlyph(
directions: ReadonlySet<DiagramDirection>,
cornerStyle: DiagramLineCornerStyle = "square",
lineStyle: DiagramLineStyle = "single",
): string {
const up = directions.has("up")
const down = directions.has("down")
const left = directions.has("left")
const right = directions.has("right")
if (lineStyle === "heavy") {
const chars = BorderChars.heavy
if (up && down && left && right) return chars.cross
if (up && down && right) return chars.leftT
if (up && down && left) return chars.rightT
if (left && right && down) return chars.topT
if (left && right && up) return chars.bottomT
if (up && right) return chars.bottomLeft
if (up && left) return chars.bottomRight
if (down && right) return chars.topLeft
if (down && left) return chars.topRight
if (up || down) return chars.vertical
return chars.horizontal
}
if (up && down && left && right) return "┼"
if (up && down && right) return "├"
if (up && down && left) return "┤"
if (left && right && down) return "┬"
if (left && right && up) return "┴"
if (up && right) return cornerStyle === "rounded" ? "╰" : "└"
if (up && left) return cornerStyle === "rounded" ? "╯" : "┘"
if (down && right) return cornerStyle === "rounded" ? "╭" : "┌"
if (down && left) return cornerStyle === "rounded" ? "╮" : "┐"
if (up || down) return "│"
return "─"
}
function isHeavyLineGlyph(char: string): boolean {
return HEAVY_LINE_GLYPHS.has(char)
}
function segmentGlyph(direction: DiagramDirection, lineStyle: DiagramLineStyle | undefined): string {
const directions = new Set<DiagramDirection>(
direction === "left" || direction === "right" ? ["left", "right"] : ["up", "down"],
)
return diagramLineGlyph(directions, "square", lineStyle === "heavy" ? "heavy" : "single")
}
export function mergeDiagramLineGlyph(
existing: string,
incoming: string,
cornerStyle: DiagramLineCornerStyle = "square",
): string | undefined {
const existingDirections = lineDirections(existing)
const incomingDirections = lineDirections(incoming)
if (!existingDirections || !incomingDirections) return undefined
return diagramLineGlyph(
new Set([...existingDirections, ...incomingDirections]),
cornerStyle,
isHeavyLineGlyph(existing) && isHeavyLineGlyph(incoming) ? "heavy" : "single",
)
}
export function diagramArrowHead(direction: DiagramDirection, style: DiagramArrowHeadStyle = "filled"): string {
if (style === "line") {
if (direction === "right") return "→"
if (direction === "left") return "←"
if (direction === "up") return "↑"
return "↓"
}
if (direction === "right") return "▶"
if (direction === "left") return "◀"
if (direction === "up") return "▲"
return "▼"
}
export function diagramArrowHeadBetween(
from: DiagramPoint,
to: DiagramPoint,
style: DiagramArrowHeadStyle = "filled",
): string {
const direction = directionBetween(from, to)
return direction ? diagramArrowHead(direction, style) : diagramArrowHead("right", style)
}
export function drawDiagramFrame(
bounds: DiagramBounds,
chars: BorderCharacters,
setCell: (x: number, y: number, char: string) => void,
): void {
setCell(bounds.left, bounds.top, chars.topLeft)
setCell(bounds.left + bounds.width - 1, bounds.top, chars.topRight)
setCell(bounds.left, bounds.top + bounds.height - 1, chars.bottomLeft)
setCell(bounds.left + bounds.width - 1, bounds.top + bounds.height - 1, chars.bottomRight)
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
setCell(x, bounds.top, chars.horizontal)
setCell(x, bounds.top + bounds.height - 1, chars.horizontal)
}
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
setCell(bounds.left, y, chars.vertical)
setCell(bounds.left + bounds.width - 1, y, chars.vertical)
}
}
export function fillDiagramFrameInterior(bounds: DiagramBounds, setCell: (x: number, y: number) => void): void {
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) setCell(x, y)
}
}
export function drawDiagramDiamond(
bounds: DiagramBounds,
setCell: (x: number, y: number, char: string) => void,
chars: DiagramDiamondCharacters = DIAGRAM_DIAMOND_CHARS,
): void {
const left = bounds.left
const right = bounds.left + bounds.width - 1
const top = bounds.top
const bottom = bounds.top + bounds.height - 1
const capInset = Math.min(2, Math.max(1, Math.floor((bounds.width - 1) / 2)))
const capLeft = left + capInset
const capRight = right - capInset
setCell(capLeft, top, chars.topLeft)
for (let x = capLeft + 1; x < capRight; x++) setCell(x, top, chars.horizontal)
setCell(capRight, top, chars.topRight)
setCell(left, top + 1, chars.upperLeft)
for (let x = left + 1; x < capLeft; x++) setCell(x, top + 1, chars.horizontal)
setCell(capLeft, top + 1, chars.upperLeftJoin)
setCell(capRight, top + 1, chars.upperRightJoin)
for (let x = capRight + 1; x < right; x++) setCell(x, top + 1, chars.horizontal)
setCell(right, top + 1, chars.upperRight)
for (let y = top + 2; y < bottom - 1; y++) {
setCell(left, y, chars.vertical)
setCell(right, y, chars.vertical)
}
setCell(left, bottom - 1, chars.lowerLeft)
for (let x = left + 1; x < capLeft; x++) setCell(x, bottom - 1, chars.horizontal)
setCell(capLeft, bottom - 1, chars.lowerLeftJoin)
setCell(capRight, bottom - 1, chars.lowerRightJoin)
for (let x = capRight + 1; x < right; x++) setCell(x, bottom - 1, chars.horizontal)
setCell(right, bottom - 1, chars.lowerRight)
setCell(capLeft, bottom, chars.bottomLeft)
for (let x = capLeft + 1; x < capRight; x++) setCell(x, bottom, chars.horizontal)
setCell(capRight, bottom, chars.bottomRight)
}
export function drawOrthogonalPath(
points: readonly DiagramPoint[],
setCell: (x: number, y: number, char: string) => void,
options: { cornerStyle?: DiagramLineCornerStyle; lineStyle?: DiagramLineStyle } = {},
): void {
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
const direction = directionBetween(from, to)
if (!direction) continue
const glyph = segmentGlyph(direction, options.lineStyle)
let step = index === 1 ? 0 : 1
walkOrthogonalSegment(from, to, index === 1, (point) => {
if (options.lineStyle !== "dashed" || step % 2 === 0) setCell(point.x, point.y, glyph)
step += 1
})
}
for (let index = 1; index < points.length - 1; index++) {
const previous = points[index - 1]!
const current = points[index]!
const next = points[index + 1]!
const fromDirection = directionBetween(current, previous)
const toDirection = directionBetween(current, next)
const directions = new Set<DiagramDirection>()
if (fromDirection) directions.add(fromDirection)
if (toDirection) directions.add(toDirection)
setCell(
current.x,
current.y,
diagramLineGlyph(directions, options.cornerStyle, options.lineStyle === "heavy" ? "heavy" : "single"),
)
}
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test"
import {
diagramBoundsFromBounds,
diagramBoundsFromPoints,
diagramBoundsFromRect,
boundsSidePoint,
directionBetween,
lane,
orthogonalPath,
orthogonalPathPoints,
pathThrough,
pathViaLane,
point,
translateDiagramBounds,
walkOrthogonalSegment,
} from "./geometry.js"
describe("diagram geometry", () => {
test("directions are only defined for orthogonal point pairs", () => {
expect(directionBetween(point(1, 2), point(5, 2))).toBe("right")
expect(directionBetween(point(1, 2), point(1, 0))).toBe("up")
expect(directionBetween(point(1, 2), point(5, 4))).toBeUndefined()
expect(directionBetween(point(1, 2), point(1, 2))).toBeUndefined()
})
test("bounds side points describe border and outside ports", () => {
const bounds = { left: 10, top: 4, width: 8, height: 5, centerX: 14, centerY: 6 }
expect(boundsSidePoint(bounds, "left", "border")).toEqual(point(10, 6))
expect(boundsSidePoint(bounds, "left")).toEqual(point(9, 6))
expect(boundsSidePoint(bounds, "right", "border")).toEqual(point(17, 6))
expect(boundsSidePoint(bounds, "right")).toEqual(point(18, 6))
expect(boundsSidePoint(bounds, "top", "border")).toEqual(point(14, 4))
expect(boundsSidePoint(bounds, "bottom")).toEqual(point(14, 9))
})
test("bounds helpers create, translate, and union bounds", () => {
const bounds = diagramBoundsFromRect(2, 3, 5, 4)
expect(bounds).toEqual({ left: 2, top: 3, width: 5, height: 4, centerX: 4, centerY: 5 })
translateDiagramBounds(bounds, 3, -1)
expect(bounds).toEqual({ left: 5, top: 2, width: 5, height: 4, centerX: 7, centerY: 4 })
expect(diagramBoundsFromBounds([bounds, diagramBoundsFromRect(0, 0, 2, 2)])).toEqual({
left: 0,
top: 0,
width: 10,
height: 6,
centerX: 5,
centerY: 3,
})
expect(diagramBoundsFromPoints([point(2, 2), point(4, 5)])).toEqual({
left: 2,
top: 2,
width: 3,
height: 4,
centerX: 3,
centerY: 4,
})
})
test("paths compose through lanes while removing duplicate joints", () => {
expect(pathThrough([point(0, 0), point(0, 0), point(3, 0)])).toEqual([point(0, 0), point(3, 0)])
expect(pathViaLane(point(0, 0), lane("x", 4), point(8, 3))).toEqual([
point(0, 0),
point(4, 0),
point(4, 3),
point(8, 3),
])
})
test("composed paths do not alias caller-owned points", () => {
const start = point(0, 0)
const end = point(8, 3)
const firstPath = pathViaLane(start, lane("x", 4), end)
const secondPath = pathViaLane(start, lane("x", 5), end)
expect(firstPath[0]).not.toBe(start)
expect(firstPath[firstPath.length - 1]).not.toBe(end)
expect(firstPath[0]).not.toBe(secondPath[0])
firstPath[0]!.x += 10
firstPath[firstPath.length - 1]!.y += 10
expect(start).toEqual(point(0, 0))
expect(end).toEqual(point(8, 3))
expect(secondPath[0]).toEqual(point(0, 0))
expect(secondPath[secondPath.length - 1]).toEqual(point(8, 3))
})
test("orthogonal paths choose a terminal lane on the dominant axis", () => {
expect(orthogonalPath(point(0, 0), point(10, 4))).toEqual([point(0, 0), point(6, 0), point(6, 4), point(10, 4)])
})
test("orthogonal segment walkers exclude endpoints", () => {
const visited: Array<{ x: number; y: number }> = []
walkOrthogonalSegment(point(0, 0), point(3, 0), false, (next) => {
visited.push(next)
})
expect(visited).toEqual([point(1, 0), point(2, 0)])
})
test("orthogonal path points include endpoints without duplicating joints", () => {
expect(orthogonalPathPoints([point(0, 0), point(3, 0), point(3, 2)])).toEqual([
point(0, 0),
point(1, 0),
point(2, 0),
point(3, 0),
point(3, 1),
point(3, 2),
])
})
})
+347
View File
@@ -0,0 +1,347 @@
export type DiagramAxis = "x" | "y"
export type DiagramDirection = "up" | "down" | "left" | "right"
export type DiagramSide = "left" | "right" | "top" | "bottom"
export interface DiagramPoint {
x: number
y: number
}
export interface DiagramBounds {
left: number
top: number
width: number
height: number
centerX: number
centerY: number
}
export interface DiagramSegment {
from: DiagramPoint
to: DiagramPoint
axis: DiagramAxis
direction: DiagramDirection
length: number
}
export interface DiagramLane {
axis: DiagramAxis
coordinate: number
}
export interface DiagramSpan {
start: number
end: number
}
const DIRECTION_AXIS = {
left: "x",
right: "x",
up: "y",
down: "y",
} as const satisfies Record<DiagramDirection, DiagramAxis>
const DIRECTION_SIGN = {
left: -1,
right: 1,
up: -1,
down: 1,
} as const satisfies Record<DiagramDirection, -1 | 1>
const DIRECTION_SIDE = {
left: "left",
right: "right",
up: "top",
down: "bottom",
} as const satisfies Record<DiagramDirection, DiagramSide>
const OPPOSITE_SIDE = {
left: "right",
right: "left",
top: "bottom",
bottom: "top",
} as const satisfies Record<DiagramSide, DiagramSide>
export function point(x: number, y: number): DiagramPoint {
return { x, y }
}
export function diagramBoundsFromRect(left: number, top: number, width: number, height: number): DiagramBounds {
return {
left,
top,
width,
height,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
}
}
export function translateDiagramBounds(bounds: DiagramBounds, dx: number, dy: number): void {
bounds.left += dx
bounds.top += dy
bounds.centerX += dx
bounds.centerY += dy
}
export function diagramBoundsFromBounds(bounds: readonly DiagramBounds[]): DiagramBounds | undefined {
if (bounds.length === 0) return undefined
const left = Math.min(...bounds.map((bound) => bound.left))
const top = Math.min(...bounds.map((bound) => bound.top))
const right = Math.max(...bounds.map((bound) => bound.left + bound.width))
const bottom = Math.max(...bounds.map((bound) => bound.top + bound.height))
return diagramBoundsFromRect(left, top, right - left, bottom - top)
}
export function diagramBoundsFromPoints(points: readonly DiagramPoint[]): DiagramBounds | undefined {
if (points.length === 0) return undefined
const left = Math.min(...points.map((point) => point.x))
const top = Math.min(...points.map((point) => point.y))
const right = Math.max(...points.map((point) => point.x))
const bottom = Math.max(...points.map((point) => point.y))
return diagramBoundsFromRect(left, top, right - left + 1, bottom - top + 1)
}
export function coordinate(point: DiagramPoint, axis: DiagramAxis): number {
return point[axis]
}
export function withCoordinate(point: DiagramPoint, axis: DiagramAxis, value: number): DiagramPoint {
return axis === "x" ? { x: value, y: point.y } : { x: point.x, y: value }
}
export function shiftPoint(point: DiagramPoint, direction: DiagramDirection, distance = 1): DiagramPoint {
switch (direction) {
case "left":
return { x: point.x - distance, y: point.y }
case "right":
return { x: point.x + distance, y: point.y }
case "up":
return { x: point.x, y: point.y - distance }
case "down":
return { x: point.x, y: point.y + distance }
}
}
export function clampPoint(point: DiagramPoint, min: DiagramPoint = { x: 0, y: 0 }): DiagramPoint {
return { x: Math.max(min.x, point.x), y: Math.max(min.y, point.y) }
}
export function samePoint(left: DiagramPoint, right: DiagramPoint): boolean {
return left.x === right.x && left.y === right.y
}
export function directionAxis(direction: DiagramDirection): DiagramAxis {
return DIRECTION_AXIS[direction]
}
export function directionSign(direction: DiagramDirection): -1 | 1 {
return DIRECTION_SIGN[direction]
}
export function sideForDirection(direction: DiagramDirection): DiagramSide {
return DIRECTION_SIDE[direction]
}
export function oppositeSide(side: DiagramSide): DiagramSide {
return OPPOSITE_SIDE[side]
}
export function directionBetween(from: DiagramPoint, to: DiagramPoint): DiagramDirection | undefined {
if (from.y === to.y) {
if (to.x > from.x) return "right"
if (to.x < from.x) return "left"
}
if (from.x === to.x) {
if (to.y > from.y) return "down"
if (to.y < from.y) return "up"
}
return undefined
}
export function boundsCenter(bounds: DiagramBounds): DiagramPoint {
return point(bounds.centerX, bounds.centerY)
}
export function boundsSidePoint(
bounds: DiagramBounds,
side: DiagramSide,
surface: "border" | "outside" = "outside",
): DiagramPoint {
switch (side) {
case "left":
return point(bounds.left - (surface === "outside" ? 1 : 0), bounds.centerY)
case "right":
return point(bounds.left + bounds.width - (surface === "outside" ? 0 : 1), bounds.centerY)
case "top":
return point(bounds.centerX, bounds.top - (surface === "outside" ? 1 : 0))
case "bottom":
return point(bounds.centerX, bounds.top + bounds.height - (surface === "outside" ? 0 : 1))
}
}
export function centerCoordinate(bounds: DiagramBounds, axis: DiagramAxis): number {
return axis === "x" ? bounds.centerX : bounds.centerY
}
export function snapCoordinate(source: number, target: number, tolerance: number): number {
return Math.abs(source - target) <= tolerance ? target : source
}
export function pathThrough(points: readonly DiagramPoint[]): DiagramPoint[] {
const path: DiagramPoint[] = []
for (const next of points) {
if (!path.length || !samePoint(path[path.length - 1]!, next)) path.push(point(next.x, next.y))
}
return path
}
export function lane(axis: DiagramAxis, coordinate: number): DiagramLane {
return { axis, coordinate }
}
export function pathViaLane(start: DiagramPoint, routeLane: DiagramLane, end: DiagramPoint): DiagramPoint[] {
return pathThrough([
start,
withCoordinate(start, routeLane.axis, routeLane.coordinate),
withCoordinate(end, routeLane.axis, routeLane.coordinate),
end,
])
}
function dominantAxis(start: DiagramPoint, end: DiagramPoint): DiagramAxis {
return Math.abs(end.x - start.x) >= Math.abs(end.y - start.y) ? "x" : "y"
}
function terminalLane(start: DiagramPoint, end: DiagramPoint, axis: DiagramAxis, terminalClearance: number): number {
const startCoordinate = coordinate(start, axis)
const endCoordinate = coordinate(end, axis)
const delta = endCoordinate - startCoordinate
const sign = Math.sign(delta)
if (sign === 0) return endCoordinate
return endCoordinate - sign * Math.min(terminalClearance, Math.max(1, Math.abs(delta) - 1))
}
export function orthogonalPath(
start: DiagramPoint,
end: DiagramPoint,
options: { preferredAxis?: DiagramAxis; terminalClearance?: number } = {},
): DiagramPoint[] {
if (start.x === end.x || start.y === end.y) return pathThrough([start, end])
const laneAxis = options.preferredAxis ?? dominantAxis(start, end)
return pathViaLane(start, lane(laneAxis, terminalLane(start, end, laneAxis, options.terminalClearance ?? 4)), end)
}
export function segmentBetween(from: DiagramPoint, to: DiagramPoint): DiagramSegment | undefined {
const direction = directionBetween(from, to)
if (!direction) return undefined
return {
from,
to,
axis: directionAxis(direction),
direction,
length: Math.abs(coordinate(to, directionAxis(direction)) - coordinate(from, directionAxis(direction))),
}
}
export function walkOrthogonalSegment(
from: DiagramPoint,
to: DiagramPoint,
includeStart: boolean,
visit: (point: DiagramPoint) => boolean | void,
): void {
const direction = directionBetween(from, to)
if (!direction) return
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
let cursor = includeStart ? from : point(from.x + dx, from.y + dy)
while (!samePoint(cursor, to)) {
if (visit(cursor) === false) return
cursor = point(cursor.x + dx, cursor.y + dy)
}
}
export function orthogonalPathPoints(points: readonly DiagramPoint[]): DiagramPoint[] {
const path: DiagramPoint[] = []
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
const direction = directionBetween(from, to)
if (!direction) continue
const dx = direction === "right" ? 1 : direction === "left" ? -1 : 0
const dy = direction === "down" ? 1 : direction === "up" ? -1 : 0
let cursor = path.length === 0 ? point(from.x, from.y) : point(from.x + dx, from.y + dy)
while (true) {
path.push(cursor)
if (samePoint(cursor, to)) break
cursor = point(cursor.x + dx, cursor.y + dy)
}
}
return path
}
export function orderedSpan(left: number, right: number): DiagramSpan {
return left <= right ? { start: left, end: right } : { start: right, end: left }
}
export function segmentSpan(segment: DiagramSegment): DiagramSpan {
return orderedSpan(coordinate(segment.from, segment.axis), coordinate(segment.to, segment.axis))
}
export function pointOnSegment(segment: DiagramSegment, coordinateValue: number): DiagramPoint {
return withCoordinate(segment.from, segment.axis, coordinateValue)
}
export function insetSpan(span: DiagramSpan, amount: number): DiagramSpan {
return { start: span.start + amount, end: span.end - amount }
}
export function spanCapacity(span: DiagramSpan): number {
return Math.max(0, span.end - span.start + 1)
}
export function centeredSpanStart(span: DiagramSpan, width: number): number {
return span.start + Math.floor((spanCapacity(span) - width) / 2)
}
export function midpoint(span: DiagramSpan): number {
return Math.round((span.start + span.end) / 2)
}
export function advanceCoordinate(origin: number, direction: DiagramDirection, distance: number): number {
return origin + directionSign(direction) * distance
}
export function beforeNearestCoordinate(
points: readonly DiagramPoint[],
axis: DiagramAxis,
direction: DiagramDirection,
clearance: number,
): number {
const coordinates = points.map((point) => coordinate(point, axis))
const nearest = directionSign(direction) > 0 ? Math.min(...coordinates) : Math.max(...coordinates)
return advanceCoordinate(nearest, direction, -clearance)
}
export function afterFarthestCoordinate(
points: readonly DiagramPoint[],
axis: DiagramAxis,
direction: DiagramDirection,
clearance: number,
): number {
const coordinates = points.map((point) => coordinate(point, axis))
const farthest = directionSign(direction) > 0 ? Math.max(...coordinates) : Math.min(...coordinates)
return advanceCoordinate(farthest, direction, clearance)
}
export function keepBefore(preferred: number, boundary: number, direction: DiagramDirection): number {
return directionSign(direction) > 0 ? Math.min(preferred, boundary) : Math.max(preferred, boundary)
}
export function keepAfter(preferred: number, boundary: number, direction: DiagramDirection): number {
return directionSign(direction) > 0 ? Math.max(preferred, boundary) : Math.min(preferred, boundary)
}
+33
View File
@@ -0,0 +1,33 @@
export interface NumberedMermaidLine {
readonly lineNumber: number
readonly text: string
}
export function* numberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
const lines = content.split(/\r?\n/)
for (let index = 0; index < lines.length; index += 1) {
yield { lineNumber: index + 1, text: lines[index]!.trim() }
}
}
export function* meaningfulNumberedMermaidLines(content: string): Generator<NumberedMermaidLine> {
for (const line of numberedMermaidLines(content)) {
if (line.text && !line.text.startsWith("%%")) yield line
}
}
export function firstMeaningfulMermaidLine(content: string): string | undefined {
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (line && !line.startsWith("%%")) return line
}
return undefined
}
export function stripMermaidQuotes(value: string): string {
const trimmed = value.trim()
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1)
}
return trimmed
}
+23
View File
@@ -0,0 +1,23 @@
import { StyledText, type TextChunk } from "@opentui/core"
import type { DiagramCanvas, DiagramCanvasRun, DiagramCanvasRunOptions } from "./canvas.js"
export function renderDiagramGridStyledText<Style extends string, Metadata extends object = object>(
grid: DiagramCanvas<Style, Metadata>,
fg: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["fg"],
bg?: (run: DiagramCanvasRun<Style, Metadata>) => TextChunk["bg"],
options?: DiagramCanvasRunOptions<Style, Metadata>,
): StyledText {
const chunks: TextChunk[] = []
grid.forEachRun(
(run) => {
chunks.push({ __isChunk: true, text: run.text, fg: fg(run), bg: bg?.(run) })
},
() => {
chunks.push({ __isChunk: true, text: "\n" })
},
options,
)
return new StyledText(chunks)
}
+3
View File
@@ -0,0 +1,3 @@
export function splitDiagramLines(value: string): string[] {
return value.split(/<br\s*\/?>/i).map((line) => line.trim())
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "./text.js"
describe("diagram text helpers", () => {
test("splits Mermaid-style line breaks", () => {
expect(splitDiagramLines("one<br/> two <br>three")).toEqual(["one", "two", "three"])
})
test("measures padded text boxes", () => {
expect(measureDiagramTextBox("wide<br/>x", { paddingX: 2, paddingY: 1 })).toEqual({
width: 8,
height: 4,
lines: ["wide", "x"],
})
})
test("measures terminal cell width", () => {
expect(diagramTextWidth("abc")).toBe(3)
})
})
+33
View File
@@ -0,0 +1,33 @@
import stringWidth from "string-width"
import { splitDiagramLines } from "./text-lines.js"
export { splitDiagramLines } from "./text-lines.js"
export interface DiagramTextBoxSize {
width: number
height: number
lines: string[]
}
export function diagramTextWidth(value: string): number {
return stringWidth(value)
}
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function* diagramTextGraphemes(value: string): Generator<string> {
for (const { segment } of graphemeSegmenter.segment(value)) yield segment
}
export function measureDiagramTextBox(
value: string,
options: { paddingX?: number; paddingY?: number; minInnerWidth?: number } = {},
): DiagramTextBoxSize {
const lines = splitDiagramLines(value)
const innerWidth = Math.max(...lines.map(diagramTextWidth), options.minInnerWidth ?? 1)
return {
width: innerWidth + (options.paddingX ?? 0) * 2,
height: lines.length + (options.paddingY ?? 0) * 2,
lines,
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
return undefined
}
+16
View File
@@ -0,0 +1,16 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
/** An otherwise valid diagram contains syntax that merman does not support. */
export class MermaidSyntaxError extends Error {
readonly _tag = "MermaidSyntaxError"
constructor(
readonly kind: MermaidDiagramKind,
readonly lineNumber: number,
readonly sourceLine: string,
reason = "Unsupported syntax",
) {
super(`${reason} in ${kind} diagram at line ${lineNumber}: "${sourceLine}"`)
this.name = "MermaidSyntaxError"
}
}
+291
View File
@@ -0,0 +1,291 @@
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
import { walkOrthogonalSegment } from "../core/geometry.js"
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
import { splitDiagramLines } from "../core/text.js"
import {
DIAGRAM_ARROW_HEADS,
diagramArrowHeadBetween,
diagramDiamondCharactersFromBorder,
diagramLineGlyph,
drawDiagramDiamond,
drawDiagramFrame,
fillDiagramFrameInterior,
drawOrthogonalPath,
mergeDiagramLineGlyph,
} from "../core/drawing.js"
import { layoutFlowchartDiagram, visualLength } from "./layout.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { flowchartDirectionBetween, flowchartSourceConnector } from "./routing.js"
import {
DATABASE_EDGE_FADE_STYLES,
NODE_EDGE_FADE_STYLES,
type FlowchartCellStyle,
type FlowchartEdgeFadeStyle,
type FlowchartGrid,
} from "./style.js"
import type {
FlowchartDiagram,
FlowchartEdgeRoute,
FlowchartNode,
FlowchartNodeBounds,
FlowchartPoint,
FlowchartSubgraphBounds,
} from "./types.js"
export const DEFAULT_BORDER_STYLE = "rounded" satisfies BorderStyle
function mergeFlowchartCell(
existing: DiagramCanvasCell<FlowchartCellStyle>,
incoming: DiagramCanvasCell<FlowchartCellStyle>,
): DiagramCanvasCell<FlowchartCellStyle> {
if (incoming.style !== "edge") return incoming
if (existing.style === "label") return existing
if (incoming.char === " ") return existing
if (existing.style !== "edge" || existing.char === " ") return incoming
if (DIAGRAM_ARROW_HEADS.has(existing.char) || DIAGRAM_ARROW_HEADS.has(incoming.char)) return incoming
return {
...incoming,
char: mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char,
} as DiagramCanvasCell<FlowchartCellStyle>
}
function setNodeText(grid: FlowchartGrid, x: number, y: number, text: string, style: FlowchartCellStyle): void {
grid.setText(x, y, text, style)
}
function drawNode(
grid: FlowchartGrid,
node: FlowchartNode,
bounds: FlowchartNodeBounds,
borderStyle: BorderStyle,
): void {
const chars = BorderChars[borderStyle]
const style: FlowchartCellStyle = node.shape === "database" ? "database" : "node"
if (node.shape === "decision") {
drawDiagramDiamond(
bounds,
(x, y, char) => grid.setCell(x, y, char, style),
diagramDiamondCharactersFromBorder(chars),
)
} else if (node.shape === "subroutine") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawSubroutineNode(grid, bounds, chars, style)
} else if (node.shape === "database") {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawDatabaseNode(grid, bounds, chars, style)
} else {
fillDiagramFrameInterior(bounds, (x, y) => grid.setCell(x, y, " ", style))
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
}
const textTop =
node.shape === "decision"
? bounds.top + Math.floor((bounds.height - bounds.lines.length) / 2)
: node.shape === "database"
? bounds.top + 2
: bounds.top + 1
for (const [index, line] of bounds.lines.entries()) {
const lineX =
node.shape === "subroutine"
? bounds.left + 3
: bounds.left + Math.max(1, Math.floor((bounds.width - visualLength(line)) / 2))
setNodeText(grid, lineX, textTop + index, line, style)
}
}
function drawSubroutineNode(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
chars: BorderCharacters,
style: FlowchartCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
const leftRailX = bounds.left + 2
const rightRailX = bounds.left + bounds.width - 3
grid.setCell(leftRailX, bounds.top, chars.topT, style)
grid.setCell(rightRailX, bounds.top, chars.topT, style)
grid.setCell(leftRailX, bounds.top + bounds.height - 1, chars.bottomT, style)
grid.setCell(rightRailX, bounds.top + bounds.height - 1, chars.bottomT, style)
for (let y = bounds.top + 1; y < bounds.top + bounds.height - 1; y++) {
grid.setCell(leftRailX, y, chars.vertical, style)
grid.setCell(rightRailX, y, chars.vertical, style)
}
}
function drawDatabaseNode(
grid: FlowchartGrid,
bounds: FlowchartNodeBounds,
chars: BorderCharacters,
style: FlowchartCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, style))
const topRailY = bounds.top + 1
const bottomRailY = bounds.top + bounds.height - 2
for (const y of [topRailY, bottomRailY]) {
grid.setCell(bounds.left, y, chars.leftT, style)
grid.setCell(bounds.left + bounds.width - 1, y, chars.rightT, style)
for (let x = bounds.left + 1; x < bounds.left + bounds.width - 1; x++) {
grid.setCell(x, y, chars.horizontal, style)
}
}
}
function drawSubgraphFrame(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds, borderStyle: BorderStyle): void {
const chars = BorderChars[borderStyle]
drawDiagramFrame(bounds, chars, (x, y, char) => grid.setCell(x, y, char, "group"))
}
function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds): void {
if (bounds.label) {
const lines = splitDiagramLines(bounds.label)
const labelY = bounds.labelSide === "top" ? bounds.top : bounds.top + bounds.height - lines.length
for (const [index, line] of lines.entries()) {
grid.setText(bounds.left + 2, labelY + index, ` ${line} `, "group")
}
}
}
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
for (const [index, line] of label.lines.entries()) {
grid.setText(label.point.x, label.point.y + index, line, style)
}
}
function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute): void {
const { edge, points } = route
if (points.length < 2) return
const style: FlowchartCellStyle = "edge"
drawOrthogonalPath(points, (x, y, char) => grid.setCell(x, y, char, style), {
cornerStyle: "rounded",
lineStyle: edge.style === "thick" ? "heavy" : "single",
})
if (edge.arrowhead !== false) {
const end = points[points.length - 1]!
const arrowFrom = points[points.length - 2]!
grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style)
}
if (edge.label) {
drawEdgeLabel(grid, route, "label")
}
}
function flowchartNodeStyle(node: FlowchartNode | undefined): "node" | "database" {
return node?.shape === "database" ? "database" : "node"
}
function sourceFadeStyles(sourceStyle: "node" | "database"): readonly FlowchartEdgeFadeStyle[] {
return sourceStyle === "database" ? DATABASE_EDGE_FADE_STYLES : NODE_EDGE_FADE_STYLES
}
function styleExistingEdgeCell(grid: FlowchartGrid, x: number, y: number, style: FlowchartEdgeFadeStyle): void {
const cell = grid.getCell(x, y)
if (cell) grid.setCell(x, y, cell.char, style)
}
function routeCellOccupancy(routes: readonly FlowchartEdgeRoute[]): Map<string, number> {
const occupancy = new Map<string, number>()
for (const route of routes) {
const routeCells = new Set<string>()
for (let index = 1; index < route.points.length; index++) {
walkOrthogonalSegment(route.points[index - 1]!, route.points[index]!, index === 1, (point) => {
routeCells.add(`${point.x}:${point.y}`)
})
}
for (const key of routeCells) occupancy.set(key, (occupancy.get(key) ?? 0) + 1)
}
return occupancy
}
function fadeSourcePath(
grid: FlowchartGrid,
connector: FlowchartPoint,
points: FlowchartPoint[],
styles: readonly FlowchartEdgeFadeStyle[],
occupancy: ReadonlyMap<string, number>,
): void {
const from = points[0]
const to = points[1]
if (!from || !to) return
const privateCells = [connector]
walkOrthogonalSegment(from, to, true, (point) => {
const key = `${point.x}:${point.y}`
const cell = grid.getCell(point.x, point.y)
if (occupancy.get(key) !== 1 || !cell || !"─━│┃".includes(cell.char) || cell.style === "label") return false
privateCells.push(point)
})
for (const [index, point] of privateCells.entries()) {
const styleIndex = Math.min(
styles.length - 1,
Math.floor(((index + 1) * styles.length) / (privateCells.length + 1)),
)
styleExistingEdgeCell(grid, point.x, point.y, styles[styleIndex]!)
}
}
function drawSourceConnectors(
grid: FlowchartGrid,
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
routes: readonly FlowchartEdgeRoute[],
): void {
const nodesById = new Map(diagram.nodes.map((node) => [node.id, node]))
const occupancy = routeCellOccupancy(routes)
for (const route of routes) {
const from = bounds.get(route.edge.from)
const sourcePoint = route.points[0]
if (!from || !sourcePoint) continue
const styles = sourceFadeStyles(flowchartNodeStyle(nodesById.get(route.edge.from)))
const connector = flowchartSourceConnector(from, sourcePoint)
grid.setCell(connector.x, connector.y, connector.char, "edge")
const routeDirection = route.points[1] ? flowchartDirectionBetween(sourcePoint, route.points[1]!) : undefined
const connectorDirection = flowchartDirectionBetween(sourcePoint, connector)
if (routeDirection && connectorDirection) {
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
if (cell) {
cell.char = diagramLineGlyph(
new Set([routeDirection, connectorDirection]),
"rounded",
route.edge.style === "thick" ? "heavy" : "single",
)
cell.style = "edge"
}
}
fadeSourcePath(grid, connector, route.points, styles, occupancy)
}
}
export function drawFlowchartDiagramGrid(
diagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions = {},
): FlowchartGrid {
const borderStyle = options.borderStyle ?? DEFAULT_BORDER_STYLE
const layout = layoutFlowchartDiagram(diagram, options)
const { bounds, routes, subgraphBounds, width, height } = layout
diagram = layout.diagram
const grid = new DiagramCanvas<FlowchartCellStyle>(width, height, {
mergeCell: mergeFlowchartCell,
})
for (const subgraph of diagram.subgraphs ?? []) {
const bound = subgraphBounds.get(subgraph.id)
if (bound) drawSubgraphFrame(grid, bound, borderStyle)
}
for (const route of routes) drawRoutedEdge(grid, route)
for (const node of diagram.nodes) {
const bound = bounds.get(node.id)
if (bound) drawNode(grid, node, bound, borderStyle)
}
drawSourceConnectors(grid, diagram, bounds, routes)
for (const subgraph of diagram.subgraphs ?? []) {
const bound = subgraphBounds.get(subgraph.id)
if (bound) drawSubgraphLabel(grid, bound)
}
return grid
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { flowchartEdgeLabelLayout } from "./labels.js"
const measure = (text: string): number => text.length
describe("flowchart edge labels", () => {
test("places vertical-route labels beside the bus", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 22, y: 3 },
{ x: 22, y: 7 },
],
"rollback",
measure,
).point,
).toEqual({ x: 23, y: 5 })
})
test("places labels inline only when padded text fits with clearance", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 13, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 2 })
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 9, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 1 })
expect(
flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 7, y: 2 },
],
"rollback",
measure,
).point,
).toEqual({ x: 2, y: 1 })
})
test("uses vertical bus labels before short terminal branches", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 155, y: 5 },
{ x: 150, y: 5 },
{ x: 150, y: 9 },
{ x: 146, y: 9 },
],
"rollback",
measure,
).point,
).toEqual({ x: 151, y: 7 })
})
test("measures br-delimited edge label lines as a block", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 0, y: 2 },
{ x: 20, y: 2 },
],
"first<br/>second line",
measure,
)
expect(layout.lines).toEqual([" first ", " second line "])
expect(layout.width).toBe(13)
expect(layout.height).toBe(2)
})
test("places multiline horizontal edge labels outside the route row", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 0, y: 5 },
{ x: 20, y: 5 },
],
"first<br/>second",
measure,
)
expect(layout.point.y + layout.height).toBeLessThanOrEqual(5)
})
test("centers multiline vertical edge labels beside their route", () => {
const layout = flowchartEdgeLabelLayout(
[
{ x: 22, y: 2 },
{ x: 22, y: 10 },
],
"one<br/>two<br/>three",
measure,
)
expect(layout.point).toEqual({ x: 23, y: 5 })
expect(layout.height).toBe(3)
})
})
+104
View File
@@ -0,0 +1,104 @@
import {
clampPoint,
centeredSpanStart,
insetSpan,
midpoint,
point,
pointOnSegment,
segmentBetween,
segmentSpan,
shiftPoint,
spanCapacity,
type DiagramSegment,
} from "../core/geometry.js"
import { splitDiagramLines } from "../core/text.js"
import type { FlowchartPoint } from "./types.js"
const LABEL_BUS_CLEARANCE = 3
const LABEL_NODE_CLEARANCE = 2
const LABEL_LINE_CLEARANCE = 2
const LABEL_PADDING = 1
export interface FlowchartEdgeLabelLayout {
lines: string[]
point: FlowchartPoint
width: number
height: number
}
export function flowchartLabelText(label: string): string {
return `${" ".repeat(LABEL_PADDING)}${label}${" ".repeat(LABEL_PADDING)}`
}
export function flowchartLabelWidth(label: string, measure: (text: string) => number): number {
return Math.max(...splitDiagramLines(label).map((line) => measure(line) + LABEL_PADDING * 2))
}
function minimumInlineLabelLength(labelWidth: number): number {
return labelWidth + LABEL_LINE_CLEARANCE * 2 - 1
}
export function flowchartHorizontalLabelRankGap(labelWidth: number): number {
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + 1
}
export function flowchartVerticalBranchLabelGap(labelWidth: number): number {
return minimumInlineLabelLength(labelWidth) + LABEL_BUS_CLEARANCE + LABEL_NODE_CLEARANCE
}
function inlineLabelSlot(segment: DiagramSegment, labelWidth: number): { x: number; fits: boolean } {
const slot = insetSpan(segmentSpan(segment), LABEL_LINE_CLEARANCE)
return { x: centeredSpanStart(slot, labelWidth), fits: spanCapacity(slot) >= labelWidth }
}
function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHeight: number): FlowchartPoint {
if (segment.axis === "x") {
const slot = inlineLabelSlot(segment, labelWidth)
if (labelHeight === 1 && slot.fits) return point(slot.x, segment.from.y)
if (labelHeight > 1) {
return shiftPoint(point(slot.x, segment.from.y), "up", labelHeight)
}
return clampPoint(shiftPoint(shiftPoint(segment.from, segment.direction, LABEL_LINE_CLEARANCE), "up", labelHeight))
}
const center = shiftPoint(pointOnSegment(segment, midpoint(segmentSpan(segment))), "right")
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
let roomyHorizontal: DiagramSegment | undefined
let verticalBus: DiagramSegment | undefined
let longest: DiagramSegment | undefined
for (let index = 1; index < points.length; index++) {
const segment = segmentBetween(points[index - 1]!, points[index]!)
if (!segment) continue
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
if (!verticalBus && segment.axis === "y") verticalBus = segment
if (!longest || segment.length > longest.length) longest = segment
}
return roomyHorizontal ?? verticalBus ?? longest
}
function flowchartLabelPoint(
points: readonly FlowchartPoint[],
labelWidth: number,
labelHeight: number,
): FlowchartPoint {
const segment = bestLabelSegment(points, labelWidth)
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
}
export function flowchartEdgeLabelLayout(
points: readonly FlowchartPoint[],
label: string,
measure: (text: string) => number,
): FlowchartEdgeLabelLayout {
const lines = splitDiagramLines(label).map(flowchartLabelText)
const width = flowchartLabelWidth(label, measure)
const height = lines.length
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
}
+807
View File
@@ -0,0 +1,807 @@
import {
diagramBoundsFromBounds,
diagramBoundsFromPoints,
segmentBetween,
segmentSpan,
translateDiagramBounds,
} from "../core/geometry.js"
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
import {
flowchartEdgeLabelLayout,
flowchartHorizontalLabelRankGap,
flowchartLabelWidth,
flowchartVerticalBranchLabelGap,
} from "./labels.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { routeFlowchartEdges } from "./routing.js"
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeRoute,
FlowchartNode,
FlowchartNodeBounds,
FlowchartNodeSize,
FlowchartSubgraphBounds,
} from "./types.js"
export const DEFAULT_MIN_NODE_GAP = 5
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
export const DEFAULT_MIN_RANK_GAP = 7
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
export const COMPACT_MIN_RANK_GAP = 4
export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
const SUBGRAPH_PADDING_X = 2
const SUBGRAPH_PADDING_TOP = 1
const SUBGRAPH_PADDING_BOTTOM = 1
export interface FlowchartLayout {
diagram: FlowchartDiagram
bounds: Map<string, FlowchartNodeBounds>
routes: FlowchartEdgeRoute[]
subgraphBounds: Map<string, FlowchartSubgraphBounds>
width: number
height: number
}
type FlowchartBounds = Pick<FlowchartSubgraphBounds, "left" | "top" | "width" | "height" | "centerX" | "centerY">
function horizontalRankGaps(
diagram: FlowchartDiagram,
normalizedRanks: ReadonlyMap<string, number>,
rankKeys: readonly number[],
fallback: number,
): number[] {
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
for (const edge of diagram.edges) {
if (!edge.label) continue
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
const labelGap = flowchartHorizontalLabelRankGap(flowchartLabelWidth(edge.label, visualLength))
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
gaps[index] = Math.max(gaps[index]!, labelGap)
}
}
return gaps
}
function verticalRankGaps(
diagram: FlowchartDiagram,
normalizedRanks: ReadonlyMap<string, number>,
rankKeys: readonly number[],
fallback: number,
): number[] {
const gaps = Array.from({ length: Math.max(0, rankKeys.length - 1) }, () => fallback)
const rankIndexes = new Map(rankKeys.map((rank, index) => [rank, index]))
for (const edge of diagram.edges) {
if (!edge.label) continue
const fromIndex = rankIndexes.get(normalizedRanks.get(edge.from) ?? -1)
const toIndex = rankIndexes.get(normalizedRanks.get(edge.to) ?? -1)
if (fromIndex === undefined || toIndex === undefined || fromIndex === toIndex) continue
const labelHeight = splitDiagramLines(edge.label).length
for (let index = Math.min(fromIndex, toIndex); index < Math.max(fromIndex, toIndex); index++) {
gaps[index] = Math.max(gaps[index]!, labelHeight + 2)
}
}
return gaps
}
function isHorizontalDirection(direction: FlowchartDirection): boolean {
return direction === "LR" || direction === "RL"
}
export function visualLength(value: string): number {
return diagramTextWidth(value)
}
export function normalizePositiveInt(value: number | undefined, fallback: number): number {
if (value === undefined || !Number.isFinite(value)) return fallback
return Math.max(1, Math.trunc(value))
}
function nodeSize(node: FlowchartNode): FlowchartNodeSize {
const { lines, width } = measureDiagramTextBox(node.label, { paddingX: 2 })
const innerWidth = width - 4
if (node.shape === "decision") {
const width = innerWidth + 6
return {
width: width % 2 === 0 ? width + 1 : width,
height: Math.max(5, lines.length + 4),
lines,
}
}
if (node.shape === "database") return { width: innerWidth + 4, height: lines.length + 4, lines }
if (node.shape === "subroutine") return { width: innerWidth + 6, height: lines.length + 2, lines }
return { width: innerWidth + 4, height: lines.length + 2, lines }
}
function rankNodes(diagram: FlowchartDiagram): Map<string, number> {
const ranks = new Map<string, number>()
const outgoing = new Map<string, string[]>()
const incoming = new Set<string>()
const incomingCounts = new Map(diagram.nodes.map((node) => [node.id, 0]))
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
const rankEdges = diagram.edges.flatMap((edge) => {
if (!edge.orderOnly) return [edge]
const fromIds = nodeIds.has(edge.from) ? [edge.from] : [...collectSubgraphNodeIds(diagram, edge.from)]
const toIds = nodeIds.has(edge.to) ? [edge.to] : [...collectSubgraphNodeIds(diagram, edge.to)]
return fromIds.flatMap((from) => toIds.map((to) => ({ ...edge, from, to })))
})
for (const edge of rankEdges) {
const list = outgoing.get(edge.from) ?? []
list.push(edge.to)
outgoing.set(edge.from, list)
incoming.add(edge.to)
incomingCounts.set(edge.to, (incomingCounts.get(edge.to) ?? 0) + 1)
}
const starts = diagram.nodes.filter((node) => !incoming.has(node.id))
if (starts.length === 0 && diagram.nodes[0]) starts.push(diagram.nodes[0])
const queue = starts.map((node) => node.id)
for (const node of starts) ranks.set(node.id, 0)
for (let index = 0; index < queue.length; index++) {
const id = queue[index]!
const rank = ranks.get(id) ?? 0
for (const to of outgoing.get(id) ?? []) {
const nextRank = rank + 1
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
ranks.set(to, nextRank)
queue.push(to)
}
}
for (const node of diagram.nodes) {
if (!ranks.has(node.id)) ranks.set(node.id, ranks.size)
}
const acyclicRanks = new Map<string, number>()
const acyclicQueue = diagram.nodes.filter((node) => incomingCounts.get(node.id) === 0).map((node) => node.id)
for (const id of acyclicQueue) acyclicRanks.set(id, 0)
for (let index = 0; index < acyclicQueue.length; index++) {
const id = acyclicQueue[index]!
const rank = acyclicRanks.get(id) ?? 0
for (const to of outgoing.get(id) ?? []) {
acyclicRanks.set(to, Math.max(acyclicRanks.get(to) ?? 0, rank + 1))
const remainingIncoming = (incomingCounts.get(to) ?? 0) - 1
incomingCounts.set(to, remainingIncoming)
if (remainingIncoming === 0) acyclicQueue.push(to)
}
}
for (const [id, rank] of acyclicRanks) ranks.set(id, rank)
return ranks
}
function translateBounds(bounds: FlowchartBounds, dx: number, dy: number): void {
translateDiagramBounds(bounds, dx, dy)
}
function translateRoutes(routes: readonly FlowchartEdgeRoute[], dx: number, dy: number): void {
for (const route of routes) {
for (const point of route.points) {
point.x += dx
point.y += dy
}
}
}
function boundsFromChildren(children: readonly FlowchartBounds[]): FlowchartBounds | undefined {
return diagramBoundsFromBounds(children)
}
function subgraphBoundFromChildren(
id: string,
label: string,
children: readonly FlowchartBounds[],
): FlowchartSubgraphBounds {
const labelLines = splitDiagramLines(label)
const labelHeight = labelLines.length
let left = Math.min(...children.map((child) => child.left)) - SUBGRAPH_PADDING_X
const top = Math.min(...children.map((child) => child.top)) - Math.max(SUBGRAPH_PADDING_TOP, labelHeight)
let right = Math.max(...children.map((child) => child.left + child.width)) + SUBGRAPH_PADDING_X
const bottom =
Math.max(...children.map((child) => child.top + child.height)) + Math.max(SUBGRAPH_PADDING_BOTTOM, labelHeight)
const minWidth = Math.max(...labelLines.map(visualLength)) + 5
if (right - left < minWidth) {
const extra = minWidth - (right - left)
left -= Math.floor(extra / 2)
right += Math.ceil(extra / 2)
}
const width = right - left
const height = Math.max(3, bottom - top)
return {
id,
label,
left,
top,
width,
height,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
labelSide: "top",
}
}
function spansOverlap(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number): boolean {
return leftStart <= rightEnd && rightStart <= leftEnd
}
function labelSlot(bounds: FlowchartSubgraphBounds, side: FlowchartSubgraphBounds["labelSide"]): FlowchartBounds {
const lines = splitDiagramLines(bounds.label)
const left = bounds.left + 2
const height = lines.length
const top = side === "top" ? bounds.top : bounds.top + bounds.height - height
const width = Math.max(...lines.map((line) => visualLength(` ${line} `)))
return { left, top, width, height, centerX: left + Math.floor(width / 2), centerY: top + Math.floor(height / 2) }
}
function segmentOverlapsSlot(
from: { x: number; y: number },
to: { x: number; y: number },
slot: FlowchartBounds,
): boolean {
const segment = segmentBetween(from, to)
if (!segment) return false
const slotRight = slot.left + slot.width - 1
const slotBottom = slot.top + slot.height - 1
const span = segmentSpan(segment)
if (segment.axis === "x") {
return (
segment.from.y >= slot.top &&
segment.from.y <= slotBottom &&
spansOverlap(span.start, span.end, slot.left, slotRight)
)
}
return (
segment.from.x >= slot.left &&
segment.from.x <= slotRight &&
spansOverlap(span.start, span.end, slot.top, slotBottom)
)
}
function routeOverlapsSlot(route: FlowchartEdgeRoute, slot: FlowchartBounds): boolean {
for (let index = 1; index < route.points.length; index++) {
if (segmentOverlapsSlot(route.points[index - 1]!, route.points[index]!, slot)) return true
}
const routeLabelBounds = labelBounds(route)
if (
!routeLabelBounds ||
!spansOverlap(
routeLabelBounds.top,
routeLabelBounds.top + routeLabelBounds.height - 1,
slot.top,
slot.top + slot.height - 1,
)
) {
return false
}
return spansOverlap(
routeLabelBounds.left,
routeLabelBounds.left + routeLabelBounds.width - 1,
slot.left,
slot.left + slot.width - 1,
)
}
function chooseSubgraphLabelSide(
bounds: FlowchartSubgraphBounds,
routes: readonly FlowchartEdgeRoute[],
): FlowchartSubgraphBounds["labelSide"] {
const topSlot = labelSlot(bounds, "top")
if (!routes.some((route) => routeOverlapsSlot(route, topSlot))) return "top"
const bottomSlot = labelSlot(bounds, "bottom")
return routes.some((route) => routeOverlapsSlot(route, bottomSlot)) ? "top" : "bottom"
}
function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBounds | undefined {
return diagramBoundsFromPoints(points)
}
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
if (!route.edge.label) return undefined
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
const { point, width, height } = label
return {
left: point.x,
top: point.y,
width,
height,
centerX: point.x + Math.floor(width / 2),
centerY: point.y + Math.floor(height / 2),
}
}
function subgraphRouteBounds(subgraphNodeIds: Set<string>, routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
return routeRenderBounds(
routes.filter((route) => subgraphNodeIds.has(route.edge.from) && subgraphNodeIds.has(route.edge.to)),
)
}
function routeRenderBounds(routes: readonly FlowchartEdgeRoute[]): FlowchartBounds[] {
const bounds: FlowchartBounds[] = []
for (const route of routes) {
const routeBounds = pathBounds(route.points)
if (routeBounds) bounds.push(routeBounds)
const routeLabelBounds = labelBounds(route)
if (routeLabelBounds) bounds.push(routeLabelBounds)
}
return bounds
}
function layoutRankedNodes(
diagram: FlowchartDiagram,
direction: FlowchartDirection,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
): Map<string, FlowchartNodeBounds> {
const horizontal = isHorizontalDirection(direction)
let widestPaddedEdgeLabel = 0
for (const edge of diagram.edges) {
if (edge.label)
widestPaddedEdgeLabel = Math.max(widestPaddedEdgeLabel, flowchartLabelWidth(edge.label, visualLength))
}
const rankNodeGap = horizontal
? minNodeGap
: Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
const ranks = rankNodes(diagram)
const maxRank = Math.max(0, ...ranks.values())
const ranksByIndex = new Map<number, FlowchartNode[]>()
const normalizedRanks = new Map<string, number>()
for (const node of diagram.nodes) {
const rank = ranks.get(node.id) ?? 0
const normalizedRank = direction === "RL" || direction === "BT" ? maxRank - rank : rank
normalizedRanks.set(node.id, normalizedRank)
const nodes = ranksByIndex.get(normalizedRank) ?? []
nodes.push(node)
ranksByIndex.set(normalizedRank, nodes)
}
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
const horizontalGaps = horizontal ? horizontalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap) : []
const verticalGaps = horizontal ? [] : verticalRankGaps(diagram, normalizedRanks, rankKeys, requestedMinRankGap)
const bounds = new Map<string, FlowchartNodeBounds>()
if (horizontal) {
const columnWidths = rankKeys.map((rank) =>
Math.max(...ranksByIndex.get(rank)!.map((node) => sizes.get(node.id)!.width)),
)
const columnHeights = rankKeys.map((rank) => {
const nodes = ranksByIndex.get(rank)!
return (
nodes.reduce((total, node) => total + sizes.get(node.id)!.height, 0) +
Math.max(0, nodes.length - 1) * rankNodeGap
)
})
const canvasHeight = Math.max(1, ...columnHeights)
const centerBaseline = Math.floor(canvasHeight / 2)
let x = 0
for (let rankIndex = 0; rankIndex < rankKeys.length; rankIndex++) {
const rank = rankKeys[rankIndex]!
const nodes = ranksByIndex.get(rank)!
const columnWidth = columnWidths[rankIndex]!
let y = centerBaseline - Math.floor(columnHeights[rankIndex]! / 2)
for (const node of nodes) {
const size = sizes.get(node.id)!
const left = x + Math.floor((columnWidth - size.width) / 2)
bounds.set(node.id, {
id: node.id,
...size,
left,
top: y,
centerX: left + Math.floor(size.width / 2),
centerY: y + Math.floor(size.height / 2),
})
y += size.height + rankNodeGap
}
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 nodes = ranksByIndex.get(rank)!
return (
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
Math.max(0, nodes.length - 1) * rankNodeGap
)
})
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]!
let x = Math.floor((canvasWidth - rowWidths[rankIndex]!) / 2)
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 + rankNodeGap
}
y += rowHeight + (verticalGaps[rankIndex] ?? 0)
}
}
return bounds
}
function layoutLocalSubgraphDirections(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
sizes: ReadonlyMap<string, FlowchartNodeSize>,
minNodeGap: number,
requestedMinRankGap: number,
): void {
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 currentBounds = boundsFromChildren(nodes.flatMap((node) => nodeBounds.get(node.id) ?? []))
if (!currentBounds) continue
const localDiagram: FlowchartDiagram = {
direction: subgraph.direction,
nodes,
edges: diagram.edges.filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to)),
subgraphs: [],
}
const localNodeGap = isHorizontalDirection(subgraph.direction) ? Math.max(4, minNodeGap - 1) : minNodeGap
const localBounds = layoutRankedNodes(localDiagram, subgraph.direction, sizes, localNodeGap, requestedMinRankGap)
const localExtent = boundsFromChildren([...localBounds.values()])
if (!localExtent) continue
const targetLeft = currentBounds.left + Math.floor((currentBounds.width - localExtent.width) / 2)
const targetTop = currentBounds.top + Math.floor((currentBounds.height - localExtent.height) / 2)
const dx = targetLeft - localExtent.left
const dy = targetTop - localExtent.top
for (const [nodeId, bound] of localBounds) {
translateBounds(bound, dx, dy)
nodeBounds.set(nodeId, bound)
}
}
}
function edgeDirection(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartDirection {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (!subgraph.direction) continue
if (subgraph.nodeIds.includes(edge.from) && subgraph.nodeIds.includes(edge.to)) return subgraph.direction
}
return diagram.direction
}
function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string): Set<string> {
const nodeIds = new Set<string>()
for (const subgraph of diagram.subgraphs ?? []) {
if (subgraph.id !== subgraphId && subgraph.parentId !== subgraphId) continue
for (const nodeId of subgraph.nodeIds) nodeIds.add(nodeId)
if (subgraph.parentId === subgraphId) {
for (const nodeId of collectSubgraphNodeIds(diagram, subgraph.id)) nodeIds.add(nodeId)
}
}
return nodeIds
}
function rankGraphComponents(ids: readonly string[], outgoing: ReadonlyMap<string, ReadonlySet<string>>): Map<string, number> {
const reachable = new Map<string, Set<string>>()
for (const id of ids) {
const seen = new Set<string>()
const queue = [id]
for (let index = 0; index < queue.length; index++) {
const current = queue[index]!
if (seen.has(current)) continue
seen.add(current)
queue.push(...(outgoing.get(current) ?? []))
}
reachable.set(id, seen)
}
const componentById = new Map<string, number>()
const components: string[][] = []
for (const id of ids) {
if (componentById.has(id)) continue
const component = ids.filter(
(candidate) => !componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
)
const componentIndex = components.length
components.push(component)
for (const member of component) componentById.set(member, componentIndex)
}
const componentOutgoing = new Map(components.map((_, index) => [index, new Set<number>()]))
const incoming = new Map(components.map((_, index) => [index, 0]))
for (const [from, targets] of outgoing) {
const fromComponent = componentById.get(from)!
for (const to of targets) {
const toComponent = componentById.get(to)!
if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
componentOutgoing.get(fromComponent)!.add(toComponent)
incoming.set(toComponent, incoming.get(toComponent)! + 1)
}
}
const componentRanks = new Map<number, number>()
const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
for (const component of queue) componentRanks.set(component, 0)
for (let index = 0; index < queue.length; index++) {
const component = queue[index]!
for (const to of componentOutgoing.get(component)!) {
componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
incoming.set(to, incoming.get(to)! - 1)
if (incoming.get(to) === 0) queue.push(to)
}
}
return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
}
function separateTopLevelItems(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
gap: number,
): boolean {
const hasLocalDirection = (diagram.subgraphs ?? []).some(
(subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
)
const coveredNodeIds = new Set<string>()
const items: { id: string; bounds: FlowchartBounds; nodeIds: Set<string>; rank: number }[] = []
const itemByEndpoint = new Map<string, string>()
const subgraphs = diagram.subgraphs ?? []
const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
const topLevelSubgraphId = (id: string): string => {
let current = subgraphById.get(id)
while (current?.parentId) current = subgraphById.get(current.parentId)
return current?.id ?? id
}
for (const subgraph of subgraphs) {
if (subgraph.parentId) continue
const bounds = subgraphBounds.get(subgraph.id)
const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
if (!bounds || nodeIds.size === 0) continue
items.push({ id: subgraph.id, bounds, nodeIds, rank: 0 })
itemByEndpoint.set(subgraph.id, subgraph.id)
for (const nodeId of nodeIds) {
coveredNodeIds.add(nodeId)
itemByEndpoint.set(nodeId, subgraph.id)
}
}
for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
for (const node of diagram.nodes) {
if (coveredNodeIds.has(node.id)) continue
const bounds = nodeBounds.get(node.id)
if (!bounds) continue
items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
itemByEndpoint.set(node.id, node.id)
}
if (items.length < 2) return false
const horizontal = isHorizontalDirection(diagram.direction)
const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
for (const nodeId of item.nodeIds) {
const bounds = nodeBounds.get(nodeId)
if (bounds) translateBounds(bounds, dx, dy)
}
}
if (hasLocalDirection) {
items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
let cursor: number | undefined
let moved = false
for (const item of items) {
const start = horizontal ? item.bounds.left : item.bounds.top
const size = horizontal ? item.bounds.width : item.bounds.height
if (cursor === undefined) {
cursor = start + size + gap
continue
}
const shift = cursor - start
moved ||= shift !== 0
moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
cursor = start + shift + size + gap
}
return moved
}
const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
const rankedItems = items.filter((item) => topLevelIds.has(item.id))
if (rankedItems.length < 2) return false
const itemById = new Map(rankedItems.map((item) => [item.id, item]))
const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
for (const edge of diagram.edges) {
const from = itemByEndpoint.get(edge.from)
const to = itemByEndpoint.get(edge.to)
if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
outgoing.get(from)!.add(to)
}
const ranks = rankGraphComponents(
rankedItems.map((item) => item.id),
outgoing,
)
for (const item of rankedItems) item.rank = ranks.get(item.id)!
const reversed = diagram.direction === "RL" || diagram.direction === "BT"
const primaryStart = (item: (typeof items)[number]): number => {
const start = horizontal ? item.bounds.left : item.bounds.top
const size = horizontal ? item.bounds.width : item.bounds.height
return reversed ? -(start + size) : start
}
const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
let cursor: number | undefined
let moved = false
for (const rank of rankKeys) {
const rankItems = itemsByRank.get(rank)!
const start = Math.min(...rankItems.map(primaryStart))
const end = Math.max(
...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
)
if (cursor === undefined) {
cursor = end + gap
continue
}
const shift = Math.max(0, cursor - start)
if (shift > 0) {
moved = true
for (const item of rankItems) {
const offset = reversed ? -shift : shift
moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
}
}
cursor = end + shift + gap
}
for (const rank of rankKeys) {
const rankItems = itemsByRank
.get(rank)!
.toSorted((a, b) =>
horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left,
)
let crossCursor: number | undefined
for (const item of rankItems) {
const start = horizontal ? item.bounds.top : item.bounds.left
const size = horizontal ? item.bounds.height : item.bounds.width
if (crossCursor === undefined) {
crossCursor = start + size + gap
continue
}
const shift = Math.max(0, crossCursor - start)
if (shift > 0) {
moved = true
moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
}
crossCursor = start + shift + size + gap
}
}
return moved
}
function layoutSubgraphs(
diagram: FlowchartDiagram,
nodeBounds: Map<string, FlowchartNodeBounds>,
routes: readonly FlowchartEdgeRoute[],
): Map<string, FlowchartSubgraphBounds> {
const subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
const subgraphs = diagram.subgraphs ?? []
for (const subgraph of [...subgraphs].reverse()) {
const children: FlowchartBounds[] = []
for (const nodeId of subgraph.nodeIds) {
const bound = nodeBounds.get(nodeId)
if (bound) children.push(bound)
}
children.push(...subgraphRouteBounds(new Set(subgraph.nodeIds), routes))
for (const childSubgraph of subgraphs) {
if (childSubgraph.parentId !== subgraph.id) continue
const bound = subgraphBounds.get(childSubgraph.id)
if (bound) children.push(bound)
}
if (children.length > 0) {
const bound = subgraphBoundFromChildren(subgraph.id, subgraph.label, children)
bound.labelSide = chooseSubgraphLabelSide(bound, routes)
subgraphBounds.set(subgraph.id, bound)
}
}
return subgraphBounds
}
function layoutFlowchartWithDirection(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions,
direction: FlowchartDirection,
): FlowchartLayout {
const diagram = direction === sourceDiagram.direction ? sourceDiagram : { ...sourceDiagram, direction }
const horizontal = isHorizontalDirection(direction)
const minNodeGap = normalizePositiveInt(options.minNodeGap, DEFAULT_MIN_NODE_GAP)
const requestedMinRankGap = normalizePositiveInt(
options.minRankGap,
options.compact
? horizontal
? COMPACT_MIN_RANK_GAP
: COMPACT_MIN_VERTICAL_RANK_GAP
: horizontal
? DEFAULT_MIN_RANK_GAP
: 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 subgraphs = diagram.subgraphs ?? []
let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
let routes: FlowchartEdgeRoute[]
if (subgraphs.length === 0) {
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
} else {
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
const moved = separateTopLevelItems(
diagram,
bounds,
subgraphBounds,
Math.max(1, Math.floor(requestedMinRankGap / 2)),
)
if (moved) {
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
}
routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
}
const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))
if (dx > 0 || dy > 0) {
for (const bound of allBounds) translateBounds(bound, dx, dy)
translateRoutes(routes, dx, dy)
}
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
const maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
return { diagram, bounds, routes, subgraphBounds, width: maxX + 4, height: maxY + 4 }
}
export function layoutFlowchartDiagram(
sourceDiagram: FlowchartDiagram,
options: FlowchartDiagramRenderOptions = {},
): FlowchartLayout {
const direction = options.direction ?? sourceDiagram.direction
const layout = layoutFlowchartWithDirection(sourceDiagram, options, direction)
const maxWidth = options.layoutMaxWidth
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")
}
+12
View File
@@ -0,0 +1,12 @@
import type { BorderStyle } from "@opentui/core"
import type { FlowchartDirection } from "./types.js"
export interface FlowchartDiagramRenderOptions {
compact?: boolean
direction?: FlowchartDirection
borderStyle?: BorderStyle
minNodeGap?: number
minRankGap?: number
/** Fold oversized horizontal layouts vertically when their rendered width exceeds this limit. */
layoutMaxWidth?: number
}
+269
View File
@@ -0,0 +1,269 @@
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeStyle,
FlowchartNode,
FlowchartSubgraph,
} from "./types.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import {
firstMeaningfulMermaidLine,
meaningfulNumberedMermaidLines,
stripMermaidQuotes as stripQuotes,
} from "../core/mermaid.js"
const DEFAULT_DIRECTION = "TD" satisfies FlowchartDirection
const FLOWCHART_HEADER_RE = /^(flowchart|graph)(?:\s+(TB|TD|BT|LR|RL))?$/i
const ID_RE = "[A-Za-z_][A-Za-z0-9_.-]*"
const SUBGRAPH_RE = /^subgraph\s+(.+)$/i
const SUBGRAPH_WITH_LABEL_RE = new RegExp(`^(${ID_RE})\\s*\\[(.+)\\]$`)
const SUBGRAPH_DIRECTION_RE = /^direction\s+(TB|TD|BT|LR|RL)$/i
const IGNORED_PRESENTATION_RE = /^(?:classDef|class|style|linkStyle)\b/i
const DATABASE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\((.+)\\)\\]$`)
const SUBROUTINE_NODE_RE = new RegExp(`^(${ID_RE})\\[\\[(.+)\\]\\]$`)
const ROUNDED_BRACKET_NODE_RE = new RegExp(`^(${ID_RE})\\(\\[(.+)\\]\\)$`)
const ROUNDED_NODE_RE = new RegExp(`^(${ID_RE})\\((.+)\\)$`)
const DECISION_NODE_RE = new RegExp(`^(${ID_RE})\\{(.+)\\}$`)
const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
const EDGE_OPERATOR_RE =
/(-\.(?!->)(.+?)\.->)|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)|(-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/g
function normalizeDirection(value?: string): FlowchartDirection {
const upper = value?.toUpperCase()
if (upper === "TB" || upper === "TD" || upper === "BT" || upper === "LR" || upper === "RL") return upper
return DEFAULT_DIRECTION
}
function normalizeSubgraphId(value: string, index: number): string {
const stripped = stripQuotes(value)
return ID_ONLY_RE.test(stripped) ? stripped : `subgraph_${index + 1}`
}
function parseSubgraphToken(token: string, index: number): Pick<FlowchartSubgraph, "id" | "label"> {
const trimmed = token
.trim()
.replace(/\s*:::.*$/, "")
.replace(/;$/, "")
const withLabel = trimmed.match(SUBGRAPH_WITH_LABEL_RE)
if (withLabel) {
return { id: withLabel[1]!, label: stripQuotes(withLabel[2]!) }
}
const label = stripQuotes(trimmed)
return { id: normalizeSubgraphId(trimmed, index), label }
}
function parseNodeToken(token: string): FlowchartNode {
const trimmed = token.trim().replace(/;$/, "")
const database = trimmed.match(DATABASE_NODE_RE)
if (database) return { id: database[1]!, label: stripQuotes(database[2]!), shape: "database" }
const subroutine = trimmed.match(SUBROUTINE_NODE_RE)
if (subroutine) return { id: subroutine[1]!, label: stripQuotes(subroutine[2]!), shape: "subroutine" }
const roundedBracket = trimmed.match(ROUNDED_BRACKET_NODE_RE)
if (roundedBracket) return { id: roundedBracket[1]!, label: stripQuotes(roundedBracket[2]!), shape: "rounded" }
const rounded = trimmed.match(ROUNDED_NODE_RE)
if (rounded) return { id: rounded[1]!, label: stripQuotes(rounded[2]!), shape: "rounded" }
const decision = trimmed.match(DECISION_NODE_RE)
if (decision) return { id: decision[1]!, label: stripQuotes(decision[2]!), shape: "decision" }
const box = trimmed.match(BOX_NODE_RE)
if (box) return { id: box[1]!, label: stripQuotes(box[2]!), shape: "box" }
return { id: trimmed, label: trimmed, shape: "box" }
}
function hasExplicitNodeShape(token: string): boolean {
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
}
function ensureNode(nodes: Map<string, FlowchartNode>, token: string): FlowchartNode {
const node = parseNodeToken(token)
const existing = nodes.get(node.id)
if (!existing) {
nodes.set(node.id, node)
return node
}
if (hasExplicitNodeShape(token)) {
existing.label = node.label
existing.shape = node.shape
}
return existing
}
function addNodeToSubgraph(subgraph: FlowchartSubgraph | undefined, nodeId: string): void {
if (!subgraph || subgraph.nodeIds.includes(nodeId)) return
subgraph.nodeIds.push(nodeId)
}
function stripNodeToken(token: string): string {
return token
.replace(/\s*:::.*$/, "")
.replace(/;$/, "")
.trim()
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
return undefined
}
function createEdge(
from: string,
to: string,
label: string,
style: FlowchartEdgeStyle | undefined,
arrowhead: boolean,
): FlowchartEdge {
const edge: FlowchartEdge = style ? { from, to, label, style } : { from, to, label }
if (!arrowhead) edge.arrowhead = false
return edge
}
interface ParsedEdgeOperator {
index: number
end: number
label: string
style: FlowchartEdgeStyle | undefined
arrowhead: boolean
orderOnly: boolean
}
function parseEdgeOperators(line: string): ParsedEdgeOperator[] {
return [...line.matchAll(EDGE_OPERATOR_RE)].map((match) => {
const inlineDashedArrow = match[1]
const startArrow = inlineDashedArrow ?? match[3] ?? match[6]!
const endArrow = inlineDashedArrow ?? match[5] ?? match[6]!
return {
index: match.index,
end: match.index + match[0].length,
label: (match[2] ?? match[4] ?? match[7] ?? "").trim(),
style: edgeStyleFromArrow(startArrow, endArrow),
arrowhead: endArrow !== "---",
orderOnly: endArrow === "~~~",
}
})
}
export function isMermaidFlowchartDiagram(content: string): boolean {
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram {
const nodes = new Map<string, FlowchartNode>()
const edges: FlowchartEdge[] = []
const subgraphs: FlowchartSubgraph[] = []
const subgraphStack: Array<{ subgraph: FlowchartSubgraph; lineNumber: number; sourceLine: string }> = []
let direction: FlowchartDirection = DEFAULT_DIRECTION
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
const header = line.match(FLOWCHART_HEADER_RE)
if (header) {
direction = normalizeDirection(header[2])
continue
}
// Mermaid CSS styling does not apply to terminal theme rendering.
if (IGNORED_PRESENTATION_RE.test(line)) continue
const subgraphMatch = line.match(SUBGRAPH_RE)
if (subgraphMatch) {
const parsed = parseSubgraphToken(subgraphMatch[1]!, subgraphs.length)
const subgraph: FlowchartSubgraph = {
...parsed,
nodeIds: [],
parentId: subgraphStack[subgraphStack.length - 1]?.subgraph.id,
}
subgraphs.push(subgraph)
subgraphStack.push({ subgraph, lineNumber: source.lineNumber, sourceLine: line })
continue
}
if (/^end$/i.test(line)) {
if (subgraphStack.length === 0) {
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, 'Unexpected "end" without an open subgraph')
}
subgraphStack.pop()
continue
}
const currentSubgraph = subgraphStack[subgraphStack.length - 1]?.subgraph
const subgraphDirection = line.match(SUBGRAPH_DIRECTION_RE)
if (subgraphDirection) {
if (!currentSubgraph) {
throw new MermaidSyntaxError(
"flowchart",
source.lineNumber,
line,
'A "direction" statement requires an open subgraph',
)
}
currentSubgraph.direction = normalizeDirection(subgraphDirection[1])
continue
}
const edgeOperators = parseEdgeOperators(line)
if (edgeOperators.length > 0) {
const nodeTokens = [
line.slice(0, edgeOperators[0]!.index),
...edgeOperators.map((operator, index) =>
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
),
]
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
for (const nodeId of chainNodeIds) {
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
}
for (let index = 0; index < edgeOperators.length; index++) {
const operator = edgeOperators[index]!
const edge = createEdge(
chainNodeIds[index]!,
chainNodeIds[index + 1]!,
operator.label,
operator.style,
operator.arrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
continue
}
}
if (hasExplicitNodeShape(line) || ID_ONLY_RE.test(stripNodeToken(line))) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
}
throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
}
const unclosedSubgraph = subgraphStack[subgraphStack.length - 1]
if (unclosedSubgraph) {
throw new MermaidSyntaxError(
"flowchart",
unclosedSubgraph.lineNumber,
unclosedSubgraph.sourceLine,
'Unclosed subgraph; expected "end"',
)
}
return { direction, nodes: [...nodes.values()], edges, subgraphs }
}
+10
View File
@@ -0,0 +1,10 @@
import { drawFlowchartDiagramGrid } from "./drawing.js"
import type { FlowchartDiagramRenderOptions } from "./options.js"
import { parseMermaidFlowchartDiagram } from "./parser.js"
export function renderFlowchartDiagram(content: string, options: FlowchartDiagramRenderOptions = {}): string {
return drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(content), options).toString({
trimTop: true,
trimBottom: true,
})
}
@@ -0,0 +1,272 @@
import { describe, expect, test } from "bun:test"
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
import { routeFlowchartEdges } from "./routing.js"
function bounds(id: string, left: number, top: number): FlowchartNodeBounds {
const width = 5
const height = 3
return {
id,
width,
height,
lines: [id],
left,
top,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor(height / 2),
}
}
function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagram["edges"]): FlowchartDiagram {
return { direction, nodes: [], edges, subgraphs: [] }
}
describe("flowchart routing", () => {
test("routes a simple horizontal edge from source port to target port", () => {
const edge = { from: "A", to: "B", label: "" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 5, y: 1 },
{ x: 19, y: 1 },
],
},
])
})
test("routes a simple reverse horizontal edge into the target right port", () => {
const edge = { from: "A", to: "B", label: "" }
const routes = routeFlowchartEdges(
diagram("RL", [edge]),
new Map([
["A", bounds("A", 20, 0)],
["B", bounds("B", 0, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 19, y: 1 },
{ x: 5, y: 1 },
],
},
])
})
test("routes horizontal back-edges above forward lanes", () => {
const edge = { from: "B", to: "A", label: "" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 22, y: -1 },
{ x: 22, y: -4 },
{ x: 2, y: -4 },
{ x: 2, y: -1 },
],
},
])
})
test("routes parallel horizontal edges on independent lanes", () => {
const edges = [
{ from: "A", to: "B", label: "first" },
{ from: "A", to: "B", label: "second" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes.map((route) => route.points)).toEqual([
[
{ x: 5, y: 1 },
{ x: 19, y: 1 },
],
[
{ x: 2, y: 3 },
{ x: 2, y: 6 },
{ x: 22, y: 6 },
{ x: 22, y: 3 },
],
])
})
test("spaces parallel horizontal lanes for multiline labels", () => {
const edges = [
{ from: "A", to: "B", label: "first" },
{ from: "A", to: "B", label: "second 1<br/>second 2<br/>second 3" },
{ from: "A", to: "B", label: "third 1<br/>third 2<br/>third 3" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 20, 0)],
]),
)
expect(routes[2]!.points[1]!.y).toBeGreaterThan(routes[1]!.points[1]!.y + 3)
})
test("routes horizontal fan-out through a shared bus lane", () => {
const edges = [
{ from: "A", to: "B", label: "" },
{ from: "A", to: "C", label: "" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 6)],
["B", bounds("B", 20, 0)],
["C", bounds("C", 20, 12)],
]),
)
expect(routes.map((route) => route.points)).toEqual([
[
{ x: 5, y: 7 },
{ x: 8, y: 7 },
{ x: 8, y: 1 },
{ x: 19, y: 1 },
],
[
{ x: 5, y: 7 },
{ x: 8, y: 7 },
{ x: 8, y: 13 },
{ x: 19, y: 13 },
],
])
})
test("routes each horizontal edge once when fan-in and fan-out overlap", () => {
const edges = [
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
]
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 0, 12)],
["C", bounds("C", 24, 0)],
["D", bounds("D", 24, 12)],
]),
)
expect(routes).toHaveLength(edges.length)
expect(routes.map((route) => `${route.edge.from}->${route.edge.to}`).sort()).toEqual([
"A->C",
"A->D",
"B->C",
"B->D",
])
})
test("routes vertical back-edges around the left side", () => {
const edge = { from: "B", to: "A", label: "" }
const routes = routeFlowchartEdges(
diagram("TD", [edge]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 0, 12)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: -1, y: 13 },
{ x: -4, y: 13 },
{ x: -4, y: 1 },
{ x: -1, y: 1 },
],
},
])
})
test("routes self edges below the source node", () => {
const edge = { from: "A", to: "A", label: "" }
const routes = routeFlowchartEdges(diagram("TD", [edge]), new Map([["A", bounds("A", 0, 0)]]))
expect(routes).toEqual([
{
edge,
points: [
{ x: 5, y: 1 },
{ x: 8, y: 1 },
{ x: 8, y: 4 },
{ x: 2, y: 4 },
{ x: 2, y: 3 },
],
},
])
})
test("routes same-column horizontal-flow edges through vertical ports", () => {
const edge = { from: "A", to: "B", label: "rollback" }
const routes = routeFlowchartEdges(
diagram("LR", [edge]),
new Map([
["A", bounds("A", 20, 0)],
["B", bounds("B", 20, 8)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 22, y: 3 },
{ x: 22, y: 7 },
],
},
])
})
test("routes overlapping horizontal-flow columns through vertical ports", () => {
const edge = { from: "A", to: "B", label: "merge" }
const routes = routeFlowchartEdges(
diagram("RL", [edge]),
new Map([
["A", bounds("A", 1, 8)],
["B", bounds("B", 0, 0)],
]),
)
expect(routes).toEqual([
{
edge,
points: [
{ x: 2, y: 7 },
{ x: 2, y: 3 },
],
},
])
})
})
+708
View File
@@ -0,0 +1,708 @@
import {
advanceCoordinate,
afterFarthestCoordinate,
beforeNearestCoordinate,
boundsCenter,
boundsSidePoint,
centerCoordinate,
coordinate,
keepAfter,
keepBefore,
lane,
oppositeSide,
orthogonalPath,
pathThrough,
pathViaLane,
sideForDirection,
snapCoordinate,
withCoordinate,
type DiagramAxis,
type DiagramDirection,
type DiagramLane,
type DiagramSide,
} from "../core/geometry.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type {
FlowchartDiagram,
FlowchartDirection,
FlowchartEdge,
FlowchartEdgeRoute,
FlowchartNodeBounds,
FlowchartPoint,
FlowchartSubgraph,
FlowchartSubgraphBounds,
} from "./types.js"
export { directionBetween as flowchartDirectionBetween } from "../core/geometry.js"
const BUS_CLEARANCE = 3
const NODE_CLEARANCE = 2
type HorizontalTravel = Extract<DiagramDirection, "left" | "right">
type VerticalTravel = Extract<DiagramDirection, "up" | "down">
type PortRole = "source" | "target"
interface EdgeRecord {
edge: FlowchartEdge
sourcePort: FlowchartPoint
targetPort: FlowchartPoint
}
function isVerticalDirection(direction: FlowchartDirection): boolean {
return direction === "TB" || direction === "TD" || direction === "BT"
}
function verticalTravel(from: FlowchartNodeBounds, to: FlowchartNodeBounds): VerticalTravel {
return centerCoordinate(to, "y") >= centerCoordinate(from, "y") ? "down" : "up"
}
function isVerticalBackEdge(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): boolean {
return direction === "BT"
? centerCoordinate(to, "y") > centerCoordinate(from, "y")
: centerCoordinate(to, "y") < centerCoordinate(from, "y")
}
function isHorizontalBackEdge(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): boolean {
return direction === "RL"
? centerCoordinate(to, "x") > centerCoordinate(from, "x")
: centerCoordinate(to, "x") < centerCoordinate(from, "x")
}
function horizontalTravel(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): HorizontalTravel {
const targetIsRight = centerCoordinate(to, "x") > centerCoordinate(from, "x")
const targetIsSameOrRight = centerCoordinate(to, "x") >= centerCoordinate(from, "x")
return direction === "RL" ? (targetIsRight ? "right" : "left") : targetIsSameOrRight ? "right" : "left"
}
function verticalBackEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
leftBoundary?: number,
): FlowchartPoint[] {
const start = boundsSidePoint(from, "left")
const end = boundsSidePoint(to, "left")
const busX = Math.min(
afterFarthestCoordinate([start, end], "x", "left", BUS_CLEARANCE),
leftBoundary === undefined ? Number.POSITIVE_INFINITY : leftBoundary - BUS_CLEARANCE * 2,
)
return pathViaLane(start, lane("x", busX), end)
}
function verticalForwardEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
const travel = verticalTravel(from, to)
const startSide = sideForDirection(travel)
const endSide = oppositeSide(startSide)
const sourceCenter = boundsCenter(from)
const targetCenter = boundsCenter(to)
const start = withCoordinate(boundsSidePoint(from, startSide), "x", snapCoordinate(sourceCenter.x, targetCenter.x, 1))
const end = boundsSidePoint(to, endSide)
return orthogonalPath(start, end, { preferredAxis: "y" })
}
function horizontalBackEdgePath(from: FlowchartNodeBounds, to: FlowchartNodeBounds): FlowchartPoint[] {
const start = boundsSidePoint(from, "top")
const end = boundsSidePoint(to, "top")
const busY = afterFarthestCoordinate([start, end], "y", "up", BUS_CLEARANCE)
return pathViaLane(start, lane("y", busY), end)
}
function horizontalEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
): FlowchartPoint[] {
const overlapsHorizontally = from.left < to.left + to.width && to.left < from.left + from.width
if (overlapsHorizontally) return verticalForwardEdgePath(from, to)
if (isHorizontalBackEdge(from, to, direction)) return horizontalBackEdgePath(from, to)
const travel = horizontalTravel(from, to, direction)
const startSide = sideForDirection(travel)
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
}
function selfEdgePath(bounds: FlowchartNodeBounds): 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
return [start, { x: rightLaneX, y: start.y }, { x: rightLaneX, y: bottomLaneY }, { x: end.x, y: bottomLaneY }, end]
}
function parallelEdgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
laneCoordinate: number,
): FlowchartPoint[] {
if (!isVerticalDirection(direction)) {
const start = boundsSidePoint(from, "bottom")
const end = boundsSidePoint(to, "bottom")
return pathViaLane(start, lane("y", laneCoordinate), end)
}
const start = boundsSidePoint(from, "right")
const end = boundsSidePoint(to, "right")
return pathViaLane(start, lane("x", laneCoordinate), end)
}
function labelHeight(edge: FlowchartEdge): number {
return edge.label ? splitDiagramLines(edge.label).length : 0
}
function rightRenderExtent(route: FlowchartEdgeRoute): number {
let right = Math.max(...route.points.map((point) => point.x))
if (route.edge.label) {
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
right = Math.max(right, label.point.x + label.width - 1)
}
return right
}
function edgePath(
from: FlowchartNodeBounds,
to: FlowchartNodeBounds,
direction: FlowchartDirection,
leftBoundary?: number,
): FlowchartPoint[] {
if (from.id === to.id) return selfEdgePath(from)
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
return isVerticalBackEdge(from, to, direction)
? verticalBackEdgePath(from, to, leftBoundary)
: verticalForwardEdgePath(from, to)
}
function sourceFanOutLane(
sourcePort: FlowchartPoint,
targetPorts: readonly FlowchartPoint[],
axis: DiagramAxis,
travel: DiagramDirection,
): number {
const sourceCoordinate = coordinate(sourcePort, axis)
const unclamped = keepBefore(
advanceCoordinate(coordinate(sourcePort, axis), travel, BUS_CLEARANCE),
beforeNearestCoordinate(targetPorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepAfter(unclamped, sourceCoordinate, travel)
}
function targetFanInLane(
sourcePorts: readonly FlowchartPoint[],
targetPort: FlowchartPoint,
axis: DiagramAxis,
travel: DiagramDirection,
): number {
const targetCoordinate = coordinate(targetPort, axis)
const unclamped = keepAfter(
advanceCoordinate(coordinate(targetPort, axis), travel, -BUS_CLEARANCE),
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepBefore(unclamped, targetCoordinate, travel)
}
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
const side = role === "source" ? sideForDirection(travel) : oppositeSide(sideForDirection(travel))
return boundsSidePoint(bounds, side)
}
function horizontalForwardRecords(
edges: FlowchartEdge[],
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): EdgeRecord[] {
const travel = direction === "RL" ? "left" : "right"
const records: EdgeRecord[] = []
for (const edge of edges) {
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!source || !target) continue
const forward =
direction === "RL"
? centerCoordinate(target, "x") < centerCoordinate(source, "x")
: centerCoordinate(target, "x") > centerCoordinate(source, "x")
if (!forward) continue
records.push({
edge,
sourcePort: portForTravel(source, travel, "source"),
targetPort: portForTravel(target, travel, "target"),
})
}
return records
}
function verticalForwardRecords(
edges: FlowchartEdge[],
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): EdgeRecord[] {
const travel = direction === "BT" ? "up" : "down"
const records: EdgeRecord[] = []
for (const edge of edges) {
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!source || !target) continue
const forward =
direction === "BT"
? centerCoordinate(target, "y") < centerCoordinate(source, "y")
: centerCoordinate(target, "y") > centerCoordinate(source, "y")
if (!forward) continue
records.push({
edge,
sourcePort: portForTravel(source, travel, "source"),
targetPort: portForTravel(target, travel, "target"),
})
}
return records
}
function horizontalExitSubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
if (subgraph.nodeIds.includes(edge.from) && !subgraph.nodeIds.includes(edge.to)) return subgraph
}
return undefined
}
function horizontalEntrySubgraph(diagram: FlowchartDiagram, edge: FlowchartEdge): FlowchartSubgraph | undefined {
for (const subgraph of [...(diagram.subgraphs ?? [])].reverse()) {
if (subgraph.direction !== "LR" && subgraph.direction !== "RL") continue
if (subgraph.nodeIds.includes(edge.to) && !subgraph.nodeIds.includes(edge.from)) return subgraph
}
return undefined
}
function horizontalSubgraphEntryTravel(subgraph: FlowchartSubgraph): HorizontalTravel {
return subgraph.direction === "RL" ? "left" : "right"
}
function horizontalSubgraphEntryLane(subgraph: FlowchartSubgraph, subgraphBound: FlowchartSubgraphBounds): number {
return subgraph.direction === "RL"
? subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
: subgraphBound.left - BUS_CLEARANCE
}
function horizontalSubgraphJoinY(from: FlowchartSubgraphBounds, targetSubgraphBound: FlowchartSubgraphBounds): number {
if (from.centerY <= targetSubgraphBound.centerY) {
const start = from.top + from.height
const end = targetSubgraphBound.top - 1
return start <= end ? Math.floor((start + end) / 2) : start
}
const start = targetSubgraphBound.top + targetSubgraphBound.height
const end = from.top - 1
return start <= end ? Math.floor((start + end) / 2) : end
}
function horizontalSubgraphExitJoinY(
from: FlowchartSubgraphBounds,
targetPort: FlowchartPoint,
targetBelow: boolean,
): number {
if (targetBelow) {
const outside = from.top + from.height
const beforeTarget = targetPort.y - 1
const preferred = targetPort.y - BUS_CLEARANCE
return outside <= beforeTarget ? Math.min(Math.max(outside, preferred), beforeTarget) : beforeTarget
}
const outside = from.top - 1
const afterTarget = targetPort.y + 1
const preferred = targetPort.y + BUS_CLEARANCE
return afterTarget <= outside ? Math.max(Math.min(outside, preferred), afterTarget) : afterTarget
}
function groupRecords<Record>(records: readonly Record[], key: (record: Record) => string): Map<string, Record[]> {
const groups = new Map<string, Record[]>()
for (const record of records) {
const groupKey = key(record)
const group = groups.get(groupKey) ?? []
group.push(record)
groups.set(groupKey, group)
}
return groups
}
function fanRoute(
edge: FlowchartEdge,
sourcePort: FlowchartPoint,
targetPort: FlowchartPoint,
routeLane: DiagramLane,
): FlowchartEdgeRoute {
return { edge, points: pathViaLane(sourcePort, routeLane, targetPort) }
}
function alignClusteredVerticalSources(records: readonly EdgeRecord[]): EdgeRecord[] {
const xs = records.map((record) => record.sourcePort.x)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
if (maxX - minX > 1) return [...records]
const x = Math.round(xs.reduce((total, value) => total + value, 0) / xs.length)
return records.map((record) => ({ ...record, sourcePort: { ...record.sourcePort, x } }))
}
function routeHorizontalFanOut(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
if (sourceRecords.length < 2) continue
const travel = direction === "RL" ? "left" : "right"
const sourcePort = sourceRecords[0]!.sourcePort
const targetPorts = sourceRecords.map((record) => record.targetPort)
const busX = sourceFanOutLane(sourcePort, targetPorts, "x", travel)
for (const record of sourceRecords) {
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("x", busX)))
handled.add(record.edge)
}
}
}
function routeHorizontalFanIn(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
for (const targetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
if (targetRecords.length < 2) continue
const travel = direction === "RL" ? "left" : "right"
const targetPort = targetRecords[0]!.targetPort
const sourcePorts = targetRecords.map((record) => record.sourcePort)
const busX = targetFanInLane(sourcePorts, targetPort, "x", travel)
for (const record of targetRecords) {
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("x", busX)))
handled.add(record.edge)
}
}
}
function routeVerticalFanOut(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
for (const sourceRecords of groupRecords(records, (record) => record.edge.from).values()) {
if (sourceRecords.length < 2) continue
const travel = direction === "BT" ? "up" : "down"
const sourcePort = sourceRecords[0]!.sourcePort
const targetPorts = sourceRecords.map((record) => record.targetPort)
const busY = sourceFanOutLane(sourcePort, targetPorts, "y", travel)
for (const record of sourceRecords) {
routes.push(fanRoute(record.edge, sourcePort, record.targetPort, lane("y", busY)))
handled.add(record.edge)
}
}
}
function routeVerticalFanIn(
records: readonly EdgeRecord[],
direction: FlowchartDirection,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
const unhandledRecords = records.filter((record) => !handled.has(record.edge))
for (const unalignedTargetRecords of groupRecords(unhandledRecords, (record) => record.edge.to).values()) {
const targetRecords = alignClusteredVerticalSources(unalignedTargetRecords)
if (targetRecords.length < 2) continue
const travel = direction === "BT" ? "up" : "down"
const targetPort = targetRecords[0]!.targetPort
const sourcePorts = targetRecords.map((record) => record.sourcePort)
const busY = targetFanInLane(sourcePorts, targetPort, "y", travel)
for (const record of targetRecords) {
routes.push(fanRoute(record.edge, record.sourcePort, targetPort, lane("y", busY)))
handled.add(record.edge)
}
}
}
function routeParallelEdges(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection,
leftBoundary: number | undefined,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
const groups = groupRecords(diagram.edges, (edge) => `${directionForEdge(edge)}:${edge.from}:${edge.to}`)
for (const edges of groups.values()) {
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 direction = directionForEdge(edges[0]!)
const canonicalRoute = { edge: edges[0]!, points: edgePath(from, to, direction, leftBoundary) }
routes.push(canonicalRoute)
handled.add(edges[0]!)
let previousRoute = canonicalRoute
for (let index = 1; index < edges.length; index++) {
const edge = edges[index]!
const laneCoordinate = isVerticalDirection(direction)
? Math.max(
Math.max(boundsSidePoint(from, "right").x, boundsSidePoint(to, "right").x) + BUS_CLEARANCE,
rightRenderExtent(previousRoute) + NODE_CLEARANCE,
)
: Math.max(
Math.max(boundsSidePoint(from, "bottom").y, boundsSidePoint(to, "bottom").y) + BUS_CLEARANCE,
Math.max(...previousRoute.points.map((point) => point.y)) + Math.max(2, labelHeight(edge) + 1),
)
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
routes.push(route)
handled.add(edge)
previousRoute = route
}
}
}
function routeHorizontalSubgraphExitFanIn(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
if (!subgraphBounds) return
const groups = new Map<string, { edge: FlowchartEdge; subgraph: FlowchartSubgraph; source: FlowchartNodeBounds }[]>()
for (const edge of diagram.edges) {
if (handled.has(edge)) continue
const subgraph = horizontalExitSubgraph(diagram, edge)
const source = bounds.get(edge.from)
const target = bounds.get(edge.to)
if (!subgraph || !source || !target) continue
const key = `${subgraph.id}:${edge.to}`
const group = groups.get(key) ?? []
group.push({ edge, subgraph, source })
groups.set(key, group)
}
for (const group of groups.values()) {
const subgraph = group[0]!.subgraph
const subgraphBound = subgraphBounds.get(subgraph.id)
const target = bounds.get(group[0]!.edge.to)
if (!subgraphBound || !target) continue
const travel: HorizontalTravel = subgraph.direction === "RL" ? "left" : "right"
const busX =
subgraph.direction === "RL"
? subgraphBound.left - BUS_CLEARANCE
: subgraphBound.left + subgraphBound.width + BUS_CLEARANCE
const targetSubgraph = horizontalEntrySubgraph(diagram, group[0]!.edge)
const targetSubgraphBound = targetSubgraph ? subgraphBounds.get(targetSubgraph.id) : undefined
const targetBelow = target.centerY >= subgraphBound.centerY
const targetPort = targetSubgraph
? portForTravel(target, horizontalSubgraphEntryTravel(targetSubgraph), "target")
: boundsSidePoint(target, targetBelow ? "top" : "bottom")
const joinY = targetSubgraphBound
? horizontalSubgraphJoinY(subgraphBound, targetSubgraphBound)
: horizontalSubgraphExitJoinY(subgraphBound, targetPort, targetBelow)
const entryX =
targetSubgraph && targetSubgraphBound
? horizontalSubgraphEntryLane(targetSubgraph, targetSubgraphBound)
: targetPort.x
for (const record of group) {
const sourcePort = portForTravel(record.source, travel, "source")
routes.push({
edge: record.edge,
points: pathThrough([
sourcePort,
{ x: busX, y: sourcePort.y },
{ x: busX, y: joinY },
{ x: entryX, y: joinY },
{ x: entryX, y: targetPort.y },
targetPort,
]),
})
handled.add(record.edge)
}
}
}
function routeHorizontalSubgraphEntries(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
handled: Set<FlowchartEdge>,
routes: FlowchartEdgeRoute[],
): void {
if (!subgraphBounds) return
for (const edge of diagram.edges) {
if (handled.has(edge)) continue
const subgraph = horizontalEntrySubgraph(diagram, edge)
const subgraphBound = subgraph ? subgraphBounds.get(subgraph.id) : undefined
const from = bounds.get(edge.from)
const to = bounds.get(edge.to)
if (!subgraph || !subgraphBound || !from || !to) continue
const targetPort = portForTravel(to, horizontalSubgraphEntryTravel(subgraph), "target")
const entryX = horizontalSubgraphEntryLane(subgraph, subgraphBound)
const travel = verticalTravel(from, to)
const sourcePort = portForTravel(from, travel, "source")
routes.push({
edge,
points: pathThrough([sourcePort, { x: entryX, y: sourcePort.y }, { x: entryX, y: targetPort.y }, targetPort]),
})
handled.add(edge)
}
}
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
if (from.x === to.x) {
if (
from.x >= bounds.left &&
from.x <= right &&
Math.max(from.y, to.y) >= bounds.top &&
Math.min(from.y, to.y) <= bottom
) {
return true
}
continue
}
if (
from.y >= bounds.top &&
from.y <= bottom &&
Math.max(from.x, to.x) >= bounds.left &&
Math.min(from.x, to.x) <= right
) {
return true
}
}
return false
}
function avoidNodeObstacles(
route: FlowchartEdgeRoute,
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
): FlowchartEdgeRoute {
const obstacle = [...bounds.values()].some(
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
)
if (!obstacle) return route
const from = bounds.get(route.edge.from)
const to = bounds.get(route.edge.to)
if (!from || !to) return route
if (isVerticalDirection(direction)) {
const start = boundsSidePoint(from, "right")
const end = boundsSidePoint(to, "right")
const busX = Math.max(...[...bounds.values()].map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
return { edge: route.edge, points: pathViaLane(start, lane("x", busX), end) }
}
const start = boundsSidePoint(from, "top")
const end = boundsSidePoint(to, "top")
const busY = Math.min(...[...bounds.values()].map((bound) => bound.top)) - BUS_CLEARANCE
return { edge: route.edge, points: pathViaLane(start, lane("y", busY), end) }
}
export function routeFlowchartEdges(
diagram: FlowchartDiagram,
bounds: Map<string, FlowchartNodeBounds>,
directionForEdge: (edge: FlowchartEdge) => FlowchartDirection = () => diagram.direction,
subgraphBounds?: ReadonlyMap<string, FlowchartSubgraphBounds>,
): FlowchartEdgeRoute[] {
const routedDiagram = { ...diagram, edges: diagram.edges.filter((edge) => !edge.orderOnly) }
const handled = new Set<FlowchartEdge>()
const routes: FlowchartEdgeRoute[] = []
const leftBoundary = subgraphBounds
? Math.min(...[...bounds.values(), ...subgraphBounds.values()].map((bound) => bound.left))
: undefined
routeParallelEdges(routedDiagram, bounds, directionForEdge, leftBoundary, handled, routes)
for (const direction of ["LR", "RL"] satisfies FlowchartDirection[]) {
const horizontalEdges = routedDiagram.edges.filter(
(edge) => !handled.has(edge) && directionForEdge(edge) === direction,
)
if (horizontalEdges.length === 0) continue
const records = horizontalForwardRecords(horizontalEdges, bounds, direction)
routeHorizontalFanOut(records, direction, handled, routes)
routeHorizontalFanIn(records, direction, handled, routes)
}
routeHorizontalSubgraphExitFanIn(routedDiagram, bounds, subgraphBounds, handled, routes)
routeHorizontalSubgraphEntries(routedDiagram, bounds, subgraphBounds, handled, routes)
for (const direction of ["TD", "TB", "BT"] satisfies FlowchartDirection[]) {
const verticalEdges = routedDiagram.edges.filter(
(edge) => !handled.has(edge) && directionForEdge(edge) === direction,
)
if (verticalEdges.length === 0) continue
const records = verticalForwardRecords(verticalEdges, bounds, direction)
routeVerticalFanOut(records, direction, handled, routes)
routeVerticalFanIn(records, direction, handled, routes)
}
for (const edge of routedDiagram.edges) {
if (handled.has(edge)) continue
const from = bounds.get(edge.from)
const to = bounds.get(edge.to)
if (!from || !to) continue
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
}
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
}
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
if (sourcePoint.x < bounds.left) return "left"
if (sourcePoint.x >= bounds.left + bounds.width) return "right"
if (sourcePoint.y < bounds.top) return "top"
return "bottom"
}
function connectorChar(side: DiagramSide): string {
switch (side) {
case "left":
return "┤"
case "right":
return "├"
case "top":
return "┴"
case "bottom":
return "┬"
}
}
export function flowchartSourceConnector(
from: FlowchartNodeBounds,
sourcePoint: FlowchartPoint,
): { x: number; y: number; char: string } {
const side = sideForOutsidePoint(from, sourcePoint)
const connector = boundsSidePoint(from, side, "border")
return {
x: side === "top" || side === "bottom" ? sourcePoint.x : connector.x,
y: side === "left" || side === "right" ? sourcePoint.y : connector.y,
char: connectorChar(side),
}
}
+53
View File
@@ -0,0 +1,53 @@
import { RGBA, type StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import {
createColorRampTheme,
DIAGRAM_FADE_STEPS,
numberedStyleKeys,
rgba,
type DiagramFadeStep,
type DiagramRgb,
} from "../core/color/style.js"
export type FlowchartBaseCellStyle = "node" | "database" | "edge" | "label" | "group"
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle>
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
export const DEFAULT_THEME_RGB = {
node: [228, 239, 232],
database: [228, 239, 232],
edge: [134, 225, 200],
label: [134, 225, 200],
group: [76, 99, 89],
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
export function resolveFlowchartStyleColors(
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
): FlowchartStyleColors {
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
return {
node,
database,
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),
}
}
export function renderGridStyledText(grid: FlowchartGrid, colors: FlowchartStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimTop: true,
trimBottom: true,
})
}
+60
View File
@@ -0,0 +1,60 @@
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
export type FlowchartEdgeStyle = "thick" | "dashed"
export interface FlowchartNode {
id: string
label: string
shape: FlowchartNodeShape
}
export interface FlowchartEdge {
from: string
to: string
label: string
style?: FlowchartEdgeStyle
arrowhead?: false
orderOnly?: boolean
}
export interface FlowchartSubgraph {
id: string
label: string
nodeIds: string[]
parentId?: string
direction?: FlowchartDirection
}
export interface FlowchartDiagram {
direction: FlowchartDirection
nodes: FlowchartNode[]
edges: FlowchartEdge[]
subgraphs?: FlowchartSubgraph[]
}
export interface FlowchartNodeSize {
width: number
height: number
lines: string[]
}
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
id: string
}
export interface FlowchartSubgraphBounds extends DiagramBounds {
id: string
label: string
labelSide: "top" | "bottom"
}
export type FlowchartPoint = DiagramPoint
export interface FlowchartEdgeRoute {
edge: FlowchartEdge
points: FlowchartPoint[]
}
export type FlowchartEdgeDirection = DiagramDirection
+225
View File
@@ -0,0 +1,225 @@
import {
TextRenderable,
RenderableEvents,
createMarkdownCodeBlockRenderer,
parseColor,
type ColorInput,
type MarkdownOptions,
type MarkdownCodeBlockRenderer,
type MouseEvent,
type RenderContext,
type RGBA,
type StyledText,
} from "@opentui/core"
import { MermaidSyntaxError } from "./diagnostics.js"
import { DiagramCanvasSizeError } from "./core/canvas.js"
import { detectMermaidDiagram } from "./detect.js"
import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js"
import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js"
import { drawSequenceDiagramGrid } from "./sequence/drawing.js"
import { parseMermaidSequenceDiagram } from "./sequence/parser.js"
import { renderSequenceGridStyledText } from "./sequence/render-grid.js"
import { resolveSequenceStyleColors } from "./sequence/style.js"
import { drawStateDiagramGrid } from "./state/drawing.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { renderStateGridStyledText } from "./state/render-grid.js"
import { resolveStateStyleColors } from "./state/style.js"
type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>
interface PreparedDiagram {
readonly kind: DiagramKind
readonly source: string
readonly text: StyledText
readonly height: number
}
export interface MermaidMarkdownRendererOptions {
compact?: boolean
colors?: {
text?: ColorInput
primary?: ColorInput
secondary?: ColorInput
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
}
}
function color(value: ColorInput | undefined): RGBA | undefined {
return value === undefined ? undefined : parseColor(value)
}
class StaticDiagramRenderable extends TextRenderable {
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
super(ctx, {
content: prepared.text,
width: "100%",
height: prepared.height,
wrapMode: "none",
selectable: false,
marginTop: 1,
})
let dragX: number | undefined
this.onMouseDown = (event: MouseEvent) => {
if (event.button !== 0) return
ctx.clearSelection()
dragX = event.x
event.preventDefault()
event.stopPropagation()
}
this.onMouseDrag = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (dragX === undefined) return
const dx = event.x - dragX
dragX = event.x
if (dx) this.scrollX -= dx
}
this.onMouseDragEnd = (event: MouseEvent) => {
dragX = undefined
event.preventDefault()
event.stopPropagation()
}
this.onMouseUp = (event: MouseEvent) => {
if (event.button !== 0) return
dragX = undefined
event.preventDefault()
event.stopPropagation()
}
this.onMouseScroll = (event: MouseEvent) => {
const scroll = event.scroll
if (!scroll || (scroll.direction !== "left" && scroll.direction !== "right")) return
event.preventDefault()
event.stopPropagation()
}
}
}
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
const colors = options.colors ?? {}
switch (kind) {
case "flowchart": {
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
return {
kind,
source,
text: renderGridStyledText(
grid,
resolveFlowchartStyleColors({
node: color(colors.primary),
database: color(colors.primary),
edge: color(colors.secondary),
label: color(colors.text),
group: color(colors.muted),
}),
),
height: size.height,
}
}
case "sequence": {
const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact: options.compact })
const size = grid.getTextSize()
return {
kind,
source,
text: renderSequenceGridStyledText(
grid,
resolveSequenceStyleColors({
participant: color(colors.primary),
lifeline: color(colors.muted),
group: color(colors.secondary),
request: color(colors.primary),
response: color(colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
note: color(colors.warning),
noteBg: color(colors.background),
}),
),
height: size.height,
}
}
case "state": {
const grid = drawStateDiagramGrid(parseMermaidStateDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderStateGridStyledText(
grid,
resolveStateStyleColors({
state: color(colors.primary),
composite: color(colors.muted),
transition: color(colors.secondary),
label: color(colors.text),
noteBorder: color(colors.warning),
noteText: color(colors.warning),
noteConnector: color(colors.muted),
start: color(colors.muted),
end: color(colors.muted),
choice: color(colors.secondary),
}),
),
height: size.height,
}
}
}
}
/** Create an OpenTUI Markdown node renderer for fenced Mermaid diagrams. */
export function createMermaidMarkdownRenderer(
ctx: RenderContext,
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
): NonNullable<MarkdownOptions["renderNode"]> {
return createMarkdownCodeBlockRenderer({ mermaid: createMermaidCodeBlockRenderer(ctx, input) })!
}
export function createMermaidCodeBlockRenderer(
ctx: RenderContext,
input: MermaidMarkdownRendererOptions | (() => MermaidMarkdownRendererOptions) = {},
): MarkdownCodeBlockRenderer {
const lastGood = new Map<string, PreparedDiagram>()
return (token, context) => {
const kind = detectMermaidDiagram(token.text)
if (!kind) return undefined
// OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
const key = context.defaultRender()?.id
const options = typeof input === "function" ? input() : input
try {
const prepared = prepareDiagram(kind, token.text, options)
const diagram = new StaticDiagramRenderable(ctx, prepared)
if (key) claimLastGood(key, prepared, diagram, lastGood)
return diagram
} catch (error) {
if (error instanceof MermaidSyntaxError) {
const previous = key ? lastGood.get(key) : undefined
if (!previous || previous.kind !== kind) return undefined
const diagram = new StaticDiagramRenderable(ctx, previous)
claimLastGood(key!, previous, diagram, lastGood)
return diagram
}
if (error instanceof DiagramCanvasSizeError) return undefined
throw error
}
}
}
function claimLastGood(
key: string,
value: PreparedDiagram,
owner: StaticDiagramRenderable,
cache: Map<string, PreparedDiagram>,
): void {
const claim = { ...value }
cache.set(key, claim)
owner.once(RenderableEvents.DESTROYED, () => {
// Reconciliation destroys the old block before synchronously creating its replacement.
queueMicrotask(() => {
if (cache.get(key) === claim) cache.delete(key)
})
})
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number]
const rgb = (value: Rgb) => RGBA.fromInts(...value)
describe("OpenCode diagram palette", () => {
test.each(
[
{
name: "dark theme",
text: [230, 232, 240],
subdued: [114, 120, 138],
secondary: [172, 176, 189],
muted: [149, 154, 169],
},
{
name: "light theme",
text: [32, 35, 43],
subdued: [119, 125, 138],
secondary: [76, 80, 91],
muted: [93, 98, 110],
},
] satisfies ReadonlyArray<{
name: string
text: Rgb
subdued: Rgb
secondary: Rgb
muted: Rgb
}>,
)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220)
const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({
text: primary,
subdued: rgb(subdued),
info,
background,
})
expect(palette.text).toBe(primary)
expect(palette.primary).toBe(primary)
expect(palette.secondary.equals(rgb(secondary))).toBe(true)
expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info)
expect(palette.background).toBe(background)
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { RGBA } from "@opentui/core"
import { blendColor } from "./core/color/style.js"
export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
readonly background: RGBA
}
export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput) {
return {
text: input.text,
primary: input.text,
secondary: blendColor(input.text, input.subdued, 0.5),
muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info,
background: input.background,
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMermaidCodeBlockRenderer } from "./markdown.js"
import { createOpenCodeDiagramPalette } from "./palette.js"
export default Plugin.define({
id: "opencode.merman",
setup(context) {
context.markdown.registerCodeBlockRenderer(
"mermaid",
createMermaidCodeBlockRenderer(context.renderer, () => ({
colors: createOpenCodeDiagramPalette({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default,
background: context.theme.background.default,
}),
})),
)
},
})
@@ -0,0 +1,688 @@
import { describe, expect, test } from "bun:test"
import { expectDiagram } from "../test/diagram.js"
import { renderSequenceDiagram } from "./diagram.js"
import { drawSequenceDiagramGrid } from "./drawing.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
describe("SequenceDiagram", () => {
test("parses Mermaid sequenceDiagram participants and messages", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: GET /
S-->>B: 401 WWW-Auth
`)
expect(diagram.participants).toEqual([
{ id: "B", label: "Browser" },
{ id: "S", label: "Server" },
])
expect(diagram.messages).toEqual([
{ from: "B", to: "S", label: "GET /", style: "solid" },
{ from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" },
])
expect(diagram.steps).toEqual([
{ type: "message", message: { from: "B", to: "S", label: "GET /", style: "solid" } },
{ type: "message", message: { from: "S", to: "B", label: "401 WWW-Auth", style: "dashed" } },
])
})
test("renders a terminal sequence diagram", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant B as Browser
participant S as Server
B->>S: GET /
S-->>B: 401 WWW-Auth
`)
expectDiagram(output).toEqualDiagram(`
╭─────────╮ ╭────────╮
│ Browser │ │ Server │
╰────┬────╯ ╰────┬───╯
│ │
│ GET / │
├─────────────────▶
│ │
│ 401 WWW-Auth │
◀─────────────────┤
│ │
`)
})
test("renders a compact terminal sequence diagram without participant boxes", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant Leaf as leaf tool
participant Location as LocationMutation
participant File as FileMutation
Leaf->>Location: resolve(path)
Location-->>Leaf: Plan(target, authority anchor)
Leaf->>File: commit(plan)
File->>Location: revalidate(plan)
Location-->>File: same target or reject
`,
{ compact: true },
)
expectDiagram(output).toEqualDiagram(`
leaf tool LocationMutation FileMutation
│ │ │
├─ resolve(path) ───────────────────▶ │
│ │ │
◀─ Plan(target, authority anchor) ──┤ │
│ │ │
├─ commit(plan) ───────────────────────────────────────────────▶
│ │ │
│ ◀─ revalidate(plan) ───────┤
│ │ │
│ ├─ same target or reject ──▶
│ │ │
`)
})
test("keeps structured sequence steps visible in compact mode", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant Worker
participant Store
Note over Worker,Store: transaction
alt accepted
Worker->>Worker: prepare
Worker->>Store: commit
end
`,
{ compact: true },
)
expectDiagram(output).toContainInOrder("Worker", "Store", "transaction", "alt: accepted", "prepare", "commit")
})
test("keeps compact labels above arrows when they do not fit inline", () => {
const output = renderSequenceDiagram(
"sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: this label is deliberately much too long to fit between endpoints despite intermediate spacing",
{ compact: true },
)
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
lines.findIndex((line) => line.includes("▶")),
)
})
test("normalizes invalid participant gaps for text rendering", () => {
const content = "sequenceDiagram\n A->>B: hello"
expect(renderSequenceDiagram(content, { minParticipantGap: Number.NaN })).toContain("hello")
})
test("connects participant headers to lifelines", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
participant Server
`)
const lines = output.split("\n")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
const grid = drawSequenceDiagramGrid(
parseMermaidSequenceDiagram(
"sequenceDiagram\n participant Browser\n participant Server\n Note over Browser,Server: context\n Browser->>Server: request",
),
)
const rampStyles = grid.rows
.flatMap((row) => row.map((cell) => cell.style))
.filter((style) => style?.startsWith("lifelineRamp"))
expect(new Set(rampStyles)).toEqual(new Set(["lifelineRamp1", "lifelineRamp2", "lifelineRamp3"]))
})
test("renders notes and long cross-participant messages in order", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
participant Server
participant Store as Ticket store
Note over Browser,Server: native browser Basic prompt
Browser->>Server: POST connect-token
Server->>Store: issue { ptyID, scope }
`)
expectDiagram(output).toContainInOrder(
"native browser Basic prompt",
"POST connect-token",
"issue { ptyID, scope }",
)
})
test("renders notes to the left and right of a participant", () => {
const output = renderSequenceDiagram(`sequenceDiagram
participant OpenTUI
Note right of OpenTUI: child is not renderable
Note left of OpenTUI: remove failed`)
expect(output).toContain("child is not renderable")
expect(output).toContain("remove failed")
})
test("preserves the parsed shape of notes over participants", () => {
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
participant A
Note over A: hello`)
expect(diagram.steps).toContainEqual({ type: "note", note: { over: ["A"], label: "hello" } })
})
test("parses the complete source of a br-delimited note", () => {
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip`)
expect(diagram.steps).toContainEqual({
type: "note",
note: {
over: ["Tool"],
label:
"directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip",
},
})
})
test("parses activation shorthand and control blocks", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
alt accepted
Server-->>-Browser: response
else rejected
activate Server
Server-->>Browser: error
deactivate Server
end
`)
expect(diagram.steps).toEqual([
{
type: "message",
message: {
from: "Browser",
to: "Server",
label: "request",
style: "solid",
activate: "Server",
},
},
{ type: "fragment", fragment: { kind: "alt", label: "accepted" } },
{
type: "message",
message: {
from: "Server",
to: "Browser",
label: "response",
style: "dashed",
deactivate: "Server",
},
},
{ type: "fragment", fragment: { kind: "else", label: "rejected" } },
{ type: "activation", activation: { participant: "Server", active: true } },
{
type: "message",
message: { from: "Server", to: "Browser", label: "error", style: "dashed" },
},
{ type: "activation", activation: { participant: "Server", active: false } },
{ type: "fragment", fragment: { kind: "end", label: "alt" } },
])
})
test("parses activation syntax without rendering activation bars", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
Server-->>-Browser: response
`)
expect(output).not.toContain("┃")
expect(output).toContain("request")
expect(output).toContain("response")
})
test("parses Mermaid arrow head variants", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
A->B: open solid
B-->A: open dashed
A-xB: failed solid
B--xA: failed dashed
A-)B: async solid
B--)A: async dashed
`)
expect(diagram.messages).toEqual([
{ from: "A", to: "B", label: "open solid", style: "solid", head: "open" },
{ from: "B", to: "A", label: "open dashed", style: "dashed", head: "open" },
{ from: "A", to: "B", label: "failed solid", style: "solid", head: "cross" },
{ from: "B", to: "A", label: "failed dashed", style: "dashed", head: "cross" },
{ from: "A", to: "B", label: "async solid", style: "solid", head: "async" },
{ from: "B", to: "A", label: "async dashed", style: "dashed", head: "async" },
])
})
test("renders Mermaid arrow head variants", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
A->B: open solid
B-->A: open dashed
A-xB: failed solid
B--xA: failed dashed
A-)B: async solid
B--)A: async dashed
`)
expect(output).toMatchInlineSnapshot(`
"╭───╮ ╭───╮
│ A │ │ B │
╰─┬─╯ ╰─┬─╯
│ │
│ open solid │
├─────────────────>│
│ │
│ open dashed │
│<─────────────────┤
│ │
│ failed solid │
├─────────────────✕│
│ │
│ failed dashed │
│✕─────────────────┤
│ │
│ async solid │
├─────────────────)│
│ │
│ async dashed │
│(─────────────────┤
│ │"
`)
})
test("renders boxed alt else regions", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
alt accepted
Browser->>Server: ok
else rejected
Server-->>Browser: no
end
`)
expectDiagram(output).toContainInOrder("╭─ alt: accepted", "ok", "├─ else: rejected", "no", "╰")
expect(output).not.toContain("end alt")
})
test("expands a fragment frame for a longer else label", () => {
const output = renderSequenceDiagram(`sequenceDiagram
A->>B: start
alt ok
A->>B: yes
else validation failed with a substantially longer explanation
B-->>A: no
end`)
const lines = output.split("\n")
const elseRow = lines.find((line) => line.includes("validation failed"))!
const endRow = [...lines].reverse().find((line) => line.includes("╰"))!
expect(elseRow.lastIndexOf("┤")).toBe(endRow.lastIndexOf("╯"))
})
test("preserves combined graphemes in participant names", () => {
const output = renderSequenceDiagram(`sequenceDiagram
participant A as Cafe\u0301
participant B
A->>B: hi`)
expect(output).toContain("Cafe\u0301")
})
test("renders fragment boxes with lifeline overhang", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant A
participant B
alt ok
A->>B: yes
end
`)
const lines = output.split("\n")
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
})
test("supports configurable fragment border styles", () => {
const output = renderSequenceDiagram(
`
sequenceDiagram
participant A
participant B
alt ok
A->>B: yes
else no
B-->>A: no
end
`,
{ fragmentBorderStyle: "double" },
)
expect(output).toContain("╔")
expect(output).toContain("╠")
expect(output).toContain("╚")
expect(output).toContain("═")
expect(output).toContain("║")
})
test("parses and renders autonumbered messages", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
autonumber
Browser->>API: request
API-->>Browser: response
`)
const output = renderSequenceDiagram(`
sequenceDiagram
autonumber
Browser->>API: request
API-->>Browser: response
`)
expect(diagram.messages.map((message) => message.number)).toEqual([1, 2])
expect(output).toContain("1. request")
expect(output).toContain("2. response")
})
test("supports autonumber start and increment", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
autonumber 10 5
Browser->>API: first
API-->>Browser: second
`)
const output = renderSequenceDiagram(`
sequenceDiagram
autonumber 10 5
Browser->>API: first
API-->>Browser: second
`)
expect(diagram.messages.map((message) => message.number)).toEqual([10, 15])
expect(output).toContain("10. first")
expect(output).toContain("15. second")
})
test("parses and renders loop regions", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
loop retry up to 3x
Browser->>API: GET /users/42
API-->>Browser: 503
end
`)
const output = renderSequenceDiagram(`
sequenceDiagram
loop retry up to 3x
Browser->>API: GET /users/42
API-->>Browser: 503
end
`)
expect(diagram.steps[0]).toEqual({
type: "fragment",
fragment: { kind: "loop", label: "retry up to 3x" },
})
expect(output).toContain("╭─ ↻ loop: retry up to 3x")
expect(output).not.toContain("end loop")
expect(output.indexOf("loop: retry up to 3x")).toBeLessThan(output.indexOf("GET /users/42"))
})
test("parses Mermaid box participant groups", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
participant Cache
end
box Purple Storage Layer
participant DB
end
box "Purple Literal Label"
participant Worker
end
Browser->>API: request
`)
expect(diagram.groups).toEqual([
{ label: "Backend", participantIds: ["API", "Cache"] },
{ label: "Storage Layer", participantIds: ["DB"] },
{ label: "Purple Literal Label", participantIds: ["Worker"] },
])
expect(diagram.steps).toEqual([
{
type: "message",
message: { from: "Browser", to: "API", label: "request", style: "solid" },
},
])
})
test("adds implicit participants inside box groups", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
participant API
box Backend
API->>DB: query
end
`)
expect(diagram.groups).toEqual([{ label: "Backend", participantIds: ["API", "DB"] }])
expect(diagram.steps).toEqual([
{ type: "message", message: { from: "API", to: "DB", label: "query", style: "solid" } },
])
})
test("does not clip long non-adjacent messages or notes", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant A
participant B
participant C
A->>C: this message needs room past the final participant
Note over A,C: this note also needs full horizontal room
`)
expect(output).toContain("this message needs room past the final participant")
expect(output).toContain("this note also needs full horizontal room")
})
test("keeps long content inside participant groups and fragment frames", () => {
const group = renderSequenceDiagram(`sequenceDiagram
box Services
participant A
participant B
participant C
A->>C: this message text runs far outside of the group container boundary
end`)
const fragment = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
alt lookup
A->>C: this non adjacent message is deliberately much wider than the frame
end`)
const groupMessageRow = group.split("\n").find((line) => line.includes("this message text"))!
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
const groupedNote = renderSequenceDiagram(`sequenceDiagram
box Services
participant A
participant B
participant C
Note over A,C: this note text runs far outside of the group container boundary
end`)
const fragmentNote = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
alt lookup
Note over A,C: this non adjacent note is deliberately much wider than the frame
end`)
const nested = renderSequenceDiagram(`sequenceDiagram
participant A
participant B
alt outer
loop inner heading wider than outer frame and participant span
A->>B: x
end
end`)
expect(groupedNote).toContain("this note text runs far outside of the group container boundary")
expect(fragmentNote).toContain("this non adjacent note is deliberately much wider than the frame")
expect(nested).toContain("span ─╮│")
expect(nested).toContain("──────╯│")
})
test("does not draw external participants inside groups expanded by self messages", () => {
const output = renderSequenceDiagram(`sequenceDiagram
box G
participant A
end
participant B as External
A->>A: this self-loop extends underneath the external participant header`)
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
const lines = output.split("\n")
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
test("renders full-height participant group boxes", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
participant Cache
participant DB
end
Browser->>API: GET /users/42
API->>Cache: get user:42
`)
expect(output).toMatchInlineSnapshot(`
" ╭─ Backend ──────────────────────────────────╮
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
│ │ │ │ │ │
│ GET /users/42 │ │ │ │
├──────────────────▶ │ │ │
│ │ │ │ │ │
│ │ │ get user:42 │ │ │
│ │ ├─────────────────▶ │ │
│ │ │ │ │ │
╰────────────────────────────────────────────╯"
`)
})
test("lets message lines pass through group borders without intersections", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Browser
box Backend
participant API
end
Browser->>API: GET /users/42
`)
const arrowLine = output.split("\n").find((line) => line.includes("▶"))!
expect(arrowLine).toContain("───────────────▶")
expect(arrowLine).not.toContain("┼")
})
test("renders self messages as loopback arrows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
participant Service
Service->>Service: Check Permissions
`)
expect(output).toMatchInlineSnapshot(`
"╭─────────╮
│ Service │
╰────┬────╯
├────────────────────╮
│ Check Permissions │
◀────────────────────╯
│"
`)
})
test("places two spacer rows above note badges and one below", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
Note over Browser,Server: phase
Browser->>Server: two
`)
const lines = output.split("\n")
const noteRow = lines.findIndex((line) => line.includes("phase"))
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
test("renders br-delimited message labels across multiple rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
`)
expect(output).toContain("POST connect-token")
expect(output).toContain("· Basic (cached by browser)")
expect(output).toContain("· X-OpenCode-Ticket: 1")
expect(output.indexOf("· X-OpenCode-Ticket: 1")).toBeLessThan(output.indexOf("├"))
})
test("renders br-delimited notes as rows without source tags", () => {
const output = renderSequenceDiagram(`sequenceDiagram
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip`)
expectDiagram(output).toContainInOrder(
"directory instead?",
"the WrongKind error carries the answer —",
"branch to list, no extra round trip",
)
expect(output).not.toMatch(/<br\s*\/?\s*>/i)
})
})
+8
View File
@@ -0,0 +1,8 @@
import { drawSequenceDiagramGrid } from "./drawing.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
import { renderSequenceGridText } from "./render-grid.js"
import type { SequenceDiagramRenderOptions } from "./types.js"
export function renderSequenceDiagram(content: string, options: SequenceDiagramRenderOptions = {}): string {
return renderSequenceGridText(drawSequenceDiagramGrid(parseMermaidSequenceDiagram(content), options))
}
+274
View File
@@ -0,0 +1,274 @@
import { BorderChars, type BorderStyle } from "@opentui/core"
import { DiagramCanvas } from "../core/canvas.js"
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
import {
createSequencePlacementPlan,
type SequenceGroupPlacement,
type SequenceHorizontalBounds,
type SequenceStepPlacement,
} from "./placement.js"
import type { SequenceGrid } from "./render-grid.js"
import { SEQUENCE_FADE_STEPS as FADE_STEPS } from "./style.js"
import type {
MessageStyle,
SequenceArrowHead,
SequenceCellStyle,
SequenceDiagram,
SequenceDiagramRenderOptions,
} from "./types.js"
const SEQUENCE_BORDER = BorderChars.rounded
function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1): string {
switch (head) {
case "open":
return direction === 1 ? ">" : "<"
case "cross":
return "✕"
case "async":
return direction === 1 ? ")" : "("
default:
return direction === 1 ? "▶" : "◀"
}
}
function createGrid(width: number, height: number): SequenceGrid {
return new DiagramCanvas(width, height)
}
function setCell(grid: SequenceGrid, x: number, y: number, char: string, style?: SequenceCellStyle): void {
grid.setCell(x, y, char, style)
}
function setText(grid: SequenceGrid, x: number, y: number, text: string, style?: SequenceCellStyle): void {
grid.setText(Math.max(0, x), y, text, style)
}
function setArrowDepartureFade(
grid: SequenceGrid,
x: number,
y: number,
direction: 1 | -1,
style: SequenceCellStyle,
): void {
setCell(
grid,
x,
y,
direction === 1 ? SEQUENCE_BORDER.leftT : SEQUENCE_BORDER.rightT,
`${style}Fade1` as SequenceCellStyle,
)
for (let step = 2; step <= 5; step++) {
setCell(grid, x + direction * (step - 1), y, SEQUENCE_BORDER.horizontal, `${style}Fade${step}` as SequenceCellStyle)
}
}
function groupVerticalChar(existing: string | undefined): string | undefined {
switch (existing) {
case undefined:
case " ":
return SEQUENCE_BORDER.vertical
case SEQUENCE_BORDER.vertical:
return SEQUENCE_BORDER.vertical
default:
return undefined
}
}
function setGroupVerticalCell(grid: SequenceGrid, x: number, y: number): void {
const existing = grid.getCell(x, y)?.char
const char = groupVerticalChar(existing)
if (char) setCell(grid, x, y, char, "group")
}
function renderParticipantGroups(
grid: SequenceGrid,
groupBounds: readonly SequenceGroupPlacement[],
bottomY: number,
): void {
for (const bounds of groupBounds) {
for (let x = bounds.leftX; x <= bounds.rightX; x++) {
setCell(grid, x, 0, SEQUENCE_BORDER.horizontal, "group")
setCell(grid, x, bottomY, SEQUENCE_BORDER.horizontal, "group")
}
setCell(grid, bounds.leftX, 0, SEQUENCE_BORDER.topLeft, "group")
setCell(grid, bounds.rightX, 0, SEQUENCE_BORDER.topRight, "group")
setCell(grid, bounds.leftX, bottomY, SEQUENCE_BORDER.bottomLeft, "group")
setCell(grid, bounds.rightX, bottomY, SEQUENCE_BORDER.bottomRight, "group")
for (let y = 1; y < bottomY; y++) {
setGroupVerticalCell(grid, bounds.leftX, y)
setGroupVerticalCell(grid, bounds.rightX, y)
}
if (bounds.labelText) {
setText(grid, bounds.leftX + 2, 0, bounds.labelText, "group")
}
}
}
function drawFragmentWalls(
grid: SequenceGrid,
bounds: SequenceHorizontalBounds,
startY: number,
endY: number,
borderStyle: BorderStyle,
): void {
if (endY < startY) return
const border = BorderChars[borderStyle]
for (let y = startY; y <= endY; y++) {
setCell(grid, bounds.leftX, y, border.vertical, "fragment")
setCell(grid, bounds.rightX, y, border.vertical, "fragment")
}
}
function renderFragment(
grid: SequenceGrid,
placement: Extract<SequenceStepPlacement, { type: "fragment" }>,
borderStyle: BorderStyle,
): void {
const { bounds, fragment, labelText: label, y } = placement
const border = BorderChars[borderStyle]
const { leftX, rightX } = bounds
const leftChar =
fragment.kind === "alt" || fragment.kind === "loop"
? border.topLeft
: fragment.kind === "else"
? border.leftT
: border.bottomLeft
const rightChar =
fragment.kind === "alt" || fragment.kind === "loop"
? border.topRight
: fragment.kind === "else"
? border.rightT
: border.bottomRight
for (let x = leftX; x <= rightX; x++) {
setCell(grid, x, y, border.horizontal, "fragment")
}
setCell(grid, leftX, y, leftChar, "fragment")
setCell(grid, rightX, y, rightChar, "fragment")
if (label) {
setText(grid, leftX + 2, y, label, "fragmentLabel")
}
}
function renderSelfMessage(
grid: SequenceGrid,
placement: Extract<SequenceStepPlacement, { type: "selfMessage" }>,
style: MessageStyle,
): void {
const { centerX, rightX, topY: topRow, bottomY: bottomRow, labelLines, message } = placement
setArrowDepartureFade(grid, centerX, topRow, 1, style)
for (let x = centerX + FADE_STEPS.length; x < rightX; x++) {
setCell(grid, x, topRow, SEQUENCE_BORDER.horizontal, style)
}
setCell(grid, rightX, topRow, SEQUENCE_BORDER.topRight, style)
for (let lineIndex = 0; lineIndex < labelLines.length; lineIndex++) {
const y = topRow + lineIndex + 1
setCell(grid, centerX, y, SEQUENCE_BORDER.vertical, "lifeline")
setText(grid, centerX + 2, y, labelLines[lineIndex]!, style)
setCell(grid, rightX, y, SEQUENCE_BORDER.vertical, style)
}
for (let x = centerX + 1; x < rightX; x++) {
setCell(grid, x, bottomRow, SEQUENCE_BORDER.horizontal, style)
}
const headX = message.head === undefined ? centerX : centerX + 1
setCell(grid, headX, bottomRow, arrowHeadChar(message.head, -1), style)
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
}
export function drawSequenceDiagramGrid(
diagram: SequenceDiagram,
options: SequenceDiagramRenderOptions = {},
): SequenceGrid {
const plan = createSequencePlacementPlan(diagram, options)
if (plan.width === 0 || plan.height === 0) return createGrid(0, 0)
const fragmentBorderStyle = options.fragmentBorderStyle ?? DEFAULT_FRAGMENT_BORDER_STYLE
const grid = createGrid(plan.width, plan.height)
if (plan.groups.length > 0) renderParticipantGroups(grid, plan.groups, plan.height - 1)
for (const placement of plan.participants) {
const { participant, centerX: center, headerLeftX, headerRightX, labelX } = placement
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
if (options.compact) {
setText(grid, labelX, participantHeaderY, participant.label, "participant")
} else {
for (let x = headerLeftX; x <= headerRightX; x++) {
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
}
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
setCell(grid, headerLeftX, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerRightX, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
setText(grid, labelX, participantHeaderY, participant.label, "participant")
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
for (let y = lifelineStartY; y <= lifelineEndY; y++) {
const distance = y - lifelineStartY
const style = !options.compact && distance < 3 ? (`lifelineRamp${distance + 1}` as SequenceCellStyle) : "lifeline"
setCell(grid, center, y, SEQUENCE_BORDER.vertical, style)
}
}
for (const placement of plan.steps) {
if (placement.type === "note") {
for (let lineIndex = 0; lineIndex < placement.textLines.length; lineIndex++) {
setText(grid, placement.textX, placement.textY + lineIndex, placement.textLines[lineIndex]!, "noteBadge")
}
continue
}
if (placement.type === "fragment") {
if (placement.wallsBefore) {
drawFragmentWalls(
grid,
placement.wallsBefore.bounds,
placement.wallsBefore.startY,
placement.wallsBefore.endY,
fragmentBorderStyle,
)
}
renderFragment(grid, placement, fragmentBorderStyle)
continue
}
const message = placement.message
const messageStyle: MessageStyle = message.style === "dashed" ? "response" : "request"
if (placement.type === "selfMessage") {
renderSelfMessage(grid, placement, messageStyle)
continue
}
if (!placement.inlineLabel) {
for (let lineIndex = 0; lineIndex < placement.labelLines.length; lineIndex++) {
setText(grid, placement.labelX, placement.labelY + lineIndex, placement.labelLines[lineIndex]!, messageStyle)
}
}
for (let x = placement.leftX + 1; x < placement.rightX; x++) {
setCell(grid, x, placement.arrowY, SEQUENCE_BORDER.horizontal, messageStyle)
}
setArrowDepartureFade(grid, placement.fromX, placement.arrowY, placement.direction, messageStyle)
setCell(grid, placement.headX, placement.arrowY, arrowHeadChar(message.head, placement.direction), messageStyle)
if (placement.inlineLabel) setText(grid, placement.labelX, placement.labelY, placement.inlineLabel, messageStyle)
}
return grid
}
+8
View File
@@ -0,0 +1,8 @@
import type { BorderStyle } from "@opentui/core"
export const DEFAULT_MIN_PARTICIPANT_GAP = 18
export const DEFAULT_FRAGMENT_BORDER_STYLE = "rounded" satisfies BorderStyle
export function normalizeSequenceMinParticipantGap(value: number | undefined): number {
return value === undefined || !Number.isFinite(value) ? DEFAULT_MIN_PARTICIPANT_GAP : Math.max(1, Math.floor(value))
}
+274
View File
@@ -0,0 +1,274 @@
import {
firstMeaningfulMermaidLine,
meaningfulNumberedMermaidLines,
stripMermaidQuotes as stripQuotes,
} from "../core/mermaid.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import type {
SequenceArrowHead,
SequenceDiagram,
SequenceMessage,
SequenceParticipant,
SequenceParticipantGroup,
SequenceStep,
} from "./types.js"
const MESSAGE_RE = /^(.+?)\s*(-->>|->>|--x|-x|--\)|-\)|-->|->)([+-]?)\s*(.+?)\s*:\s*(.*)$/
const NOTE_RE = /^note\s+(over|left\s+of|right\s+of)\s+(.+?)\s*:\s*(.*)$/i
const PARTICIPANT_RE = /^(?:participant|actor)\s+(\S+)(?:\s+as\s+(.+))?$/i
const ACTIVATION_RE = /^(activate|deactivate)\s+(.+)$/i
const BOX_RE = /^box(?:\s+(.+))?$/i
const ALT_RE = /^alt\s+(.+)$/i
const ELSE_RE = /^else(?:\s+(.+))?$/i
const LOOP_RE = /^loop\s+(.+)$/i
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
const CSS_COLOR_NAMES = new Set([
"black",
"white",
"red",
"green",
"blue",
"yellow",
"cyan",
"magenta",
"silver",
"gray",
"grey",
"maroon",
"olive",
"lime",
"aqua",
"teal",
"navy",
"fuchsia",
"purple",
"orange",
"brightblack",
"brightred",
"brightgreen",
"brightblue",
"brightyellow",
"brightcyan",
"brightmagenta",
"brightwhite",
])
function arrowHeadForSyntax(arrow: string): SequenceArrowHead | undefined {
if (arrow.endsWith("x")) return "cross"
if (arrow.endsWith(")")) return "async"
if (arrow.endsWith(">") && !arrow.endsWith(">>")) return "open"
return undefined
}
function isBoxColorToken(value: string): boolean {
const lowerValue = value.toLowerCase()
return (
lowerValue === "transparent" ||
CSS_COLOR_NAMES.has(lowerValue) ||
/^#[0-9a-f]{3,8}$/i.test(value) ||
/^rgba?\(.+\)$/i.test(value)
)
}
function splitLeadingBoxToken(value: string): { token: string; rest: string } {
if (/^rgba?\(/i.test(value)) {
const closeIndex = value.indexOf(")")
if (closeIndex >= 0) {
return { token: value.slice(0, closeIndex + 1), rest: value.slice(closeIndex + 1).trim() }
}
}
const firstSpace = value.search(/\s/)
return firstSpace < 0
? { token: value, rest: "" }
: { token: value.slice(0, firstSpace), rest: value.slice(firstSpace + 1).trim() }
}
function boxLabelText(value: string | undefined): string {
const rawLabel = (value ?? "").trim()
if ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) {
return stripQuotes(rawLabel)
}
const label = stripQuotes(rawLabel)
if (!label) return ""
const { token, rest } = splitLeadingBoxToken(label)
return isBoxColorToken(token) ? stripQuotes(rest) : label
}
function addParticipantToGroup(group: SequenceParticipantGroup | undefined, participantId: string): void {
if (!group || group.participantIds.includes(participantId)) return
group.participantIds.push(participantId)
}
function ensureParticipant(
participants: SequenceParticipant[],
id: string,
label: string = id,
replaceExistingLabel: boolean = false,
): void {
const existing = participants.find((participant) => participant.id === id)
if (existing) {
if (replaceExistingLabel) existing.label = label
return
}
participants.push({ id, label })
}
export function isMermaidSequenceDiagram(content: string): boolean {
return firstMeaningfulMermaidLine(content)?.toLowerCase() === "sequencediagram"
}
export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
const participants: SequenceParticipant[] = []
const messages: SequenceMessage[] = []
const steps: SequenceStep[] = []
const groups: SequenceParticipantGroup[] = []
const blockStack: Array<{ kind: "box" | "alt" | "loop"; lineNumber: number; sourceLine: string }> = []
const groupStack: SequenceParticipantGroup[] = []
let nextMessageNumber: number | undefined
let messageNumberIncrement = 1
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
if (line.toLowerCase() === "sequencediagram") continue
const autonumberMatch = line.match(AUTONUMBER_RE)
if (autonumberMatch) {
nextMessageNumber = Number.parseInt(autonumberMatch[1] ?? "1", 10)
messageNumberIncrement = Number.parseInt(autonumberMatch[2] ?? "1", 10)
continue
}
const boxMatch = line.match(BOX_RE)
if (boxMatch) {
const group: SequenceParticipantGroup = { label: boxLabelText(boxMatch[1]), participantIds: [] }
groups.push(group)
groupStack.push(group)
blockStack.push({ kind: "box", lineNumber: source.lineNumber, sourceLine: line })
continue
}
const participantMatch = line.match(PARTICIPANT_RE)
if (participantMatch) {
const id = stripQuotes(participantMatch[1]!)
ensureParticipant(participants, id, stripQuotes(participantMatch[2] ?? id), true)
addParticipantToGroup(groupStack[groupStack.length - 1], id)
continue
}
const noteMatch = line.match(NOTE_RE)
if (noteMatch) {
const position = noteMatch[1]!.toLowerCase().replace(/\s+of$/, "") as "over" | "left" | "right"
const over = noteMatch[2]!
.split(",")
.map((participant) => stripQuotes(participant))
.filter((participant) => participant.length > 0)
for (const participant of over) ensureParticipant(participants, participant)
steps.push({
type: "note",
note: {
over,
label: stripQuotes(noteMatch[3]!),
...(position === "over" ? {} : { position }),
},
})
continue
}
const activationMatch = line.match(ACTIVATION_RE)
if (activationMatch) {
const participant = stripQuotes(activationMatch[2]!)
ensureParticipant(participants, participant)
steps.push({
type: "activation",
activation: { participant, active: activationMatch[1]!.toLowerCase() === "activate" },
})
continue
}
const altMatch = line.match(ALT_RE)
if (altMatch) {
blockStack.push({ kind: "alt", lineNumber: source.lineNumber, sourceLine: line })
steps.push({ type: "fragment", fragment: { kind: "alt", label: stripQuotes(altMatch[1]!) } })
continue
}
const loopMatch = line.match(LOOP_RE)
if (loopMatch) {
blockStack.push({ kind: "loop", lineNumber: source.lineNumber, sourceLine: line })
steps.push({ type: "fragment", fragment: { kind: "loop", label: stripQuotes(loopMatch[1]!) } })
continue
}
const elseMatch = line.match(ELSE_RE)
if (elseMatch) {
if (blockStack[blockStack.length - 1]?.kind !== "alt") {
throw new MermaidSyntaxError(
"sequence",
source.lineNumber,
line,
'Unexpected "else" without an open "alt" block',
)
}
steps.push({ type: "fragment", fragment: { kind: "else", label: stripQuotes(elseMatch[1] ?? "") } })
continue
}
if (line.toLowerCase() === "end") {
const block = blockStack.pop()
if (!block)
throw new MermaidSyntaxError("sequence", source.lineNumber, line, 'Unexpected "end" without an open block')
if (block.kind === "box") {
groupStack.pop()
continue
}
steps.push({ type: "fragment", fragment: { kind: "end", label: block.kind } })
continue
}
const messageMatch = line.match(MESSAGE_RE)
if (messageMatch) {
const from = stripQuotes(messageMatch[1]!)
const arrow = messageMatch[2]!
const activationMarker = messageMatch[3]!
const to = stripQuotes(messageMatch[4]!)
const message: SequenceMessage = {
from,
to,
label: stripQuotes(messageMatch[5]!),
style: arrow.startsWith("--") ? "dashed" : "solid",
}
const activeGroup = groupStack[groupStack.length - 1]
ensureParticipant(participants, from)
ensureParticipant(participants, to)
addParticipantToGroup(activeGroup, from)
addParticipantToGroup(activeGroup, to)
const head = arrowHeadForSyntax(arrow)
if (head) message.head = head
if (nextMessageNumber !== undefined) {
message.number = nextMessageNumber
nextMessageNumber += messageNumberIncrement
}
if (activationMarker === "+") message.activate = to
else if (activationMarker === "-") message.deactivate = from
messages.push(message)
steps.push({ type: "message", message })
continue
}
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
}
const unclosedBlock = blockStack[blockStack.length - 1]
if (unclosedBlock) {
throw new MermaidSyntaxError(
"sequence",
unclosedBlock.lineNumber,
unclosedBlock.sourceLine,
`Unclosed ${unclosedBlock.kind} block; expected "end"`,
)
}
return { participants, messages, steps, groups }
}
@@ -0,0 +1,172 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth } from "../core/text.js"
import { parseMermaidSequenceDiagram } from "./parser.js"
import { createSequencePlacementPlan } from "./placement.js"
describe("createSequencePlacementPlan", () => {
test("expands one fragment frame for a longer else label", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
A->>B: start
alt ok
A->>B: yes
else validation failed with a substantially longer explanation
B-->>A: no
end`),
)
const fragments = plan.steps.filter((step) => step.type === "fragment")
expect(fragments).toHaveLength(3)
expect(fragments.map((fragment) => fragment.bounds.rightX)).toEqual([
fragments[0]!.bounds.rightX,
fragments[0]!.bounds.rightX,
fragments[0]!.bounds.rightX,
])
expect(fragments[1]!.labelText).toContain("validation failed")
})
test("includes non-adjacent message and note labels within planned width", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
A->>C: this message needs room past the final participant
Note over A,C: this note also needs full horizontal room`),
)
const message = plan.steps.find((step) => step.type === "message")!
const note = plan.steps.find((step) => step.type === "note")!
const messageWidth = Math.max(...message.labelLines.map(diagramTextWidth))
expect(message.labelX + messageWidth).toBeLessThanOrEqual(plan.width)
expect(note.textX + Math.max(...note.textLines.map(diagramTextWidth))).toBeLessThanOrEqual(plan.width)
})
test("keeps side notes clear of adjacent participant lifelines", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
Note right of A: a long note between the first two participants
Note left of C: another long note between the final participants`),
)
const notes = plan.steps.filter((step) => step.type === "note")
expect(notes[0]!.textX + Math.max(...notes[0]!.textLines.map(diagramTextWidth))).toBeLessThan(
plan.participants[1]!.centerX,
)
expect(notes[1]!.textX).toBeGreaterThan(plan.participants[1]!.centerX)
})
test("allocates group space around a contained self-message loop", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
box Backend
participant Service
Service->>Service: Check Permissions
end`),
)
const group = plan.groups[0]!
const message = plan.steps.find((step) => step.type === "selfMessage")!
expect(group.leftX).toBeGreaterThanOrEqual(0)
expect(group.rightX).toBeGreaterThanOrEqual(message.rightX + 2)
expect(plan.width).toBeGreaterThan(group.rightX)
})
test("keeps external participants outside a group expanded by internal content", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
box G
participant A
end
participant B as External
A->>A: this self-loop extends underneath the external participant header`),
)
const group = plan.groups[0]!
const external = plan.participants.find((participant) => participant.participant.id === "B")!
expect(external.headerLeftX).toBeGreaterThan(group.rightX)
})
test("expands group and fragment frames around contained long content", () => {
const groupPlan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
box Services
participant A
participant B
participant C
A->>C: this message text runs far outside of the group container boundary
end`),
)
const group = groupPlan.groups[0]!
const groupedMessage = groupPlan.steps.find((step) => step.type === "message")!
const groupedMessageRight = groupedMessage.labelX + Math.max(...groupedMessage.labelLines.map(diagramTextWidth)) - 1
expect(group.rightX).toBeGreaterThan(groupedMessageRight)
const fragmentPlan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
participant B
participant C
alt lookup
A->>C: this non adjacent message is deliberately much wider than the frame
end`),
)
const fragment = fragmentPlan.steps
.filter((step) => step.type === "fragment")
.find((step) => step.fragment.kind === "alt")!
const fragmentMessage = fragmentPlan.steps.find((step) => step.type === "message")!
const fragmentMessageRight =
fragmentMessage.labelX + Math.max(...fragmentMessage.labelLines.map(diagramTextWidth)) - 1
expect(fragment.bounds.rightX).toBeGreaterThan(fragmentMessageRight)
})
test("sizes multiline notes and their group and fragment bounds for every br spelling", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
box Tools
participant Tool
alt retry
Note over Tool: directory instead?<br/>the WrongKind error carries the answer —<br/>branch to list, no extra round trip
Note over Tool: first<br>second<br />third
end
end`),
)
const notes = plan.steps.filter((step) => step.type === "note")
const fragment = plan.steps.filter((step) => step.type === "fragment").find((step) => step.fragment.kind === "alt")!
const group = plan.groups[0]!
const firstNoteWidth = Math.max(...notes[0]!.textLines.map(diagramTextWidth))
expect(notes[0]!.textLines.map((line) => line.trim())).toEqual([
"directory instead?",
"the WrongKind error carries the answer —",
"branch to list, no extra round trip",
])
expect(notes[1]!.textLines.map((line) => line.trim())).toEqual(["first", "second", "third"])
expect(fragment.bounds.rightX).toBeGreaterThan(notes[0]!.textX + firstNoteWidth - 1)
expect(group.rightX).toBeGreaterThan(notes[0]!.textX + firstNoteWidth - 1)
expect(plan.height).toBeGreaterThan(notes[1]!.textY + notes[1]!.textLines.length)
})
test("preserves nesting inset when a child fragment has a wide heading", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
participant B
alt outer
loop inner heading wider than outer frame and participant span
A->>B: x
end
end`),
)
const starts = plan.steps
.filter((step) => step.type === "fragment")
.filter((step) => step.fragment.kind === "alt" || step.fragment.kind === "loop")
expect(starts[0]!.bounds.rightX).toBeGreaterThan(starts[1]!.bounds.rightX)
})
})
+633
View File
@@ -0,0 +1,633 @@
import { diagramTextWidth } from "../core/text.js"
import { normalizeSequenceMinParticipantGap } from "./options.js"
import type {
SequenceDiagram,
SequenceDiagramRenderOptions,
SequenceFragment,
SequenceMessage,
SequenceNote,
SequenceParticipant,
SequenceParticipantGroup,
SequenceStep,
} from "./types.js"
const NOTE_HORIZONTAL_PADDING = 1
const GROUP_HORIZONTAL_PADDING = 2
const FRAGMENT_HORIZONTAL_OVERHANG = 3
export interface SequenceHorizontalBounds {
leftX: number
rightX: number
}
export interface SequenceParticipantPlacement {
participant: SequenceParticipant
centerX: number
headerLeftX: number
headerRightX: number
labelX: number
}
export interface SequenceGroupPlacement {
group: SequenceParticipantGroup
labelText: string
leftX: number
rightX: number
}
export interface SequenceWallPlacement {
bounds: SequenceHorizontalBounds
startY: number
endY: number
}
export type SequenceStepPlacement =
| { type: "note"; note: SequenceNote; textLines: string[]; textX: number; textY: number }
| {
type: "fragment"
fragment: SequenceFragment
labelText: string
bounds: SequenceHorizontalBounds
y: number
wallsBefore?: SequenceWallPlacement
}
| {
type: "message"
message: SequenceMessage
labelLines: string[]
labelX: number
labelY: number
arrowY: number
fromX: number
toX: number
leftX: number
rightX: number
headX: number
direction: 1 | -1
inlineLabel?: string
}
| {
type: "selfMessage"
message: SequenceMessage
labelLines: string[]
centerX: number
rightX: number
topY: number
bottomY: number
}
export interface SequencePlacementPlan {
width: number
height: number
rows: {
participantHeaderTopY: number
participantHeaderY: number
participantRuleY: number
lifelineStartY: number
lifelineEndY: number
}
participants: SequenceParticipantPlacement[]
groups: SequenceGroupPlacement[]
steps: SequenceStepPlacement[]
}
interface SequenceGroupRange {
group: SequenceParticipantGroup
startIndex: number
endIndex: number
}
interface PendingFragmentFrame {
startIndex: number
bounds: SequenceHorizontalBounds
}
interface ActiveFragmentFrame {
bounds: SequenceHorizontalBounds
boundaryY: number
}
function visualLength(value: string): number {
return diagramTextWidth(value)
}
function centeredStart(center: number, text: string): number {
return center - Math.floor(visualLength(text) / 2)
}
function mermaidLabelLines(label: string): string[] {
const lines = label.split(/(?:<br\s*\/?\s*>|\\n)/i).map((line) => line.trimEnd())
return lines.length > 0 ? lines : [""]
}
function noteLabelLines(label: string): string[] {
const lines = mermaidLabelLines(label)
const width = labelLinesWidth(lines)
const padding = " ".repeat(NOTE_HORIZONTAL_PADDING)
return lines.map((line) => `${padding}${line}${" ".repeat(width - visualLength(line))}${padding}`)
}
function messageLabelText(message: SequenceMessage): string {
return message.number === undefined ? message.label : `${message.number}. ${message.label}`
}
function participantHeaderWidth(label: string, compact: boolean): number {
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
}
function fragmentLabelText(fragment: SequenceFragment): string {
if (fragment.kind === "end") return ""
const prefix = fragment.kind === "loop" ? "↻ loop" : fragment.kind
return ` ${prefix}: ${fragment.label} `
}
function messageLabelLines(label: string): string[] {
return mermaidLabelLines(label)
}
function labelLinesWidth(lines: string[]): number {
return lines.reduce((max, line) => Math.max(max, visualLength(line)), 0)
}
function messageWidth(message: SequenceMessage): number {
return labelLinesWidth(messageLabelLines(messageLabelText(message)))
}
function selfMessageLoopWidthForLines(labelLines: string[]): number {
return Math.max(10, labelLinesWidth(labelLines) + 4)
}
function selfMessageLoopWidth(message: SequenceMessage): number {
return selfMessageLoopWidthForLines(messageLabelLines(messageLabelText(message)))
}
function arrowHeadX(toX: number, direction: 1 | -1, head: SequenceMessage["head"]): number {
return head === undefined ? toX : toX - direction
}
function inlineMessageLabel(
message: SequenceMessage,
labelLines: string[],
fromX: number,
toX: number,
compact: boolean,
): string | undefined {
if (!compact || message.from === message.to || labelLines.length !== 1) return undefined
const label = ` ${labelLines[0]} `
return visualLength(label) <= Math.abs(toX - fromX) - 3 ? label : undefined
}
function getStepHeight(
step: SequenceStep,
centers: number[],
participantIndexes: Map<string, number>,
compact: boolean,
): number {
if (step.type === "note") return noteLabelLines(step.note.label).length + 2
if (step.type === "activation") return 0
if (step.type === "fragment") return 2
const labelLines = messageLabelLines(messageLabelText(step.message))
const fromX = centers[participantIndexes.get(step.message.from) ?? -1]
const toX = centers[participantIndexes.get(step.message.to) ?? -1]
if (fromX !== undefined && toX !== undefined && inlineMessageLabel(step.message, labelLines, fromX, toX, compact)) {
return 2
}
return labelLines.length + (step.message.from === step.message.to ? 3 : 2)
}
function createParticipantIndexMap(diagram: SequenceDiagram): Map<string, number> {
return new Map(diagram.participants.map((participant, index) => [participant.id, index]))
}
function getParticipantIndexes(participantIndexes: Map<string, number>, participantIds: string[]): number[] {
return participantIds.map((id) => participantIndexes.get(id) ?? -1).filter((index) => index >= 0)
}
function groupLabelText(group: SequenceParticipantGroup): string {
return group.label ? ` ${group.label} ` : ""
}
function getGroupRanges(diagram: SequenceDiagram, participantIndexes: Map<string, number>): SequenceGroupRange[] {
return diagram.groups.flatMap((group) => {
const indexes = getParticipantIndexes(participantIndexes, group.participantIds)
return indexes.length === 0 ? [] : [{ group, startIndex: Math.min(...indexes), endIndex: Math.max(...indexes) }]
})
}
function getStepParticipantIndexes(step: SequenceStep, participantIndexes: Map<string, number>): number[] {
if (step.type === "message") {
return getParticipantIndexes(participantIndexes, [step.message.from, step.message.to])
}
if (step.type === "note") return getParticipantIndexes(participantIndexes, step.note.over)
return []
}
function getStepContentBounds(
step: SequenceStep,
centers: number[],
participantIndexes: Map<string, number>,
): SequenceHorizontalBounds | undefined {
if (step.type === "message") {
const fromIndex = participantIndexes.get(step.message.from) ?? -1
const toIndex = participantIndexes.get(step.message.to) ?? -1
if (fromIndex < 0 || toIndex < 0) return undefined
const fromX = centers[fromIndex]!
const toX = centers[toIndex]!
if (fromIndex === toIndex) return { leftX: fromX, rightX: fromX + selfMessageLoopWidth(step.message) }
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
return { leftX, rightX: Math.max(rightX, leftX + 2 + messageWidth(step.message) - 1) }
}
if (step.type !== "note") return undefined
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
if (indexes.length === 0) return undefined
const centerX = Math.floor((centers[Math.min(...indexes)]! + centers[Math.max(...indexes)]!) / 2)
const textLines = noteLabelLines(step.note.label)
const width = labelLinesWidth(textLines)
const leftX =
step.note.position === "left"
? centerX - width - 2
: step.note.position === "right"
? centerX + 2
: centerX - Math.floor(width / 2)
return { leftX, rightX: leftX + width - 1 }
}
function rangeContainsIndexes(range: SequenceGroupRange, indexes: readonly number[]): boolean {
return indexes.length > 0 && indexes.every((index) => index >= range.startIndex && index <= range.endIndex)
}
function resolveGroupBounds(
diagram: SequenceDiagram,
centers: number[],
participantIndexes: Map<string, number>,
groupRanges: SequenceGroupRange[],
compact: boolean,
): SequenceGroupPlacement[] {
return groupRanges.map((range) => {
let contentLeftX = centers[range.startIndex]!
let contentRightX = centers[range.endIndex]!
for (let i = range.startIndex; i <= range.endIndex; i++) {
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
const headerStartX = centers[i]! - Math.floor(headerWidth / 2)
contentLeftX = Math.min(contentLeftX, headerStartX)
contentRightX = Math.max(contentRightX, headerStartX + headerWidth - 1)
}
for (const step of diagram.steps) {
const indexes = getStepParticipantIndexes(step, participantIndexes)
if (!rangeContainsIndexes(range, indexes)) continue
const bounds = getStepContentBounds(step, centers, participantIndexes)
if (bounds) {
contentLeftX = Math.min(contentLeftX, bounds.leftX)
contentRightX = Math.max(contentRightX, bounds.rightX)
}
}
const labelText = groupLabelText(range.group)
let leftX = contentLeftX - GROUP_HORIZONTAL_PADDING
let rightX = contentRightX + GROUP_HORIZONTAL_PADDING
const extraWidth = Math.max(0, visualLength(labelText) + 4 - (rightX - leftX + 1))
leftX -= Math.floor(extraWidth / 2)
rightX += Math.ceil(extraWidth / 2)
return { group: range.group, labelText, leftX, rightX }
})
}
function expandHorizontalBounds(bounds: SequenceHorizontalBounds, leftX: number, rightX: number): void {
bounds.leftX = Math.min(bounds.leftX, leftX)
bounds.rightX = Math.max(bounds.rightX, rightX)
}
function getDiagramContentBounds(
diagram: SequenceDiagram,
centers: number[],
participantIndexes: Map<string, number>,
compact: boolean,
): SequenceHorizontalBounds {
const bounds = { leftX: 0, rightX: 0 }
for (let i = 0; i < diagram.participants.length; i++) {
const headerWidth = participantHeaderWidth(diagram.participants[i]!.label, compact)
const labelStartX = centers[i]! - Math.floor(headerWidth / 2)
expandHorizontalBounds(bounds, labelStartX, labelStartX + headerWidth - 1)
}
for (const step of diagram.steps) {
const content = getStepContentBounds(step, centers, participantIndexes)
if (content) expandHorizontalBounds(bounds, content.leftX, content.rightX)
}
return bounds
}
function getFragmentFrameBounds(
centers: number[],
fragment: SequenceFragment,
nestingDepth = 0,
): SequenceHorizontalBounds | undefined {
const leftParticipantX = centers[0]
const rightParticipantX = centers[centers.length - 1]
if (leftParticipantX === undefined || rightParticipantX === undefined) return undefined
const leftX = leftParticipantX - FRAGMENT_HORIZONTAL_OVERHANG + nestingDepth
const participantRightX = rightParticipantX + FRAGMENT_HORIZONTAL_OVERHANG - nestingDepth
return { leftX, rightX: Math.max(participantRightX, leftX + 2 + visualLength(fragmentLabelText(fragment)) + 1) }
}
function getFragmentFrameBoundsByStep(
centers: number[],
steps: SequenceStep[],
participantIndexes: Map<string, number>,
): Map<number, SequenceHorizontalBounds> {
const boundsByStep = new Map<number, SequenceHorizontalBounds>()
const activeFrames: PendingFragmentFrame[] = []
for (const [index, step] of steps.entries()) {
if (step.type !== "fragment") {
const content = getStepContentBounds(step, centers, participantIndexes)
if (content) {
for (const frame of activeFrames) {
expandHorizontalBounds(frame.bounds, content.leftX - 1, content.rightX + 1)
}
}
continue
}
const bounds = getFragmentFrameBounds(centers, step.fragment, activeFrames.length)
if (!bounds) continue
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
activeFrames.push({ startIndex: index, bounds: { ...bounds } })
continue
}
const frame = activeFrames[activeFrames.length - 1]
if (!frame) continue
expandHorizontalBounds(frame.bounds, bounds.leftX, bounds.rightX)
if (step.fragment.kind !== "end") continue
activeFrames.pop()
boundsByStep.set(frame.startIndex, frame.bounds)
const parent = activeFrames[activeFrames.length - 1]
if (parent) expandHorizontalBounds(parent.bounds, frame.bounds.leftX - 1, frame.bounds.rightX + 1)
}
for (const frame of activeFrames) boundsByStep.set(frame.startIndex, frame.bounds)
return boundsByStep
}
function resolveParticipantCenters(
diagram: SequenceDiagram,
participantIndexes: Map<string, number>,
minParticipantGap: number,
compact: boolean,
): number[] {
const gaps = Array.from({ length: Math.max(0, diagram.participants.length - 1) }, (_, index) => {
const left = diagram.participants[index]!
const right = diagram.participants[index + 1]!
return Math.max(
minParticipantGap,
Math.ceil(participantHeaderWidth(left.label, compact) / 2) +
Math.ceil(participantHeaderWidth(right.label, compact) / 2) +
6,
)
})
for (const message of diagram.messages) {
const fromIndex = participantIndexes.get(message.from) ?? -1
const toIndex = participantIndexes.get(message.to) ?? -1
if (fromIndex === toIndex && fromIndex >= 0 && fromIndex < diagram.participants.length - 1) {
gaps[fromIndex] = Math.max(
gaps[fromIndex]!,
selfMessageLoopWidth(message) + Math.ceil(visualLength(diagram.participants[fromIndex + 1]!.label) / 2) + 2,
)
continue
}
if (fromIndex < 0 || toIndex < 0 || Math.abs(fromIndex - toIndex) !== 1) continue
const gapIndex = Math.min(fromIndex, toIndex)
gaps[gapIndex] = Math.max(gaps[gapIndex]!, messageWidth(message) + 6)
}
for (const step of diagram.steps) {
if (step.type !== "note") continue
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
if (indexes.length === 1 && step.note.position && step.note.position !== "over") {
const participantIndex = indexes[0]!
const gapIndex = step.note.position === "left" ? participantIndex - 1 : participantIndex
if (gapIndex >= 0 && gapIndex < gaps.length) {
gaps[gapIndex] = Math.max(gaps[gapIndex]!, labelLinesWidth(noteLabelLines(step.note.label)) + 4)
}
continue
}
if (indexes.length !== 2 || Math.abs(indexes[0]! - indexes[1]!) !== 1) continue
const gapIndex = Math.min(indexes[0]!, indexes[1]!)
gaps[gapIndex] = Math.max(gaps[gapIndex]!, labelLinesWidth(noteLabelLines(step.note.label)) + 4)
}
const centers = [Math.max(1, Math.floor(participantHeaderWidth(diagram.participants[0]?.label ?? "", compact) / 2))]
for (let i = 1; i < diagram.participants.length; i++) centers[i] = centers[i - 1]! + gaps[i - 1]!
return centers
}
function separateExpandedGroupsFromExternalParticipants(
diagram: SequenceDiagram,
centers: number[],
participantIndexes: Map<string, number>,
ranges: SequenceGroupRange[],
compact: boolean,
): number[] {
const adjusted = [...centers]
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
let changed = false
const groups = resolveGroupBounds(diagram, adjusted, participantIndexes, ranges, compact)
for (const [index, range] of ranges.entries()) {
const group = groups[index]!
if (range.startIndex > 0) {
const previousIndex = range.startIndex - 1
const previousWidth = participantHeaderWidth(diagram.participants[previousIndex]!.label, compact)
const previousRight = adjusted[previousIndex]! - Math.floor(previousWidth / 2) + previousWidth - 1
const shift = previousRight + GROUP_HORIZONTAL_PADDING + 1 - group.leftX
if (shift > 0) {
for (let participantIndex = range.startIndex; participantIndex < adjusted.length; participantIndex++) {
adjusted[participantIndex]! += shift
}
changed = true
}
}
if (range.endIndex < diagram.participants.length - 1) {
const nextIndex = range.endIndex + 1
const nextWidth = participantHeaderWidth(diagram.participants[nextIndex]!.label, compact)
const nextLeft = adjusted[nextIndex]! - Math.floor(nextWidth / 2)
const shift = group.rightX + GROUP_HORIZONTAL_PADDING + 1 - nextLeft
if (shift > 0) {
for (let participantIndex = nextIndex; participantIndex < adjusted.length; participantIndex++) {
adjusted[participantIndex]! += shift
}
changed = true
}
}
}
if (!changed) return adjusted
}
return adjusted
}
export function createSequencePlacementPlan(
diagram: SequenceDiagram,
options: Pick<SequenceDiagramRenderOptions, "compact" | "minParticipantGap"> = {},
): SequencePlacementPlan {
if (diagram.participants.length === 0) {
return {
width: 0,
height: 0,
rows: {
participantHeaderTopY: 0,
participantHeaderY: 0,
participantRuleY: 0,
lifelineStartY: 0,
lifelineEndY: 0,
},
participants: [],
groups: [],
steps: [],
}
}
const indexes = createParticipantIndexMap(diagram)
const compact = options.compact ?? false
let centers = resolveParticipantCenters(
diagram,
indexes,
normalizeSequenceMinParticipantGap(options.minParticipantGap),
compact,
)
const ranges = getGroupRanges(diagram, indexes)
centers = separateExpandedGroupsFromExternalParticipants(diagram, centers, indexes, ranges, compact)
let groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
let contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
let frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
const fragmentBounds = (): SequenceHorizontalBounds => {
const result = { leftX: 0, rightX: 0 }
for (const bounds of frameBounds.values()) expandHorizontalBounds(result, bounds.leftX, bounds.rightX)
return result
}
let fragments = fragmentBounds()
const leftOverflow = Math.min(
groups.reduce((left, group) => Math.min(left, group.leftX), 0),
contentBounds.leftX,
fragments.leftX,
0,
)
if (leftOverflow < 0) {
centers = centers.map((center) => center - leftOverflow)
groups = resolveGroupBounds(diagram, centers, indexes, ranges, compact)
contentBounds = getDiagramContentBounds(diagram, centers, indexes, compact)
frameBounds = getFragmentFrameBoundsByStep(centers, diagram.steps, indexes)
fragments = fragmentBounds()
}
const hasGroups = groups.length > 0
const participantHeaderTopY = hasGroups ? 1 : 0
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
const baseHeight =
stepStartY + diagram.steps.reduce((total, step) => total + getStepHeight(step, centers, indexes, compact), 0)
const height = hasGroups ? Math.max(5, baseHeight + 1) : Math.max(3, baseHeight)
const lifelineEndY = hasGroups ? height - 2 : height - 1
const participants = diagram.participants.map((participant, index) => {
const centerX = centers[index]!
const width = participantHeaderWidth(participant.label, compact)
const headerLeftX = centerX - Math.floor(width / 2)
return {
participant,
centerX,
headerLeftX,
headerRightX: headerLeftX + width - 1,
labelX: centeredStart(centerX, participant.label),
}
})
const steps: SequenceStepPlacement[] = []
let stepY = stepStartY
const activeFrames: ActiveFragmentFrame[] = []
for (const [stepIndex, step] of diagram.steps.entries()) {
if (step.type === "activation") continue
const stepHeight = getStepHeight(step, centers, indexes, compact)
if (step.type === "note") {
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
if (noteIndexes.length > 0) {
const centerX = Math.floor((centers[Math.min(...noteIndexes)]! + centers[Math.max(...noteIndexes)]!) / 2)
const textLines = noteLabelLines(step.note.label)
const width = labelLinesWidth(textLines)
const textX =
step.note.position === "left"
? centerX - width - 2
: step.note.position === "right"
? centerX + 2
: centerX - Math.floor(width / 2)
steps.push({ type: "note", note: step.note, textLines, textX, textY: stepY + 1 })
}
stepY += stepHeight
continue
}
if (step.type === "fragment") {
let bounds = frameBounds.get(stepIndex) ?? getFragmentFrameBounds(centers, step.fragment)
let wallsBefore: SequenceWallPlacement | undefined
if (step.fragment.kind === "alt" || step.fragment.kind === "loop") {
if (bounds) activeFrames.push({ bounds, boundaryY: stepY })
} else {
const frame = activeFrames[activeFrames.length - 1]
if (frame) {
bounds = frame.bounds
wallsBefore = { bounds, startY: frame.boundaryY + 1, endY: stepY - 1 }
if (step.fragment.kind === "end") activeFrames.pop()
else frame.boundaryY = stepY
}
}
if (bounds)
steps.push({
type: "fragment",
fragment: step.fragment,
labelText: fragmentLabelText(step.fragment),
bounds,
y: stepY,
wallsBefore,
})
stepY += stepHeight
continue
}
const fromIndex = indexes.get(step.message.from) ?? -1
const toIndex = indexes.get(step.message.to) ?? -1
if (fromIndex < 0 || toIndex < 0) continue
const labelLines = messageLabelLines(messageLabelText(step.message))
if (fromIndex === toIndex) {
const centerX = centers[fromIndex]!
steps.push({
type: "selfMessage",
message: step.message,
labelLines,
centerX,
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
topY: stepY,
bottomY: stepY + labelLines.length + 1,
})
} else {
const fromX = centers[fromIndex]!
const toX = centers[toIndex]!
const direction: 1 | -1 = toX > fromX ? 1 : -1
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
steps.push({
type: "message",
message: step.message,
labelLines,
labelX: leftX + 2,
labelY: stepY,
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
fromX,
toX,
leftX,
rightX,
direction,
headX: arrowHeadX(toX, direction, step.message.head),
inlineLabel,
})
}
stepY += stepHeight
}
return {
width,
height,
rows: { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY },
participants,
groups,
steps,
}
}
@@ -0,0 +1,22 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import { sequenceStyleBackgroundColor, sequenceStyleColor, type SequenceStyleColors } from "./style.js"
import type { SequenceCellStyle } from "./types.js"
export type SequenceGrid = DiagramCanvas<SequenceCellStyle>
export function renderSequenceGridText(grid: SequenceGrid): string {
return grid.toString()
}
export function renderSequenceGridStyledText(
grid: SequenceGrid,
colors: Parameters<typeof sequenceStyleColor>[1],
): StyledText {
return renderDiagramGridStyledText(
grid,
(run) => sequenceStyleColor(run.style, colors),
(run) => sequenceStyleBackgroundColor(run.style, colors as Required<SequenceStyleColors>),
)
}
+81
View File
@@ -0,0 +1,81 @@
import { RGBA } from "@opentui/core"
import {
createColorRampTheme,
DIAGRAM_FADE_STEPS,
numberedStyleKeys,
rgba,
type DiagramRgb,
} from "../core/color/style.js"
import type { FadeStyle, LifelineRampStyle, SequenceCellStyle } from "./types.js"
export interface SequenceStyleColors {
participant?: RGBA
lifeline?: RGBA
group?: RGBA
request?: RGBA
response?: RGBA
fragment?: RGBA
fragmentLabelBg?: RGBA
note?: RGBA
noteBg?: RGBA
}
export const SEQUENCE_FADE_STEPS = DIAGRAM_FADE_STEPS
const LIFELINE_RAMP_STYLES = [
"lifelineRamp1",
"lifelineRamp2",
"lifelineRamp3",
] as const satisfies readonly LifelineRampStyle[]
const DEFAULT_THEME_RGB = {
participant: [228, 239, 232],
lifeline: [111, 138, 126],
group: [76, 99, 89],
request: [134, 225, 200],
response: [230, 177, 126],
fragment: [154, 184, 169],
fragmentLabelBg: [28, 43, 36],
noteFg: [215, 229, 221],
noteBg: [36, 56, 47],
} as const satisfies Record<string, DiagramRgb>
export function resolveSequenceStyleColors(
colors: SequenceStyleColors = {},
): Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA> {
const participant = colors.participant ?? rgba(DEFAULT_THEME_RGB.participant)
const lifeline = colors.lifeline ?? rgba(DEFAULT_THEME_RGB.lifeline)
const request = colors.request ?? rgba(DEFAULT_THEME_RGB.request)
const response = colors.response ?? rgba(DEFAULT_THEME_RGB.response)
return {
participant,
lifeline,
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
request,
response,
fragment: colors.fragment ?? rgba(DEFAULT_THEME_RGB.fragment),
fragmentLabelBg: colors.fragmentLabelBg ?? rgba(DEFAULT_THEME_RGB.fragmentLabelBg),
note: colors.note ?? rgba(DEFAULT_THEME_RGB.noteFg),
noteBg: colors.noteBg ?? rgba(DEFAULT_THEME_RGB.noteBg),
...createColorRampTheme(numberedStyleKeys("requestFade", SEQUENCE_FADE_STEPS), lifeline, request),
...createColorRampTheme(numberedStyleKeys("responseFade", SEQUENCE_FADE_STEPS), lifeline, response),
...createColorRampTheme(LIFELINE_RAMP_STYLES, participant, lifeline),
}
}
export function sequenceStyleColor(
style: SequenceCellStyle | undefined,
colors: Required<SequenceStyleColors> & Record<FadeStyle | LifelineRampStyle, RGBA>,
): RGBA | undefined {
if (style === "noteBadge") return colors.note
if (style === "fragmentLabel") return colors.fragment
return style ? colors[style] : undefined
}
export function sequenceStyleBackgroundColor(
style: SequenceCellStyle | undefined,
colors: Required<SequenceStyleColors>,
): RGBA | undefined {
if (style === "note" || style === "noteBadge") return colors.noteBg
if (style === "fragmentLabel") return colors.fragmentLabelBg
return undefined
}
+74
View File
@@ -0,0 +1,74 @@
import type { BorderStyle } from "@opentui/core"
export interface SequenceParticipant {
id: string
label: string
}
export interface SequenceParticipantGroup {
label: string
participantIds: string[]
}
export interface SequenceMessage {
from: string
to: string
label: string
style: "solid" | "dashed"
head?: SequenceArrowHead
number?: number
activate?: string
deactivate?: string
}
export type SequenceArrowHead = "open" | "cross" | "async"
export interface SequenceNote {
over: string[]
label: string
position?: "over" | "left" | "right"
}
export interface SequenceActivation {
participant: string
active: boolean
}
export interface SequenceFragment {
kind: "alt" | "else" | "loop" | "end"
label: string
}
export type SequenceStep =
| { type: "message"; message: SequenceMessage }
| { type: "note"; note: SequenceNote }
| { type: "activation"; activation: SequenceActivation }
| { type: "fragment"; fragment: SequenceFragment }
export interface SequenceDiagram {
participants: SequenceParticipant[]
messages: SequenceMessage[]
steps: SequenceStep[]
groups: SequenceParticipantGroup[]
}
export interface SequenceDiagramRenderOptions {
compact?: boolean
minParticipantGap?: number
fragmentBorderStyle?: BorderStyle
}
export type MessageStyle = "request" | "response"
export type FadeStyle = `${MessageStyle}Fade${1 | 2 | 3 | 4 | 5}`
export type LifelineRampStyle = `lifelineRamp${1 | 2 | 3}`
export type SequenceCellStyle =
| "participant"
| "lifeline"
| "group"
| MessageStyle
| FadeStyle
| LifelineRampStyle
| "fragment"
| "fragmentLabel"
| "note"
| "noteBadge"
+605
View File
@@ -0,0 +1,605 @@
import { describe, expect, test } from "bun:test"
import stringWidth from "string-width"
import { expectDiagram } from "../test/diagram.js"
import { renderStateDiagram } from "./diagram.js"
import { drawStateDiagramGrid } from "./drawing.js"
import { parseMermaidStateDiagram } from "./parser.js"
describe("StateDiagram", () => {
test("detects and parses Mermaid state diagrams", () => {
const diagram = parseMermaidStateDiagram(`
stateDiagram-v2
%% request lifecycle
direction LR
[*] --> Idle
Idle --> Loading: submit
Loading --> Success: done
Success --> [*]
`)
expect(diagram.direction).toBe("LR")
expect(diagram.states).toEqual([
{ id: "__start", label: "●", kind: "start" },
{ id: "Idle", label: "Idle", kind: "state" },
{ id: "Loading", label: "Loading", kind: "state" },
{ id: "Success", label: "Success", kind: "state" },
{ id: "__end", label: "◎", kind: "end" },
])
expect(diagram.transitions).toEqual([
{ from: "__start", to: "Idle", label: "" },
{ from: "Idle", to: "Loading", label: "submit" },
{ from: "Loading", to: "Success", label: "done" },
{ from: "Success", to: "__end", label: "" },
])
})
test("parses quoted state aliases", () => {
const diagram = parseMermaidStateDiagram(`
stateDiagram-v2
state "Waiting<br/>for Payment" as WaitingPayment
[*] --> WaitingPayment
`)
expect(diagram.states).toContainEqual({
id: "WaitingPayment",
label: "Waiting<br/>for Payment",
kind: "state",
})
})
test("parses choice pseudo-states", () => {
const diagram = parseMermaidStateDiagram(`
stateDiagram-v2
[*] --> Decision
state Decision <<choice>>
Decision --> Accepted: yes
`)
expect(diagram.states).toContainEqual({ id: "Decision", label: "┼", kind: "choice" })
})
test("parses composite states and notes", () => {
const diagram = parseMermaidStateDiagram(`
stateDiagram-v2
state Authenticated {
[*] --> Idle
Idle --> Editing: open
}
note right of Editing
Draft changes
end note
`)
expect(diagram.composites).toContainEqual({ id: "Authenticated", label: "Authenticated" })
expect(diagram.states).toContainEqual({
id: "Idle",
label: "Idle",
kind: "state",
parentId: "Authenticated",
})
expect(diagram.states).toContainEqual({
id: "Authenticated.__start",
label: "●",
kind: "start",
parentId: "Authenticated",
})
expect(diagram.notes).toEqual([{ target: "Editing", position: "right", lines: ["Draft changes"] }])
})
test("renders a horizontal state diagram", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Loading: submit
Loading --> Success: done
Success --> [*]
`)
expectDiagram(output).toEqualDiagram(`
╭──────╮ submit ╭─────────╮ done ╭─────────╮
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
╰──────╯ ╰─────────╯ ╰─────────╯
`)
})
test("renders reverse horizontal direction from right to left", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction RL
A --> B`)
const labelRow = output.split("\n").find((line) => line.includes(" A ") && line.includes(" B "))!
expect(labelRow.indexOf("B")).toBeLessThan(labelRow.indexOf("A"))
expect(output).toContain("◀")
})
test("does not mutate a parsed diagram when rendering with a direction override", () => {
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
direction LR
A --> B`)
drawStateDiagramGrid(diagram, { direction: "RL" })
expect(diagram.direction).toBe("LR")
})
test("places right-to-left transition labels between intact frames", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction RL
A --> B: reopen after a very detailed reviewer comment`)
expect(output).toContain("╭───╮")
expect(output.match(/╭───╮/g)?.length).toBe(2)
expect(output).toContain("reopen after a very detailed reviewer comment")
})
test("keeps Unicode state labels inside their measured frame", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
state "界" as Wide`)
const widths = output.split("\n").map((line) => stringWidth(line))
expect(new Set(widths).size).toBe(1)
expect(output).toContain("界")
})
test("reserves horizontal room for long transition labels", () => {
const label = "this transition label is much wider than the route"
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B: ${label}`)
const labelRow = output.split("\n").find((line) => line.includes(label))!
expect(labelRow.match(/╭───╮/g)?.length).toBe(2)
})
test("renders every line of multiline transition labels", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B: first<br/>second`)
expect(output).toContain("first")
expect(output).toContain("second")
})
test("keeps reciprocal multiline transition labels clear of routes", () => {
const output = renderStateDiagram(`stateDiagram-v2
[*] --> Running: create from base image
Running --> Dormant: 📸 suspend hook fires<br/>(WE must call it on idle)
Dormant --> Running: wake from snapshot image<br/>(apt installs restored!)
Running --> Lost: 💥 sandbox dies BEFORE hook fires<br/>(crash, our bug, race)
Lost --> Running: wake from LAST snapshot<br/>⚠ files since then GONE`)
const labelLines = [
"create from base image",
"📸 suspend hook fires",
"(WE must call it on idle)",
"wake from snapshot image",
"(apt installs restored!)",
"💥 sandbox dies BEFORE hook fires",
"(crash, our bug, race)",
"wake from LAST snapshot",
"⚠ files since then GONE",
]
for (const line of labelLines) expect(output.split(line)).toHaveLength(2)
expect(output).toMatchInlineSnapshot(`
"
create from base image ╭─────────╮
●───────────────────────▶│ Running │
╰──┬──────╯ 💥 sandbox dies BEFORE hook fires
▲ │ ▲ (crash, our bug, race)
╭────────┼─╰───┼───────╮
▼ ╭────┼─────╯ ▼
╭──────┴──╮ │ ╭──────╮
│ Dormant │ │ │ Lost │
╰─────────╯ │ ╰───┬──╯
│ │
📸 suspend hook fires │ │
(WE must call it on idle)│ │
╰───────────────╯
wake from snapshot image
(apt installs restored!)
wake from LAST snapshot
⚠ files since then GONE"
`)
})
test("renders a vertical state diagram", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction TB
[*] --> Cart
Cart --> Payment: checkout
Payment --> Complete
`)
expect(output).toMatchInlineSnapshot(`
" ●
╭──────╮
│ Cart │
╰───┬──╯
│ checkout
╭─────────╮
│ Payment │
╰────┬────╯
╭──────────╮
│ Complete │
╰──────────╯"
`)
})
test("renders branched and backward transitions visibly", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Loading: submit
Loading --> Success: 200 OK
Loading --> Error: timeout
Error --> Loading: retry
Success --> [*]
`)
expect(output).toMatchInlineSnapshot(`
" ╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
╰──────╯ ╰──┬──────╯ ╰─────────╯
│ ▲
timeout │ │
▼ │ retry
╭─────┴─╮
│ Error │
╰───────╯"
`)
})
test("captures converging labeled branches with long state names", () => {
const output = renderStateDiagram(`stateDiagram-v2
[*] --> Waiting
state "Waiting for surface and workspace" as Waiting
state "Surface bound only" as Surface
state "Workspace bound only" as Workspace
state "Ready with queued input" as Ready
state "Agent activity requested" as Active
Waiting --> Surface: InteractionSurfaceBound
Waiting --> Workspace: WorkspaceBound
Surface --> Ready: WorkspaceBound
Workspace --> Ready: InteractionSurfaceBound
Ready --> Active: AgentActivityRequested`)
expect(output).toMatchInlineSnapshot(`
" ╭───────────────────────────────────╮ InteractionSurfaceBound ╭────────────────────╮ WorkspaceBound ╭─────────────────────────╮ AgentActivityRequested ╭──────────────────────────╮
●────────────▶│ Waiting for surface and workspace ├────────────────────────▶│ Surface bound only ├───────────────▶│ Ready with queued input ├───────────────────────▶│ Agent activity requested │
╰───────────────┬───────────────────╯ ╰────────────────────╯ ╰─────────────────────────╯ ╰──────────────────────────╯
│ WorkspaceBound ▲
╰──────────────────────────────────────────────────────╮ │
│ InteractionSurfaceBound │
▼ ╭───────────────────────────────────────╯
╭─────────────┴────────╮
│ Workspace bound only │
╰──────────────────────╯"
`)
})
test("keeps raised note connectors off outgoing transitions", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Loading: submit
Loading --> Success: 200 OK
Loading --> Error: timeout
note right of Loading : waiting for response
Error --> Loading: retry
Success --> [*]
`)
expect(output).toMatchInlineSnapshot(`
" ╔══════════════════════╗
╔═══╣ waiting for response ║
║ ╚══════════════════════╝
╭──────╮ submit ╭─────────╮ 200 OK ╭─────────╮
●────────────▶│ Idle ├────────────▶│ Loading ├────────────▶│ Success ├────────────▶◎
╰──────╯ ╰──┬──────╯ ╰─────────╯
│ ▲
timeout │ │
▼ │ retry
╭─────┴─╮
│ Error │
╰───────╯"
`)
})
test("places a composite note above an occupied right side", () => {
const output = renderStateDiagram(`stateDiagram-v2
[*] --> Processing
state Processing {
[*] --> Validate
Validate --> Decision
state Decision <<choice>>
Decision --> Accepted: valid
Decision --> Rejected: invalid
Accepted --> [*]
Rejected --> [*]
}
note right of Processing
Validation and routing happen
inside this composite state.
end note
Processing --> Complete
Complete --> [*]`)
const lines = output.split("\n")
const noteBottom = lines.findIndex((line) => line.includes("╚═══════════════════════════════╝"))
const completeRow = lines.findIndex((line) => line.includes(" Complete "))
expect(noteBottom).toBeGreaterThanOrEqual(0)
expect(noteBottom).toBeLessThan(completeRow)
expect(lines.slice(noteBottom + 1, completeRow).every((line) => !line.includes("Complete"))).toBe(true)
expect(lines).toHaveLength(20)
})
test("renders configurable line arrowheads", () => {
const output = renderStateDiagram(
`
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Loading: submit
`,
{ arrowHeadStyle: "line" },
)
expect(output).toContain("→")
expect(output).not.toContain("▶")
})
test("renders self transitions and choice branches", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
state Decision <<choice>>
[*] --> Editing
Editing --> Editing: type
Editing --> Decision: submit
Decision --> Saved: ok
Decision --> Error: fail
Error --> Editing: retry
`)
expect(output).toMatchInlineSnapshot(`
" ╭─────────╮ submit ok ╭───────╮
●────────────▶│ Editing ├─────────────┬────────────▶│ Saved │
╰──┬──────╯ │ ╰───────╯
▲ │ ▲ type │ fail
│ ╰────╯ │
│ ▼
│ ╭───────╮
│ │ Error │
│ ╰───┬───╯
│ │
│ │
│ retry │
╰──────────────────────╯"
`)
})
test("connects lower routed branches into choice junctions", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
state Decision <<choice>>
[*] --> Fork
Fork --> Upper
Fork --> Lower
Upper --> Decision
Lower --> Decision
Decision --> Done
Done --> [*]`)
expect(output).toContain("Upper ├─────────────┬────────────▶│ Done")
})
test("renders self transitions as loops in vertical diagrams", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
Working --> Working: retry`)
expectDiagram(output).toEqualDiagram(`
╭─────────╮
│ Working │
╰──┬──────╯
│ ▲ retry
╰────╯
`)
})
test("renders parallel transitions without losing labels", () => {
const horizontal = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B: first
A --> B: second`)
const vertical = renderStateDiagram(`stateDiagram-v2
direction TB
A --> B: first
A --> B: second`)
expect(horizontal).toContain("first")
expect(horizontal).toContain("second")
expect(vertical).toContain("first")
expect(vertical).toContain("second")
})
test("keeps independent overlapping feedback labels and paths distinct", () => {
const content = (direction: "LR" | "RL") => `stateDiagram-v2
direction ${direction}
A --> B: advance
B --> C: continue
C --> D: finish
C --> A: reset A
D --> B: reset B`
for (const direction of ["LR", "RL"] as const) {
const output = renderStateDiagram(content(direction))
expect(output).toContain("reset A")
expect(output).toContain("reset B")
expect(output).not.toContain("res│t")
}
})
test("keeps independent internal feedback paths inside their composite frame", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
state Runtime {
A --> B: advance
B --> C: continue
C --> D: finish
C --> A: reset A
D --> B: reset B
}`)
const lines = output.split("\n")
const frameTop = lines.findIndex((line) => line.includes("Runtime"))
const upperFeedback = lines.findIndex((line) => line.includes("reset B"))
expect(upperFeedback).toBeGreaterThan(frameTop)
expect(output).toContain("reset A")
expect(output).not.toContain("res│t")
})
test("places notes away from independent feedback corridors", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B: advance
B --> C: continue
C --> D: finish
C --> A: reset A
D --> B: reset B
note right of B : note beside B`)
expect(output).toContain("note beside B")
expect(output).toContain("reset B")
expect(output).not.toContain("╭─║")
expect(output).not.toContain("║──")
})
test("keeps duplicate feedback labels away from an independent return path", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B
B --> C
C --> D
C --> A: ca
D --> B: db1
D --> B: db2`)
expect(output).toContain("ca")
expect(output).toContain("db1")
expect(output).toContain("db2")
expect(output).not.toContain("c│")
})
test("renders composite state containers", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
state Authenticated {
[*] --> Idle
Idle --> Editing: open
Editing --> [*]: save
}
`)
expect(output).toMatchInlineSnapshot(`
"╭─ Authenticated ──────────────────────────────────────────────╮
│ │
│ ╭──────╮ open ╭─────────╮ save │
│ ─────────────▶│ Idle ├────────────▶│ Editing ├────────────── │
│ ╰──────╯ ╰─────────╯ │
│ │
╰──────────────────────────────────────────────────────────────╯"
`)
})
test("routes transitions entering and leaving composite states through scoped markers", () => {
const content = `
stateDiagram-v2
direction LR
[*] --> Authenticated: login
state Authenticated {
[*] --> Idle
Idle --> Editing: open
Editing --> [*]: save
}
Authenticated --> [*]: logout
`
const diagram = parseMermaidStateDiagram(content)
const output = renderStateDiagram(content)
expect(diagram.transitions).toContainEqual({
from: "__start",
to: "Authenticated.__start",
label: "login",
})
expect(diagram.transitions).toContainEqual({
from: "Authenticated.__end",
to: "__end",
label: "logout",
})
expect(output).toMatchInlineSnapshot(`
" ╭─ Authenticated ──────────────────╮
│ │
login │ ╭──────╮ open ╭─────────╮ │ save
●────────────▶│ Idle ├────────────▶│ Editing ├────────────▶◎
│ ╰──────╯ ╰─────────╯ │
│ │
╰──────────────────────────────────╯"
`)
})
test("renders notes attached to states", () => {
const output = renderStateDiagram(`
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Loading: submit
note right of Loading : waits for response
`)
expect(output).toMatchInlineSnapshot(`
" ╭──────╮ submit ╭─────────╮ ╔════════════════════╗
●────────────▶│ Idle ├────────────▶│ Loading │════╣ waits for response ║
╰──────╯ ╰─────────╯ ╚════════════════════╝"
`)
})
test("keeps composite-to-choice elbows connected", () => {
const output = renderStateDiagram(`stateDiagram-v2
[*] --> Authenticated
state Authenticated {
[*] --> Editing
Editing --> Ready
}
note right of Editing
Draft changes
end note
Authenticated --> Decision
state Decision <<choice>>
Decision --> [*]`)
expect(output).toContain("╰─────────────┬\n")
})
})
+8
View File
@@ -0,0 +1,8 @@
import { drawStateDiagramGrid } from "./drawing.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { renderStateGridText } from "./render-grid.js"
import type { StateDiagramRenderOptions } from "./types.js"
export function renderStateDiagram(content: string, options: StateDiagramRenderOptions = {}): string {
return renderStateGridText(drawStateDiagramGrid(parseMermaidStateDiagram(content), options))
}
+298
View File
@@ -0,0 +1,298 @@
import { BorderChars, type BorderCharacters, type BorderStyle } from "@opentui/core"
import { DiagramCanvas, type DiagramCanvasCell } from "../core/canvas.js"
import {
diagramArrowHead,
diagramLineGlyph,
drawDiagramFrame,
fillDiagramFrameInterior,
mergeDiagramLineGlyph,
} from "../core/drawing.js"
import {
createStateDiagramLayout,
expandCompositeBoundsForFeedback,
expandCompositeBoundsForInternalTransitions,
type StateDiagramBoxBounds as BoxBounds,
type StateDiagramNoteBounds as StateNoteBounds,
} from "./layout.js"
import { DEFAULT_STATE_ARROW_HEAD_STYLE, DEFAULT_STATE_BORDER_STYLE, normalizeStateMinStateGap } from "./options.js"
import type { StateGrid } from "./render-grid.js"
import {
createStateTransitionJunctionPlans,
createStateTransitionRenderPlans,
measureStateTransitionLabel,
type StateTransitionRenderPlan,
} from "./routing.js"
import type {
NoteConnectorRampStyle,
StateCellStyle,
StateDiagram,
StateDiagramArrowHeadStyle,
StateDiagramRenderOptions,
StateDiagramState,
} from "./types.js"
import { isHiddenCompositeMarker, prepareVisibleStateDiagram } from "./visible-model.js"
type StateCell = DiagramCanvasCell<StateCellStyle>
function translateTransitionPlans(
plans: readonly StateTransitionRenderPlan[],
dy: number,
): StateTransitionRenderPlan[] {
return plans.map((plan) => ({
...plan,
cells: plan.cells.map((cell) => ({ ...cell, y: cell.y + dy })),
path: plan.path.map(([x, y]) => [x, y + dy]),
label: plan.label ? { ...plan.label, y: plan.label.y + dy } : undefined,
}))
}
function makeGrid(width: number, height: number): StateGrid {
return new DiagramCanvas(width, height, {
mergeCell: (existing, incoming): StateCell => {
const shouldMerge = existing.style === "transition" && incoming.style === "transition"
return {
...incoming,
char: shouldMerge
? (mergeDiagramLineGlyph(existing.char, incoming.char, "rounded") ?? incoming.char)
: incoming.char,
}
},
})
}
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 {
grid.setText(x, y, text, style)
}
function setTransitionLabel(
grid: StateGrid,
x: number,
y: number,
lines: readonly string[],
style: StateCellStyle,
): void {
lines.forEach((line, index) => setText(grid, x, y + index, line, style))
}
function drawBox(
grid: StateGrid,
state: StateDiagramState,
bounds: BoxBounds,
lines: string[],
borderStyle: BorderStyle,
): void {
if (isHiddenCompositeMarker(state)) return
if (state.kind !== "state") {
setCell(grid, bounds.left, bounds.top, state.label, state.kind)
return
}
const style: StateCellStyle = "state"
fillDiagramFrameInterior(bounds, (x, y) => setCell(grid, x, y, " ", style))
drawStateFrame(grid, bounds, BorderChars[borderStyle], style)
lines.forEach((line, index) => {
setText(grid, bounds.left + 2, bounds.top + 1 + index, line, style)
})
}
function drawStateFrame(grid: StateGrid, bounds: BoxBounds, chars: BorderCharacters, style: StateCellStyle): void {
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
}
function drawContainerFrame(
grid: StateGrid,
bounds: BoxBounds,
label: string,
chars: BorderCharacters,
style: StateCellStyle,
): void {
drawDiagramFrame(bounds, chars, (x, y, char) => setCell(grid, x, y, char, style))
if (label) setText(grid, bounds.left + 2, bounds.top, ` ${label} `, style)
}
function drawHorizontalNoteConnector(grid: StateGrid, fromX: number, toX: number, y: number, char: string): void {
const step = fromX <= toX ? 1 : -1
for (let x = fromX; step === 1 ? x <= toX : x >= toX; x += step) {
const distanceFromNote = Math.abs(toX - x)
const style: StateCellStyle =
distanceFromNote < 3 ? (`noteConnectorRamp${3 - distanceFromNote}` as NoteConnectorRampStyle) : "noteConnector"
setCell(grid, x, y, char, style)
}
}
function drawNote(grid: StateGrid, bounds: StateNoteBounds, target: BoxBounds): void {
const chars = BorderChars.double
const connectorChars = BorderChars.double
const noteX = bounds.note.position === "right" ? bounds.left - 1 : bounds.left + bounds.width
const targetX = bounds.note.position === "right" ? target.left + target.width : target.left - 1
const targetBottom = target.top + target.height - 1
const noteBottom = bounds.top + bounds.height - 1
const noteAbove = noteBottom < target.top
const noteBelow = bounds.top > targetBottom
let connectorY: number
if (noteAbove || noteBelow) {
const targetY = noteAbove ? target.top - 1 : targetBottom + 1
connectorY = bounds.centerY
const verticalStep = targetY <= connectorY ? 1 : -1
for (let y = targetY; verticalStep === 1 ? y <= connectorY : y >= connectorY; y += verticalStep) {
setCell(grid, targetX, y, connectorChars.vertical, "noteConnector")
}
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
const connectorTurnsRight = targetX <= noteX
const corner = noteAbove
? connectorTurnsRight
? connectorChars.topLeft
: connectorChars.topRight
: connectorTurnsRight
? connectorChars.bottomLeft
: connectorChars.bottomRight
setCell(grid, targetX, connectorY, corner, "noteConnector")
} else {
connectorY = Math.max(bounds.top + 1, Math.min(target.centerY, bounds.top + bounds.height - 2))
drawHorizontalNoteConnector(grid, targetX, noteX, connectorY, connectorChars.horizontal)
}
drawContainerFrame(grid, bounds, "", chars, "noteBorder")
setCell(
grid,
bounds.note.position === "right" ? bounds.left : bounds.left + bounds.width - 1,
connectorY,
bounds.note.position === "right" ? chars.rightT : chars.leftT,
"noteBorder",
)
bounds.lines.forEach((line, index) => setText(grid, bounds.left + 2, bounds.top + 1 + index, line, "noteText"))
}
function drawTransitionRenderPlan(
grid: StateGrid,
plan: StateTransitionRenderPlan,
arrowHeadStyle: StateDiagramArrowHeadStyle,
rampDeparture: boolean,
): void {
const departure = new Map(
rampDeparture
? plan.path.slice(0, 3).map(([x, y], index) => [`${x}:${y}`, `stateDepartureRamp${index + 1}` as StateCellStyle])
: [],
)
for (const cell of plan.cells) {
const char = cell.arrowDirection ? diagramArrowHead(cell.arrowDirection, arrowHeadStyle) : cell.char
setCell(grid, cell.x, cell.y, char, departure.get(`${cell.x}:${cell.y}`) ?? "transition")
}
if (plan.label) {
setTransitionLabel(grid, plan.label.x, plan.label.y, plan.label.lines, "label")
}
}
function drawTransitionJunctionPlans(
grid: StateGrid,
diagram: StateDiagram,
bounds: Map<string, BoxBounds>,
renderPlans: readonly StateTransitionRenderPlan[],
): void {
for (const plan of createStateTransitionJunctionPlans(diagram, bounds, renderPlans)) {
const style = plan.kind === "choice" ? "choice" : "transition"
setCell(grid, plan.bounds.left, plan.bounds.top, diagramLineGlyph(plan.connections, "rounded"), style)
}
}
export function drawStateDiagramGrid(sourceDiagram: StateDiagram, options: StateDiagramRenderOptions = {}): StateGrid {
const directedDiagram = options.direction ? { ...sourceDiagram, direction: options.direction } : sourceDiagram
const diagram = prepareVisibleStateDiagram(directedDiagram)
const borderStyle = options.borderStyle ?? DEFAULT_STATE_BORDER_STYLE
const arrowHeadStyle = options.arrowHeadStyle ?? DEFAULT_STATE_ARROW_HEAD_STYLE
const minStateGap = normalizeStateMinStateGap(options.minStateGap)
const { bounds, sizes, compositeBounds, noteBounds } = createStateDiagramLayout(diagram, {
minStateGap,
})
let allBounds = [...bounds.values(), ...noteBounds]
let maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
let feedbackLaneY = maxY + 3
let feedbackTopY = Math.min(0, ...allBounds.map((bound) => bound.top)) - 3
expandCompositeBoundsForFeedback(diagram, bounds, compositeBounds, feedbackLaneY)
let transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
const transitionTop = Math.min(
0,
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
)
if (transitionTop < 0) {
const dy = -transitionTop
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
bound.top += dy
bound.centerY += dy
}
feedbackLaneY += dy
feedbackTopY += dy
transitionPlans = createStateTransitionRenderPlans(diagram, bounds, feedbackLaneY, feedbackTopY)
}
expandCompositeBoundsForInternalTransitions(diagram, compositeBounds, transitionPlans)
const contentTop = Math.min(
0,
...[...bounds.values(), ...noteBounds].map((bound) => bound.top),
...transitionPlans.flatMap((plan) => [...plan.cells.map((cell) => cell.y), ...(plan.label ? [plan.label.y] : [])]),
)
if (contentTop < 0) {
const dy = -contentTop
for (const bound of new Set([...bounds.values(), ...noteBounds])) {
bound.top += dy
bound.centerY += dy
}
transitionPlans = translateTransitionPlans(transitionPlans, dy)
}
allBounds = [...bounds.values(), ...noteBounds]
const maxX = Math.max(0, ...allBounds.map((bound) => bound.left + bound.width))
maxY = Math.max(0, ...allBounds.map((bound) => bound.top + bound.height))
const transitionLabelSizes = diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label))
const maxTransitionLabelWidth = Math.max(0, ...transitionLabelSizes.map((size) => size.width))
const maxTransitionLabelLines = Math.max(0, ...transitionLabelSizes.map((size) => size.height))
const transitionRight = Math.max(
maxX,
...transitionPlans.flatMap((plan) => [
...plan.cells.map((cell) => cell.x + 1),
...(plan.label ? [plan.label.x + measureStateTransitionLabel(plan.route.transition.label).width] : []),
]),
)
const transitionBottom = Math.max(
maxY,
...transitionPlans.flatMap((plan) => [
...plan.cells.map((cell) => cell.y + 1),
...(plan.label ? [plan.label.y + plan.label.lines.length] : []),
]),
)
const grid = makeGrid(
Math.max(maxX + Math.max(24, maxTransitionLabelWidth + 4), transitionRight + 2),
Math.max(maxY + 8 + maxTransitionLabelLines, transitionBottom + 2),
)
for (const composite of diagram.composites) {
const bound = compositeBounds.get(composite.id)
if (!bound) continue
drawContainerFrame(grid, bound, composite.label, BorderChars[borderStyle], "composite")
}
for (const state of diagram.states) {
const bound = bounds.get(state.id)
const size = sizes.get(state.id)
if (!bound || !size) continue
drawBox(grid, state, bound, size.lines, borderStyle)
}
for (const plan of transitionPlans) {
const source = diagram.states.find((state) => state.id === plan.route.transition.from)
drawTransitionRenderPlan(grid, plan, arrowHeadStyle, source?.kind === "state")
}
drawTransitionJunctionPlans(grid, diagram, bounds, transitionPlans)
for (const noteBound of noteBounds) {
const target = bounds.get(noteBound.note.target)
if (target) drawNote(grid, noteBound, target)
}
return grid
}
+15
View File
@@ -0,0 +1,15 @@
export function stateDiagramStartMarkerId(scope?: string): string {
return scope ? `${scope}.__start` : "__start"
}
export function stateDiagramEndMarkerId(scope?: string): string {
return scope ? `${scope}.__end` : "__end"
}
export function stateDiagramMarkerId(position: "from" | "to", scope?: string): string {
return position === "from" ? stateDiagramStartMarkerId(scope) : stateDiagramEndMarkerId(scope)
}
export function normalizeStateDiagramEndpoint(value: string, position: "from" | "to", scope?: string): string {
return value === "[*]" ? stateDiagramMarkerId(position, scope) : value
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test"
import type { StateDiagram } from "./types.js"
import { createStateDiagramLayout } from "./layout.js"
describe("StateDiagramLayout", () => {
test("lays out horizontal main-path states before branch states", () => {
const diagram: StateDiagram = {
direction: "LR",
states: [
{ id: "A", label: "A", kind: "state" },
{ id: "B", label: "B", kind: "state" },
{ id: "C", label: "C", kind: "state" },
],
transitions: [
{ from: "A", to: "B", label: "main" },
{ from: "A", to: "C", label: "branch" },
],
composites: [],
notes: [],
}
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const a = layout.bounds.get("A")!
const b = layout.bounds.get("B")!
const c = layout.bounds.get("C")!
expect(a.left).toBeLessThan(b.left)
expect(c.top).toBeGreaterThan(a.top)
})
test("aligns a reconverging side branch under the parallel main-path stage", () => {
const diagram: StateDiagram = {
direction: "LR",
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "Fork", to: "Upper", label: "upper" },
{ from: "Fork", to: "Lower", label: "lower" },
{ from: "Upper", to: "Join", label: "join" },
{ from: "Lower", to: "Join", label: "join" },
],
composites: [],
notes: [],
}
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const upper = layout.bounds.get("Upper")!
const lower = layout.bounds.get("Lower")!
expect(lower.centerX).toBe(upper.centerX)
expect(lower.top).toBeGreaterThan(upper.top)
})
test("places note bounds outside their target state", () => {
const diagram: StateDiagram = {
direction: "LR",
states: [
{ id: "A", label: "A", kind: "state" },
{ id: "B", label: "B", kind: "state" },
],
transitions: [{ from: "A", to: "B", label: "next" }],
composites: [],
notes: [{ target: "A", position: "right", lines: ["note"] }],
}
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const target = layout.bounds.get("A")!
const note = layout.noteBounds[0]!
expect(note.left).toBeGreaterThanOrEqual(target.left + target.width)
expect(note.lines).toEqual(["note"])
})
test("widens only the horizontal gap that carries a long label", () => {
const diagram: StateDiagram = {
direction: "LR",
states: [
{ id: "A", label: "A", kind: "state" },
{ id: "B", label: "B", kind: "state" },
{ id: "C", label: "C", kind: "state" },
],
transitions: [
{ from: "A", to: "B", label: "a transition label requiring substantially more room" },
{ from: "B", to: "C", label: "ok" },
],
composites: [],
notes: [],
}
const layout = createStateDiagramLayout(diagram, { minStateGap: 5 })
const a = layout.bounds.get("A")!
const b = layout.bounds.get("B")!
const c = layout.bounds.get("C")!
const longGap = b.left - (a.left + a.width)
const shortGap = c.left - (b.left + b.width)
expect(longGap).toBeGreaterThan(shortGap)
})
})
+661
View File
@@ -0,0 +1,661 @@
import { translateDiagramBounds } from "../core/geometry.js"
import { diagramTextWidth, measureDiagramTextBox, splitDiagramLines } from "../core/text.js"
import {
hasReverseTransition,
isStateHorizontalFeedback,
measureStateTransitionLabel,
type StateTransitionRenderPlan,
} from "./routing.js"
import type {
StateDiagram,
StateDiagramCompositeState,
StateDiagramNote,
StateDiagramState,
StateDiagramTransition,
} from "./types.js"
export interface StateDiagramBoxBounds {
id: string
left: number
top: number
width: number
height: number
centerX: number
centerY: number
}
export interface StateDiagramLayout {
bounds: Map<string, StateDiagramBoxBounds>
sizes: Map<string, { width: number; height: number; lines: string[] }>
compositeBounds: Map<string, StateDiagramBoxBounds>
noteBounds: StateDiagramNoteBounds[]
}
export interface StateDiagramNoteBounds extends StateDiagramBoxBounds {
note: StateDiagramNote
lines: string[]
}
export interface StateDiagramLayoutOptions {
minStateGap: number
}
function visualLength(value: string): number {
return diagramTextWidth(value)
}
function splitStateDiagramLines(value: string): string[] {
return splitDiagramLines(value)
}
function computeRanks(diagram: StateDiagram): Map<string, number> {
const ranks = new Map<string, number>()
const outgoing = new Map<string, string[]>()
for (const transition of diagram.transitions) {
const list = outgoing.get(transition.from) ?? []
list.push(transition.to)
outgoing.set(transition.from, list)
}
const first = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
if (!first) return ranks
ranks.set(first, 0)
const queue = [first]
while (queue.length > 0) {
const id = queue.shift()!
const rank = ranks.get(id) ?? 0
for (const to of outgoing.get(id) ?? []) {
const nextRank = rank + 1
if ((ranks.get(to) ?? Number.POSITIVE_INFINITY) <= nextRank) continue
ranks.set(to, nextRank)
queue.push(to)
}
}
for (const state of diagram.states) {
if (!ranks.has(state.id)) ranks.set(state.id, ranks.size)
}
return ranks
}
function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTransition[]> {
const outgoing = new Map<string, StateDiagramTransition[]>()
for (const transition of diagram.transitions) {
const list = outgoing.get(transition.from) ?? []
list.push(transition)
outgoing.set(transition.from, list)
}
return outgoing
}
function reaches(diagram: StateDiagram, from: string, target: string): boolean {
const outgoing = outgoingTransitions(diagram)
const visited = new Set<string>()
const stack = [from]
while (stack.length > 0) {
const id = stack.pop()!
if (id === target) return true
if (visited.has(id)) continue
visited.add(id)
for (const transition of outgoing.get(id) ?? []) stack.push(transition.to)
}
return false
}
function computeMainPath(diagram: StateDiagram): string[] {
const outgoing = outgoingTransitions(diagram)
const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
if (!start) return []
const path = [start]
const visited = new Set(path)
let current = start
while (true) {
const candidates = (outgoing.get(current) ?? []).filter((transition) => !visited.has(transition.to))
if (candidates.length === 0) break
const next =
candidates.find((transition) => diagram.states.find((state) => state.id === transition.to)?.kind === "end") ??
candidates.find((transition) => !reaches(diagram, transition.to, current)) ??
candidates.find((transition) => !hasReverseTransition(diagram, transition))
if (!next) break
path.push(next.to)
visited.add(next.to)
current = next.to
}
return path
}
function stateSize(state: StateDiagramState): { width: number; height: number; lines: string[] } {
if (state.kind !== "state") return { width: 1, height: 1, lines: [state.label] }
return measureDiagramTextBox(state.label, { paddingX: 2, paddingY: 1 })
}
function noteLines(note: StateDiagramNote): string[] {
const lines = note.lines.flatMap(splitStateDiagramLines).map((line) => line.trim())
return lines.length > 0 ? lines : [""]
}
function noteSize(note: StateDiagramNote): { width: number; height: number; lines: string[] } {
const lines = noteLines(note)
const innerWidth = Math.max(...lines.map(diagramTextWidth), 1)
return { width: innerWidth + 4, height: lines.length + 2, lines }
}
function emptyLayout(
bounds: Map<string, StateDiagramBoxBounds>,
sizes: Map<string, { width: number; height: number; lines: string[] }>,
): StateDiagramLayout {
return { bounds, sizes, compositeBounds: new Map(), noteBounds: [] }
}
function shiftBounds(bounds: Iterable<StateDiagramBoxBounds>, dx: number, dy: number): void {
for (const bound of bounds) {
translateDiagramBounds(bound, dx, dy)
}
}
function uniqueBounds(...bounds: Iterable<StateDiagramBoxBounds>[]): StateDiagramBoxBounds[] {
return [...new Set(bounds.flatMap((group) => [...group]))]
}
function normalizeLayout(layout: StateDiagramLayout): void {
const allBounds = uniqueBounds(layout.bounds.values(), layout.compositeBounds.values(), layout.noteBounds)
if (allBounds.length === 0) return
const minX = Math.min(0, ...allBounds.map((bound) => bound.left))
const minY = Math.min(0, ...allBounds.map((bound) => bound.top))
if (minX === 0 && minY === 0) return
shiftBounds(allBounds, -minX, -minY)
}
function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
const statesByParent = new Map<string, string[]>()
const compositesByParent = new Map<string, StateDiagramCompositeState[]>()
for (const state of diagram.states) {
if (!state.parentId) continue
const states = statesByParent.get(state.parentId) ?? []
states.push(state.id)
statesByParent.set(state.parentId, states)
}
for (const composite of diagram.composites) {
if (!composite.parentId) continue
const composites = compositesByParent.get(composite.parentId) ?? []
composites.push(composite)
compositesByParent.set(composite.parentId, composites)
}
const addComposite = (composite: StateDiagramCompositeState): StateDiagramBoxBounds | undefined => {
const existing = layout.compositeBounds.get(composite.id)
if (existing) return existing
for (const child of compositesByParent.get(composite.id) ?? []) addComposite(child)
const childBounds = [
...(statesByParent.get(composite.id) ?? []),
...(compositesByParent.get(composite.id) ?? []).map((child) => child.id),
]
.map((id) => layout.bounds.get(id))
.filter((bound): bound is StateDiagramBoxBounds => Boolean(bound))
if (childBounds.length === 0) return undefined
const left = Math.min(...childBounds.map((bound) => bound.left)) - 2
const top = Math.min(...childBounds.map((bound) => bound.top)) - 2
const right = Math.max(...childBounds.map((bound) => bound.left + bound.width)) + 2
const bottom = Math.max(...childBounds.map((bound) => bound.top + bound.height)) + 2
const width = Math.max(right - left, visualLength(composite.label) + 5)
const bound = {
id: composite.id,
left,
top,
width,
height: bottom - top,
centerX: left + Math.floor(width / 2),
centerY: top + Math.floor((bottom - top) / 2),
}
layout.compositeBounds.set(composite.id, bound)
layout.bounds.set(composite.id, bound)
return bound
}
for (const composite of diagram.composites) addComposite(composite)
}
function addNoteBounds(diagram: StateDiagram, layout: StateDiagramLayout): void {
const compositeIds = new Set(diagram.composites.map((composite) => composite.id))
const avoidBounds = [...layout.bounds.values()].filter((bound) => !compositeIds.has(bound.id))
const noteBounds: StateDiagramNoteBounds[] = []
for (const [index, note] of diagram.notes.entries()) {
const target = layout.bounds.get(note.target)
if (!target) continue
const size = noteSize(note)
noteBounds.push(placeNote(note, index, target, size, avoidBounds, noteBounds))
}
layout.noteBounds = noteBounds
}
function intersects(
left: number,
top: number,
width: number,
height: number,
bound: StateDiagramBoxBounds,
padding = 1,
): boolean {
return (
left < bound.left + bound.width + padding &&
left + width + padding > bound.left &&
top < bound.top + bound.height + padding &&
top + height + padding > bound.top
)
}
function createNoteBound(
note: StateDiagramNote,
index: number,
left: number,
top: number,
size: { width: number; height: number; lines: string[] },
): StateDiagramNoteBounds {
return {
id: `${note.target}-note-${index}`,
left,
top,
width: size.width,
height: size.height,
centerX: left + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
note,
lines: size.lines,
}
}
function placeNote(
note: StateDiagramNote,
index: number,
target: StateDiagramBoxBounds,
size: { width: number; height: number; lines: string[] },
avoidBounds: readonly StateDiagramBoxBounds[],
existingNotes: readonly StateDiagramNoteBounds[],
): StateDiagramNoteBounds {
const gap = 4
const baseLeft = note.position === "right" ? target.left + target.width + gap : target.left - size.width - gap
const baseTop = target.centerY - Math.floor(size.height / 2)
const candidateTops = [
baseTop,
target.top - size.height - 2,
target.top + target.height + 2,
baseTop - size.height - 2,
baseTop + target.height + 2,
]
const collides = (left: number, top: number) => {
for (const bound of avoidBounds) {
if (bound.id !== target.id && intersects(left, top, size.width, size.height, bound)) return true
}
for (const bound of existingNotes) {
if (intersects(left, top, size.width, size.height, bound)) return true
}
return false
}
for (const top of candidateTops) {
if (!collides(baseLeft, top)) return createNoteBound(note, index, baseLeft, top, size)
}
const shiftedLeft =
note.position === "right"
? Math.max(...avoidBounds.map((bound) => bound.left + bound.width), target.left + target.width) + gap
: Math.min(...avoidBounds.map((bound) => bound.left), target.left) - size.width - gap
return createNoteBound(note, index, shiftedLeft, baseTop + target.height + 1, size)
}
function belongsToComposite(
id: string,
compositeId: string,
statesById: Map<string, StateDiagramState>,
compositesById: Map<string, StateDiagramCompositeState>,
): boolean {
let parentId = statesById.get(id)?.parentId ?? compositesById.get(id)?.parentId
while (parentId) {
if (parentId === compositeId) return true
parentId = compositesById.get(parentId)?.parentId
}
return false
}
function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagramLayout): void {
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 descendantNotes = layout.noteBounds.filter((noteBound) =>
belongsToComposite(noteBound.note.target, composite.id, statesById, compositesById),
)
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
bound.left = left
bound.top = top
bound.width = Math.max(right - left, visualLength(composite.label) + 5)
bound.height = bottom - top
bound.centerX = bound.left + Math.floor(bound.width / 2)
bound.centerY = bound.top + Math.floor(bound.height / 2)
}
}
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 {
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) {
const compositeBound = layout.compositeBounds.get(composite.id)
if (!compositeBound) continue
for (const state of diagram.states) {
if (belongsToComposite(state.id, composite.id, statesById, compositesById)) continue
const bound = layout.bounds.get(state.id)
if (!bound || !boundsIntersect(bound, compositeBound)) continue
const dx = compositeBound.left + compositeBound.width + 4 - bound.left
if (dx <= 0) continue
const leftThreshold = bound.left
const boundsToShift: StateDiagramBoxBounds[] = []
for (const candidate of diagram.states) {
if (belongsToComposite(candidate.id, composite.id, statesById, compositesById)) continue
const candidateBound = layout.bounds.get(candidate.id)
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
}
for (const candidate of diagram.composites) {
if (candidate.id === composite.id || belongsToComposite(candidate.id, composite.id, statesById, compositesById))
continue
const candidateBound = layout.compositeBounds.get(candidate.id)
if (candidateBound && candidateBound.left >= leftThreshold) boundsToShift.push(candidateBound)
}
shiftBounds(uniqueBounds(boundsToShift), dx, 0)
}
}
}
function finalizeLayout(diagram: StateDiagram, layout: StateDiagramLayout): StateDiagramLayout {
if (diagram.composites.length === 0 && diagram.notes.length === 0) return layout
addCompositeBounds(diagram, layout)
normalizeLayout(layout)
addNoteBounds(diagram, layout)
expandCompositeBoundsForNotes(diagram, layout)
separateExternalBoundsFromComposites(diagram, layout)
normalizeLayout(layout)
return layout
}
export function createStateDiagramLayout(
diagram: StateDiagram,
options: StateDiagramLayoutOptions,
): StateDiagramLayout {
if (diagram.direction === "LR" || diagram.direction === "RL") {
return finalizeLayout(diagram, createHorizontalLayout(diagram, options))
}
const ranks = computeRanks(diagram)
const byRank = new Map<number, StateDiagramState[]>()
for (const state of diagram.states) {
const rank = ranks.get(state.id) ?? 0
const list = byRank.get(rank) ?? []
list.push(state)
byRank.set(rank, list)
}
const rankKeys = [...byRank.keys()].sort((a, b) => a - b)
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
const bounds = new Map<string, StateDiagramBoxBounds>()
const outgoingLabelRows = 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))
}
const singleColumnCenter = Math.max(
0,
...rankKeys.flatMap((rank) => {
const states = byRank.get(rank)!
return states.length === 1 ? [Math.floor(sizes.get(states[0]!.id)!.width / 2)] : []
}),
)
let y = 0
for (const rank of rankKeys) {
const states = byRank.get(rank)!
const rowHeight = Math.max(...states.map((state) => sizes.get(state.id)!.height))
let x = 0
for (const state of states) {
const size = sizes.get(state.id)!
const top = y + Math.floor((rowHeight - size.height) / 2)
const left = states.length === 1 ? singleColumnCenter - Math.floor(size.width / 2) : x
bounds.set(state.id, {
id: state.id,
left,
top,
width: size.width,
height: size.height,
centerX: left + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
x += size.width + options.minStateGap + 8
}
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
y += rowHeight + Math.max(4, labelRows + 3)
}
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
}
function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayoutOptions): StateDiagramLayout {
const sizes = new Map(diagram.states.map((state) => [state.id, stateSize(state)]))
const bounds = new Map<string, StateDiagramBoxBounds>()
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const mainPath = computeMainPath(diagram)
const mainIds = new Set(mainPath)
const mainPathIndex = new Map(mainPath.map((id, index) => [id, index]))
const baselineY = Math.max(
1,
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height),
)
const defaultGap = options.minStateGap + 8
let x = 0
for (const [index, id] of mainPath.entries()) {
const state = statesById.get(id)
const size = sizes.get(id)
if (!state || !size) continue
const top = state.kind === "state" ? baselineY - Math.floor(size.height / 2) : baselineY
bounds.set(id, {
id,
left: x,
top,
width: size.width,
height: size.height,
centerX: x + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
const nextId = mainPath[index + 1]
const adjacentLabelWidth = diagram.transitions
.filter((transition) => transition.from === id && transition.to === nextId)
.reduce((width, transition) => Math.max(width, measureStateTransitionLabel(transition.label).width), 0)
x += size.width + Math.max(defaultGap, adjacentLabelWidth + 2)
}
const branchesByParent = new Map<string, string[]>()
for (const transition of diagram.transitions) {
if (!mainIds.has(transition.from) || mainIds.has(transition.to)) continue
const list = branchesByParent.get(transition.from) ?? []
if (!list.includes(transition.to)) list.push(transition.to)
branchesByParent.set(transition.from, list)
}
for (const [parentId, branchIds] of branchesByParent) {
const parent = bounds.get(parentId)
if (!parent) continue
const branchGap = defaultGap
const branchSizes = branchIds.map((id) => sizes.get(id)!).filter(Boolean)
const totalWidth =
branchSizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, branchSizes.length - 1) * branchGap
const parentIndex = mainPathIndex.get(parentId)
const joinIds = branchIds.map(
(id) =>
diagram.transitions.find(
(transition) =>
transition.from === id &&
mainIds.has(transition.to) &&
parentIndex !== undefined &&
(mainPathIndex.get(transition.to) ?? -1) > parentIndex + 1,
)?.to,
)
const commonJoin = joinIds[0] && joinIds.every((id) => id === joinIds[0]) ? joinIds[0] : undefined
const parallelLane = commonJoin && parentIndex !== undefined ? bounds.get(mainPath[parentIndex + 1]!) : undefined
let left = (parallelLane?.centerX ?? parent.centerX) - Math.floor(totalWidth / 2)
for (const branchId of branchIds) {
if (bounds.has(branchId)) continue
const size = sizes.get(branchId)
if (!size) continue
const top = baselineY + (parallelLane ? 6 : 5)
bounds.set(branchId, {
id: branchId,
left,
top,
width: size.width,
height: size.height,
centerX: left + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
left += size.width + branchGap
}
}
const ranks = computeRanks(diagram)
const fallbackStates = diagram.states.filter((state) => !bounds.has(state.id))
for (const state of fallbackStates) {
const size = sizes.get(state.id)!
const rank = ranks.get(state.id) ?? bounds.size
const top = baselineY + 5
const left = rank * (size.width + defaultGap)
bounds.set(state.id, {
id: state.id,
left,
top,
width: size.width,
height: size.height,
centerX: left + Math.floor(size.width / 2),
centerY: top + Math.floor(size.height / 2),
})
}
const minX = Math.min(0, ...[...bounds.values()].map((bound) => bound.left))
if (minX < 0) {
for (const bound of bounds.values()) {
bound.left -= minX
bound.centerX -= minX
}
}
if (diagram.direction === "RL") {
const right = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width))
for (const bound of bounds.values()) {
bound.left = right - bound.left - bound.width
bound.centerX = bound.left + Math.floor(bound.width / 2)
}
const branchLabelGutter = Math.max(
0,
...diagram.transitions.flatMap((transition) => {
const from = bounds.get(transition.from)
const to = bounds.get(transition.to)
return from && to && from.centerY !== to.centerY
? [measureStateTransitionLabel(transition.label).width + 2]
: []
}),
)
if (branchLabelGutter > 0) {
shiftBounds(bounds.values(), branchLabelGutter, 0)
}
}
return emptyLayout(bounds, sizes)
}
export function expandCompositeBoundsForFeedback(
diagram: StateDiagram,
bounds: Map<string, StateDiagramBoxBounds>,
compositeBounds: Map<string, StateDiagramBoxBounds>,
feedbackLaneY: number,
): 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) {
const compositeBound = compositeBounds.get(composite.id)
if (!compositeBound) continue
const hasInternalFeedback = diagram.transitions.some((transition) => {
if (!belongsToComposite(transition.from, composite.id, statesById, compositesById)) return false
if (!belongsToComposite(transition.to, composite.id, statesById, compositesById)) return false
const from = bounds.get(transition.from)
const to = bounds.get(transition.to)
return Boolean(from && to && isStateHorizontalFeedback(diagram, from, to))
})
if (!hasInternalFeedback) continue
const bottom = Math.max(compositeBound.top + compositeBound.height, feedbackLaneY + 2)
compositeBound.height = bottom - compositeBound.top
compositeBound.centerY = compositeBound.top + Math.floor(compositeBound.height / 2)
}
}
export function expandCompositeBoundsForInternalTransitions(
diagram: StateDiagram,
compositeBounds: Map<string, StateDiagramBoxBounds>,
transitionPlans: readonly StateTransitionRenderPlan[],
): void {
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) {
const bound = compositeBounds.get(composite.id)
if (!bound) continue
const internalPlans = transitionPlans.filter(
(plan) =>
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) : []),
])
if (occupiedYs.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)
bound.top = top
bound.height = bottom - top
bound.centerY = bound.top + Math.floor(bound.height / 2)
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { BorderStyle } from "@opentui/core"
import type { StateDiagramArrowHeadStyle } from "./types.js"
export const DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP = 5
export const DEFAULT_STATE_BORDER_STYLE = "rounded" satisfies BorderStyle
export const DEFAULT_STATE_ARROW_HEAD_STYLE = "filled" satisfies StateDiagramArrowHeadStyle
export function normalizeStateMinStateGap(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value)) return DEFAULT_STATE_DIAGRAM_MIN_STATE_GAP
return Math.max(1, Math.trunc(value))
}
+218
View File
@@ -0,0 +1,218 @@
import { firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
import { splitDiagramLines } from "../core/text-lines.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import { normalizeStateDiagramEndpoint, stateDiagramEndMarkerId, stateDiagramStartMarkerId } from "./endpoint.js"
import type {
StateDiagram,
StateDiagramCompositeState,
StateDiagramDirection,
StateDiagramNote,
StateDiagramState,
StateDiagramTransition,
} from "./types.js"
const DEFAULT_DIRECTION = "LR" satisfies StateDiagramDirection
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 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
return DEFAULT_DIRECTION
}
function isMermaidHeader(line: string): boolean {
return line.toLowerCase() === "statediagram-v2" || line.toLowerCase() === "statediagram"
}
function ensureState(
states: Map<string, StateDiagramState>,
id: string,
label = id,
kind: StateDiagramState["kind"] = "state",
parentId?: string,
): void {
const existing = states.get(id)
if (existing) {
if (existing.label === existing.id && label !== id) existing.label = label
if (parentId && !existing.parentId) existing.parentId = parentId
if (kind !== "state") {
existing.kind = kind
existing.label = label
}
return
}
states.set(id, parentId ? { id, label, kind, parentId } : { id, label, kind })
}
function resolveCompositeTransitionEndpoint(
id: string,
markerId: (scope?: string) => string,
compositeIds: ReadonlySet<string>,
states: Map<string, StateDiagramState>,
): string {
if (!compositeIds.has(id)) return id
const marker = markerId(id)
return states.has(marker) ? marker : id
}
function resolveCompositeTransitions(
transitions: readonly StateDiagramTransition[],
compositeIds: ReadonlySet<string>,
states: Map<string, StateDiagramState>,
): StateDiagramTransition[] {
return transitions.map((transition) => ({
from: resolveCompositeTransitionEndpoint(transition.from, stateDiagramEndMarkerId, compositeIds, states),
to: resolveCompositeTransitionEndpoint(transition.to, stateDiagramStartMarkerId, compositeIds, states),
label: transition.label,
}))
}
export function isMermaidStateDiagram(content: string): boolean {
return isMermaidHeader(firstMeaningfulMermaidLine(content) ?? "")
}
export function parseMermaidStateDiagram(content: string): StateDiagram {
const states = new Map<string, StateDiagramState>()
const transitions: StateDiagramTransition[] = []
const composites: StateDiagramCompositeState[] = []
const notes: StateDiagramNote[] = []
const parentStack: Array<{ id: string; lineNumber: number; sourceLine: string }> = []
let pendingNote:
| { target: string; position: "left" | "right"; lines: string[]; lineNumber: number; sourceLine: string }
| undefined
let direction: StateDiagramDirection = DEFAULT_DIRECTION
for (const source of numberedMermaidLines(content)) {
const line = source.text
if (pendingNote) {
if (NOTE_END_RE.test(line)) {
notes.push({
target: pendingNote.target,
position: pendingNote.position,
lines: pendingNote.lines,
})
pendingNote = undefined
} else if (line || pendingNote.lines.length > 0) {
pendingNote.lines.push(line)
}
continue
}
if (!line || line.startsWith("%%") || isMermaidHeader(line)) continue
if (line === "}") {
if (parentStack.length === 0) {
throw new MermaidSyntaxError("state", source.lineNumber, line, 'Unexpected "}" without an open composite state')
}
parentStack.pop()
continue
}
const parentId = parentStack[parentStack.length - 1]?.id
const directionMatch = line.match(DIRECTION_RE)
if (directionMatch) {
direction = normalizeDirection(directionMatch[1])
continue
}
const inlineNoteMatch = line.match(NOTE_INLINE_RE)
if (inlineNoteMatch) {
notes.push({
position: inlineNoteMatch[1]!.toLowerCase() as "left" | "right",
target: inlineNoteMatch[2]!,
lines: splitDiagramLines(inlineNoteMatch[3]!.trim()),
})
continue
}
const noteMatch = line.match(NOTE_START_RE)
if (noteMatch) {
pendingNote = {
position: noteMatch[1]!.toLowerCase() as "left" | "right",
target: noteMatch[2]!,
lines: [],
lineNumber: source.lineNumber,
sourceLine: line,
}
continue
}
const compositeMatch = line.match(COMPOSITE_STATE_RE)
if (compositeMatch) {
const id = compositeMatch[2]!
composites.push({
id,
label: compositeMatch[1] ?? id,
...(parentId ? { parentId } : {}),
})
parentStack.push({ id, lineNumber: source.lineNumber, sourceLine: line })
continue
}
const stateMatch = line.match(STATE_RE)
if (stateMatch) {
ensureState(states, stateMatch[2]!, stateMatch[1]!, "state", parentId)
continue
}
const choiceMatch = line.match(CHOICE_STATE_RE)
if (choiceMatch) {
ensureState(states, choiceMatch[1]!, "┼", "choice", parentId)
continue
}
const transitionMatch = line.match(TRANSITION_RE)
if (transitionMatch) {
const rawFrom = transitionMatch[1]!
const rawTo = transitionMatch[2]!
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)
ensureState(states, to, rawTo === "[*]" ? "◎" : to, rawTo === "[*]" ? "end" : "state", parentId)
transitions.push({ from, to, label: transitionMatch[3]?.trim() ?? "" })
continue
}
throw new MermaidSyntaxError("state", source.lineNumber, line)
}
if (pendingNote) {
throw new MermaidSyntaxError(
"state",
pendingNote.lineNumber,
pendingNote.sourceLine,
'Unclosed note; expected "end note"',
)
}
const unclosedComposite = parentStack[parentStack.length - 1]
if (unclosedComposite) {
throw new MermaidSyntaxError(
"state",
unclosedComposite.lineNumber,
unclosedComposite.sourceLine,
'Unclosed composite state; expected "}"',
)
}
if (composites.length === 0) {
return { direction, states: [...states.values()], transitions, composites, notes }
}
const compositeIds = new Set(composites.map((composite) => composite.id))
return {
direction,
states: [...states.values()].filter((state) => !compositeIds.has(state.id)),
transitions: resolveCompositeTransitions(transitions, compositeIds, states),
composites,
notes,
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { StyledText } from "@opentui/core"
import type { DiagramCanvas } from "../core/canvas.js"
import { renderDiagramGridStyledText } from "../core/render-grid.js"
import type { StateStyleColors } from "./style.js"
import type { StateCellStyle } from "./types.js"
export type StateGrid = DiagramCanvas<StateCellStyle>
export function renderStateGridText(grid: StateGrid): string {
return grid.toString({ trimBottom: true })
}
export function renderStateGridStyledText(grid: StateGrid, colors: StateStyleColors): StyledText {
return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
trimBottom: true,
})
}
+317
View File
@@ -0,0 +1,317 @@
import { describe, expect, test } from "bun:test"
import type { StateDiagramBoxBounds } from "./layout.js"
import {
createStateTransitionJunctionPlans,
createStateTransitionRenderPlans,
createStateTransitionRoutePlans,
} from "./routing.js"
import { prepareVisibleStateDiagram, type StateVisibleDiagram } from "./visible-model.js"
function bounds(id: string, centerX: number, centerY: number): StateDiagramBoxBounds {
return { id, left: centerX - 2, top: centerY - 1, width: 5, height: 3, centerX, centerY }
}
describe("createStateTransitionRoutePlans", () => {
test("classifies horizontal transition behavior before painting", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B", "C"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "A", to: "B", label: "forward" },
{ from: "B", to: "C", label: "branch" },
{ from: "C", to: "A", label: "reset" },
{ from: "B", to: "B", label: "retry" },
],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 14, 4)],
["C", bounds("C", 24, 10)],
])
const plans = createStateTransitionRoutePlans(diagram, placements, 18)
expect(plans.map((plan) => [plan.transition.label, plan.kind])).toEqual([
["forward", "horizontal-forward"],
["branch", "vertical-elbow"],
["reset", "bottom-feedback"],
["retry", "self"],
])
expect(plans.find((plan) => plan.kind === "bottom-feedback")).toMatchObject({ railY: 18 })
})
test("classifies vertical self-transitions as loops before directional routing", () => {
const diagram: StateVisibleDiagram = {
direction: "TB",
states: [{ id: "Working", label: "Working", kind: "state" }],
transitions: [{ from: "Working", to: "Working", label: "retry" }],
composites: [],
notes: [],
}
expect(
createStateTransitionRoutePlans(diagram, new Map([["Working", bounds("Working", 5, 3)]]), 12)[0],
).toMatchObject({
kind: "self",
})
})
test("allocates separate lanes for parallel transitions", () => {
const horizontal: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "A", to: "B", label: "first" },
{ from: "A", to: "B", label: "second" },
],
composites: [],
notes: [],
}
const vertical = { ...horizontal, direction: "TB" as const }
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 18, 4)],
])
const verticalPlacements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 4, 14)],
])
expect(createStateTransitionRoutePlans(horizontal, placements, 12).map((plan) => plan.kind)).toEqual([
"horizontal-forward",
"bottom-parallel",
])
expect(createStateTransitionRoutePlans(vertical, verticalPlacements, 22).map((plan) => plan.kind)).toEqual([
"vertical",
"side-parallel",
])
})
test("routes interleaving independent feedback transitions on opposite sides", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "A", to: "B", label: "advance" },
{ from: "B", to: "C", label: "continue" },
{ from: "C", to: "D", label: "finish" },
{ from: "C", to: "A", label: "reset A" },
{ from: "D", to: "B", label: "reset B" },
],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 14, 4)],
["C", bounds("C", 24, 4)],
["D", bounds("D", 34, 4)],
])
const plans = createStateTransitionRenderPlans(diagram, placements, 12).filter((plan) =>
plan.route.transition.label.startsWith("reset"),
)
expect(plans.map((plan) => plan.route.kind)).toEqual(["bottom-feedback", "top-feedback"])
const firstCells = new Set(plans[0]!.path.map(([x, y]) => `${x}:${y}`))
expect(plans[1]!.path.some(([x, y]) => firstCells.has(`${x}:${y}`))).toBe(false)
})
test("routes nested same-side feedback transitions from inner to outer rails", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "D", to: "A", label: "outer" },
{ from: "C", to: "B", label: "inner" },
],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 14, 4)],
["C", bounds("C", 24, 4)],
["D", bounds("D", 34, 4)],
])
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
const outer = plans.find((plan) => plan.route.transition.label === "outer")!
const inner = plans.find((plan) => plan.route.transition.label === "inner")!
const outerCells = new Set(outer.path.map(([x, y]) => `${x}:${y}`))
expect(outer.route).toMatchObject({ kind: "bottom-feedback", railY: 15 })
expect(inner.route).toMatchObject({ kind: "bottom-feedback", railY: 12 })
expect(inner.path.some(([x, y]) => outerCells.has(`${x}:${y}`))).toBe(false)
})
test("allocates duplicate feedback transitions without crossing an independent feedback path", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B", "C", "D"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "C", to: "A", label: "ca" },
{ from: "D", to: "B", label: "db1" },
{ from: "D", to: "B", label: "db2" },
],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 14, 4)],
["C", bounds("C", 24, 4)],
["D", bounds("D", 34, 4)],
])
const plans = createStateTransitionRenderPlans(diagram, placements, 12)
const independent = plans.find((plan) => plan.route.transition.label === "ca")!
const independentCells = new Set(independent.path.map(([x, y]) => `${x}:${y}`))
const duplicates = plans.filter((plan) => plan.route.transition.label.startsWith("db"))
expect(duplicates.map((plan) => plan.route.kind)).toEqual(["top-feedback", "top-feedback"])
expect(duplicates.every((plan) => plan.path.every(([x, y]) => !independentCells.has(`${x}:${y}`)))).toBe(true)
})
})
describe("createStateTransitionRenderPlans", () => {
test("prepares concrete cells, labels, and route paths before painting", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["A", "B"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [{ from: "A", to: "B", label: "next" }],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["B", bounds("B", 14, 4)],
])
const plan = createStateTransitionRenderPlans(diagram, placements, 18)[0]!
expect(plan.cells).toEqual([
{ x: 6, y: 4, char: "├" },
{ x: 7, y: 4, char: "─" },
{ x: 8, y: 4, char: "─" },
{ x: 9, y: 4, char: "─" },
{ x: 10, y: 4, char: "─" },
{ x: 11, y: 4, arrowDirection: "right" },
])
expect(plan.label).toEqual({ x: 8, y: 3, lines: ["next"] })
expect(plan.path).toEqual([
[6, 4],
[7, 4],
[8, 4],
[9, 4],
[10, 4],
[11, 4],
])
})
})
describe("createStateTransitionJunctionPlans", () => {
test("prepares choice topology from connected transitions", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: [
{ id: "A", label: "A", kind: "state" },
{ id: "Decision", label: "Decision", kind: "choice" },
{ id: "B", label: "B", kind: "state" },
{ id: "C", label: "C", kind: "state" },
],
transitions: [
{ from: "A", to: "Decision", label: "" },
{ from: "Decision", to: "B", label: "yes" },
{ from: "Decision", to: "C", label: "no" },
],
composites: [],
notes: [],
}
const placements = new Map([
["A", bounds("A", 4, 4)],
["Decision", { id: "Decision", left: 14, top: 4, width: 1, height: 1, centerX: 14, centerY: 4 }],
["B", bounds("B", 24, 4)],
["C", bounds("C", 4, 10)],
])
const plan = createStateTransitionJunctionPlans(
diagram,
placements,
createStateTransitionRenderPlans(diagram, placements, 18),
)[0]!
expect(plan.kind).toBe("choice")
expect([...plan.connections]).toEqual(["left", "right", "down"])
expect(plan.transitions.map((transition) => transition.label)).toEqual(["", "yes", "no"])
})
test("derives a lower choice connection from its routed elbow approach", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: [
{ id: "Upper", label: "Upper", kind: "state" },
{ id: "Lower", label: "Lower", kind: "state" },
{ id: "Decision", label: "Decision", kind: "choice" },
{ id: "Done", label: "Done", kind: "state" },
],
transitions: [
{ from: "Upper", to: "Decision", label: "" },
{ from: "Lower", to: "Decision", label: "" },
{ from: "Decision", to: "Done", label: "" },
],
composites: [],
notes: [],
}
const placements = new Map([
["Upper", bounds("Upper", 14, 4)],
["Lower", bounds("Lower", 14, 11)],
["Decision", { ...bounds("Decision", 24, 4), left: 24, top: 4, width: 1, height: 1, centerX: 24, centerY: 4 }],
["Done", bounds("Done", 34, 4)],
])
const renderPlans = createStateTransitionRenderPlans(diagram, placements, 18)
const plan = createStateTransitionJunctionPlans(diagram, placements, renderPlans)[0]!
expect([...plan.connections]).toContain("down")
})
})
describe("reconverging vertical elbows", () => {
test("uses separate top connectors for a lower parallel lane", () => {
const diagram: StateVisibleDiagram = {
direction: "LR",
states: ["Fork", "Upper", "Lower", "Join"].map((id) => ({ id, label: id, kind: "state" })),
transitions: [
{ from: "Fork", to: "Upper", label: "" },
{ from: "Fork", to: "Lower", label: "down" },
{ from: "Upper", to: "Join", label: "" },
{ from: "Lower", to: "Join", label: "up" },
],
composites: [],
notes: [],
}
const placements = new Map([
["Fork", bounds("Fork", 4, 4)],
["Upper", bounds("Upper", 14, 4)],
["Lower", bounds("Lower", 14, 11)],
["Join", bounds("Join", 24, 4)],
])
const plans = createStateTransitionRenderPlans(diagram, placements, 18)
const entering = plans.find((plan) => plan.route.transition.to === "Lower")!
const leaving = plans.find((plan) => plan.route.transition.from === "Lower")!
expect(entering.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
expect(leaving.route).toMatchObject({ kind: "vertical-elbow", offsetConnector: true })
expect(entering.path.at(-1)).not.toEqual(leaving.path[0])
expect(entering.cells.at(-1)).toMatchObject({ arrowDirection: "down" })
expect(entering.cells.at(-2)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "│" })
expect(entering.cells.at(-2)!.y).toBe(entering.cells.at(-1)!.y - 1)
expect(entering.cells.at(-3)).toMatchObject({ x: entering.cells.at(-1)!.x, char: "╮" })
expect(entering.cells.at(-3)!.y).toBe(entering.cells.at(-1)!.y - 2)
const enteringHorizontalY = entering.cells.find((cell) => cell.char === "╰")!.y
const leavingHorizontalY = leaving.cells.find((cell) => cell.char === "╭")!.y
expect(entering.label!.y).toBeLessThan(enteringHorizontalY)
expect(leaving.label!.y).toBeLessThan(leavingHorizontalY)
})
})
+734
View File
@@ -0,0 +1,734 @@
import { BorderChars } from "@opentui/core"
import type { DiagramDirection } from "../core/geometry.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js"
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
import { isHiddenCompositeMarker, type StateVisibleDiagram, type StateVisibleTransition } from "./visible-model.js"
interface StateTransitionRoutePlanBase {
transition: StateVisibleTransition
from: BoxBounds
to: BoxBounds
targetIsChoice: boolean
}
export type StateTransitionRoutePlan =
| (StateTransitionRoutePlanBase & { kind: "self" })
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
| (StateTransitionRoutePlanBase & { kind: "vertical" })
export type StateTransitionPathPoint = readonly [number, number]
interface StateTransitionRenderCellBase {
x: number
y: number
}
export type StateTransitionRenderCell = StateTransitionRenderCellBase &
({ char: string; arrowDirection?: never } | { char?: never; arrowDirection: DiagramDirection })
export interface StateTransitionRenderLabel {
x: number
y: number
lines: readonly string[]
}
export interface StateTransitionRenderPlan {
route: StateTransitionRoutePlan
cells: readonly StateTransitionRenderCell[]
path: readonly StateTransitionPathPoint[]
label?: StateTransitionRenderLabel
}
export interface StateTransitionJunctionPlan {
state: StateDiagramState
bounds: BoxBounds
connections: ReadonlySet<DiagramDirection>
transitions: readonly StateVisibleTransition[]
kind: "choice" | "hidden-composite-marker"
}
interface StateTransitionRenderBuilder {
route: StateTransitionRoutePlan
cells: StateTransitionRenderCell[]
path: StateTransitionPathPoint[]
label?: StateTransitionRenderLabel
}
export function measureStateTransitionLabel(label: string): { lines: string[]; width: number; height: number } {
if (!label) return { lines: [], width: 0, height: 0 }
const lines = splitDiagramLines(label)
return { lines, width: Math.max(...lines.map(diagramTextWidth)), height: lines.length }
}
export function hasReverseTransition(diagram: StateDiagram, transition: StateDiagramTransition): boolean {
return diagram.transitions.some((other) => other.from === transition.to && other.to === transition.from)
}
export function isStateHorizontalFeedback(
diagram: Pick<StateDiagram, "direction">,
from: BoxBounds,
to: BoxBounds,
): boolean {
if (diagram.direction === "RL") return to.centerX > from.centerX
return to.centerX < from.centerX
}
interface FeedbackAllocation {
side: "bottom" | "top"
railY: number
}
interface AllocatedFeedbackInterval extends FeedbackAllocation {
left: number
right: number
lane: number
}
interface FeedbackInterval {
transition: StateVisibleTransition
left: number
right: number
side?: "bottom" | "top"
}
function feedbackIntervalsOverlap(
left: { left: number; right: number },
right: { left: number; right: number },
): boolean {
return left.left <= right.right && right.left <= left.right
}
function feedbackIntervalsCross(
left: { left: number; right: number },
right: { left: number; right: number },
): boolean {
return (
(left.left < right.left && right.left < left.right && left.right < right.right) ||
(right.left < left.left && left.left < right.right && right.right < left.right)
)
}
function createFeedbackAllocations(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
feedbackLaneY: number,
laneGap: number,
feedbackTopY?: number,
): ReadonlyMap<StateVisibleTransition, FeedbackAllocation> {
if (diagram.direction !== "LR" && diagram.direction !== "RL") return new Map()
const allocations = new Map<StateVisibleTransition, FeedbackAllocation>()
const sidedIntervals: Record<"bottom" | "top", FeedbackInterval[]> = { bottom: [], top: [] }
const canonicalSides = new Map<string, "bottom" | "top">()
const topLaneY = feedbackTopY ?? Math.min(...[...bounds.values()].map((bound) => bound.top)) - 3
const intervals: FeedbackInterval[] = []
for (const transition of diagram.transitions) {
const from = bounds.get(transition.from)
const to = bounds.get(transition.to)
if (!from || !to || transition.from === transition.to || !isStateHorizontalFeedback(diagram, from, to)) continue
if (from.centerY !== to.centerY && !(from.centerY > to.centerY)) continue
intervals.push({ transition, left: Math.min(from.centerX, to.centerX), right: Math.max(from.centerX, to.centerX) })
}
for (const interval of intervals) {
const endpointKey = `${interval.transition.from}\u0000${interval.transition.to}`
const side =
canonicalSides.get(endpointKey) ??
(["bottom", "top"] as const).find(
(candidate) => !sidedIntervals[candidate].some((existing) => feedbackIntervalsCross(existing, interval)),
)
if (!side) continue
canonicalSides.set(endpointKey, side)
interval.side = side
sidedIntervals[side].push(interval)
}
for (const side of ["bottom", "top"] as const) {
const occupied: AllocatedFeedbackInterval[] = []
const intervalsByWidth = [...sidedIntervals[side]].sort(
(left, right) => left.right - left.left - (right.right - right.left),
)
for (const interval of intervalsByWidth) {
let lane = 0
while (occupied.some((existing) => existing.lane === lane && feedbackIntervalsOverlap(existing, interval))) lane++
const railY = side === "bottom" ? feedbackLaneY + lane * laneGap : topLaneY - lane * laneGap
occupied.push({ ...interval, side, lane, railY })
allocations.set(interval.transition, { side, railY })
}
}
return allocations
}
function hasOpposingTopConnector(
diagram: StateVisibleDiagram,
transition: StateVisibleTransition,
bounds: ReadonlyMap<string, BoxBounds>,
): boolean {
const from = bounds.get(transition.from)
const to = bounds.get(transition.to)
if (!from || !to || from.centerY === to.centerY) return false
const lowerId = from.centerY > to.centerY ? transition.from : transition.to
const lower = bounds.get(lowerId)!
const leavesLower = transition.from === lowerId
return diagram.transitions.some((other) => {
if (other === transition || (leavesLower ? other.to !== lowerId : other.from !== lowerId)) return false
const otherFrom = bounds.get(other.from)
const otherTo = bounds.get(other.to)
if (!otherFrom || !otherTo || Math.max(otherFrom.centerY, otherTo.centerY) !== lower.centerY) return false
if (other.from !== lowerId) return true
return !(
(diagram.direction === "LR" || diagram.direction === "RL") &&
isStateHorizontalFeedback(diagram, otherFrom, otherTo)
)
})
}
export function createStateTransitionRoutePlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
feedbackLaneY: number,
feedbackTopY?: number,
): StateTransitionRoutePlan[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const endpointOccurrences = new Map<string, number>()
const maxLabelWidth = Math.max(
0,
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).width),
)
const parallelLaneGap = Math.max(
3,
...diagram.transitions.map((transition) => measureStateTransitionLabel(transition.label).height + 2),
)
const sideLaneX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + maxLabelWidth + 3
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
const from = bounds.get(transition.from)
const to = bounds.get(transition.to)
if (!from || !to) return []
const targetState = statesById.get(transition.to)
const targetIsChoice = targetState?.kind === "choice" || isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice }
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
const endpointKey = `${transition.from}\u0000${transition.to}`
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
endpointOccurrences.set(endpointKey, parallelIndex + 1)
const feedback =
(diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
const feedbackAllocation = feedbackAllocations.get(transition)
if (feedbackAllocation) {
return [
{
...base,
kind: feedbackAllocation.side === "bottom" ? "bottom-feedback" : "top-feedback",
railY: feedbackAllocation.railY,
},
]
}
if (parallelIndex > 0) {
if (diagram.direction === "LR" || diagram.direction === "RL") {
return [
{
...base,
kind: "bottom-parallel",
railY: feedbackLaneY + (parallelIndex - 1) * parallelLaneGap,
},
]
}
return [{ ...base, kind: "side-parallel", railX: sideLaneX + (parallelIndex - 1) * parallelLaneGap }]
}
if (diagram.direction !== "LR" && diagram.direction !== "RL") return [{ ...base, kind: "vertical" }]
if (from.centerY !== to.centerY) {
if (from.centerY > to.centerY && feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
const hasReverse = hasReverseTransition(diagram, transition)
return [
{
...base,
kind: "vertical-elbow",
hasReverse,
offsetConnector: hasReverse || hasOpposingTopConnector(diagram, transition, bounds),
},
]
}
if (feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
})
}
function addCell(builder: StateTransitionRenderBuilder, cell: StateTransitionRenderCell): void {
builder.cells.push(cell)
builder.path.push([cell.x, cell.y])
}
function addPathPoint(builder: StateTransitionRenderBuilder, x: number, y: number): void {
builder.path.push([x, y])
}
function addLabel(builder: StateTransitionRenderBuilder, x: number, y: number, label: string): void {
const metrics = measureStateTransitionLabel(label)
if (metrics.lines.length > 0) builder.label = { x, y, lines: metrics.lines }
}
function addHorizontalLine(
builder: StateTransitionRenderBuilder,
fromX: number,
toX: number,
y: number,
direction: 1 | -1,
): void {
for (let x = fromX; direction === 1 ? x <= toX : x >= toX; x += direction) {
addCell(builder, { x, y, char: "─" })
}
}
function addVerticalLine(
builder: StateTransitionRenderBuilder,
x: number,
fromY: number,
toY: number,
direction: 1 | -1,
): void {
for (let y = fromY; direction === 1 ? y <= toY : y >= toY; y += direction) {
addCell(builder, { x, y, char: "│" })
}
}
function addRightDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
if (bounds.width <= 1 || bounds.height <= 1) return
addCell(builder, {
x: bounds.left + bounds.width - 1,
y: bounds.centerY,
char: BorderChars.rounded.leftT,
})
}
function addLeftDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds): void {
if (bounds.width <= 1 || bounds.height <= 1) return
addCell(builder, { x: bounds.left, y: bounds.centerY, char: BorderChars.rounded.rightT })
}
function addBottomDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
if (bounds.width <= 1 || bounds.height <= 1) return
addCell(builder, {
x,
y: bounds.top + bounds.height - 1,
char: BorderChars.rounded.topT,
})
}
function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBounds, x: number): void {
if (bounds.width <= 1 || bounds.height <= 1) return
addCell(builder, { x, y: bounds.top, char: BorderChars.rounded.bottomT })
}
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, leftToRight, transition } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "horizontal-forward" }
>
const y = from.centerY
if (leftToRight) addRightDeparture(builder, from)
else addLeftDeparture(builder, from)
const step = leftToRight ? 1 : -1
const startX = leftToRight ? from.left + from.width : from.left - 1
const endX = leftToRight ? to.left - 1 : to.left + to.width
addHorizontalLine(builder, startX, targetIsChoice ? endX : endX - step, y, step)
if (targetIsChoice) addPathPoint(builder, to.left, y)
else addCell(builder, { x: endX, y, arrowDirection: leftToRight ? "right" : "left" })
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
addLabel(builder, labelX, Math.max(0, y - metrics.height), transition.label)
}
function addSelfTransition(builder: StateTransitionRenderBuilder): void {
const { from: bounds, transition } = builder.route
if (bounds.width <= 1 || bounds.height <= 1) 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)))
addBottomDeparture(builder, bounds, sourceX)
addCell(builder, { x: sourceX, y: bottomY + 1, char: "│" })
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: "╯" })
addCell(builder, { x: targetX, y: bottomY + 1, arrowDirection: "up" })
if (transition.label) addLabel(builder, targetX + 2, bottomY + 1, transition.label)
}
function outsideBottomY(bounds: BoxBounds): number {
return bounds.top + bounds.height
}
function outsideTopY(bounds: BoxBounds): number {
return bounds.top - 1
}
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "bottom-feedback" | "bottom-parallel" }
>
const sourceX = from.centerX
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : targetX
const sourceBottomY = outsideBottomY(from)
const targetBottomY = outsideBottomY(to)
addBottomDeparture(builder, from, sourceX)
addVerticalLine(builder, sourceX, sourceBottomY, railY - 1, 1)
addCell(builder, { x: sourceX, y: railY, char: sourceX > railTargetX ? "╯" : "╰" })
if (sourceX !== railTargetX) {
const horizontalStep = sourceX < railTargetX ? 1 : -1
for (let x = sourceX + horizontalStep; x !== railTargetX; x += horizontalStep) {
addCell(builder, { x, y: railY, char: "─" })
}
}
addCell(builder, { x: railTargetX, y: railY, char: sourceX > railTargetX ? "╰" : "╯" })
for (let y = railY - 1; y > targetBottomY; y--) addCell(builder, { x: railTargetX, y, char: "│" })
if (railTargetX !== targetX) {
addCell(builder, { x: railTargetX, y: targetBottomY, char: railTargetX < targetX ? "╭" : "╮" })
const horizontalStep = railTargetX < targetX ? 1 : -1
for (let x = railTargetX + horizontalStep; x !== targetX; x += horizontalStep) {
addCell(builder, { x, y: targetBottomY, char: "─" })
}
}
addCell(builder, { x: targetX, y: targetBottomY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "up" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
const labelX =
metrics.width <= horizontalRoom
? Math.min(sourceX, railTargetX) + Math.max(1, Math.floor((Math.abs(sourceX - railTargetX) - metrics.width) / 2))
: railTargetX + 2
addLabel(builder, labelX, Math.max(0, railY - metrics.height), transition.label)
}
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "top-feedback" }
>
const sourceX = from.centerX
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + to.width - 2 : to.left + 1) : to.centerX
const sourceTopY = outsideTopY(from)
const targetTopY = outsideTopY(to)
addTopDeparture(builder, from, sourceX)
addVerticalLine(builder, sourceX, sourceTopY, railY + 1, -1)
addCell(builder, { x: sourceX, y: railY, char: sourceX > targetX ? "╮" : "╭" })
if (sourceX !== targetX) {
const horizontalStep = sourceX < targetX ? 1 : -1
for (let x = sourceX + horizontalStep; x !== targetX; x += horizontalStep)
addCell(builder, { x, y: railY, char: "─" })
}
addCell(builder, { x: targetX, y: railY, char: sourceX > targetX ? "╭" : "╮" })
for (let y = railY + 1; y < targetTopY; y++) addCell(builder, { x: targetX, y, char: "│" })
addCell(builder, { x: targetX, y: targetTopY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "down" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - targetX) - 2
const labelX =
metrics.width <= horizontalRoom
? Math.min(sourceX, targetX) + Math.max(1, Math.floor((Math.abs(sourceX - targetX) - metrics.width) / 2))
: targetX + 2
addLabel(builder, labelX, railY - metrics.height, transition.label)
}
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railX } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "side-parallel" }
>
const startX = from.left + from.width
const endX = to.left + to.width
const startY = from.centerY
const endY = to.centerY
const verticalStep: 1 | -1 = startY <= endY ? 1 : -1
addRightDeparture(builder, from)
addHorizontalLine(builder, startX, railX - 1, startY, 1)
addCell(builder, { x: railX, y: startY, char: verticalStep === 1 ? "╮" : "╯" })
for (let y = startY + verticalStep; y !== endY; y += verticalStep) addCell(builder, { x: railX, y, char: "│" })
addCell(builder, { x: railX, y: endY, char: verticalStep === 1 ? "╯" : "╮" })
for (let x = railX - 1; x > endX; x--) addCell(builder, { x, y: endY, char: "─" })
addCell(builder, { x: endX, y: endY, ...(targetIsChoice ? { char: "─" } : { arrowDirection: "left" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (transition.label) addLabel(builder, railX + 2, Math.min(startY, endY) + 1, transition.label)
}
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
if (bounds.width <= 2) return bounds.centerX
return Math.max(bounds.left + 1, Math.min(bounds.left + bounds.width - 2, preferredX))
}
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice, hasReverse, offsetConnector } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "vertical-elbow" }
>
const topToBottom = from.centerY < to.centerY
const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
const startX = innerConnectorX(from, from.centerX + offset)
const endX = innerConnectorX(to, to.centerX + offset)
const startY = topToBottom ? from.top + from.height : from.top - 1
const endY = topToBottom ? to.top - 1 : to.top + to.height
const verticalStep = topToBottom ? 1 : -1
if (topToBottom) addBottomDeparture(builder, from, startX)
else addTopDeparture(builder, from, startX)
const availableApproach = Math.max(0, Math.abs(endY - startY) - 1)
const bendY = startX === endX ? endY : topToBottom ? endY - verticalStep * Math.min(2, availableApproach) : startY
const targetApproachLength = Math.abs(endY - bendY)
const hasTargetApproach = targetApproachLength > 0
if (startY !== bendY) addVerticalLine(builder, startX, startY, bendY - verticalStep, verticalStep)
if (startX !== endX) {
const horizontalStep = startX < endX ? 1 : -1
addCell(builder, {
x: startX,
y: bendY,
char: topToBottom ? (startX < endX ? "╰" : "╯") : startX < endX ? "╭" : "╮",
})
for (let x = startX + horizontalStep; x !== endX; x += horizontalStep) addCell(builder, { x, y: bendY, char: "─" })
if (hasTargetApproach) {
addCell(builder, {
x: endX,
y: bendY,
char: topToBottom ? (startX < endX ? "╮" : "╭") : startX < endX ? "╯" : "╰",
})
for (let distance = 1; distance < targetApproachLength; distance++) {
addCell(builder, { x: endX, y: bendY + verticalStep * distance, char: "│" })
}
}
}
const targetChar = targetIsChoice ? (hasTargetApproach || startX === endX ? "│" : topToBottom ? "┬" : "┴") : undefined
addCell(builder, {
x: endX,
y: endY,
...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
})
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
if (topToBottom) {
const leftLabelX = startX - metrics.width - 2
const labelX = hasReverse || endX < startX ? (leftLabelX >= 0 ? leftLabelX : startX + 4) : startX + 2
addLabel(
builder,
labelX,
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.min(startY + 1, endY),
transition.label,
)
} else {
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
addLabel(
builder,
startX === endX ? startX + 3 : labelX,
hasTargetApproach ? Math.max(0, bendY - metrics.height) : Math.max(0, startY),
transition.label,
)
}
}
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice } = builder.route
const topToBottom = from.centerY <= to.centerY
const x = from.centerX
const startY = topToBottom ? from.top + from.height : from.top - 1
const endY = topToBottom ? to.top - 1 : to.top + to.height
const step = topToBottom ? 1 : -1
if (topToBottom) addBottomDeparture(builder, from, x)
else addTopDeparture(builder, from, x)
if (startY !== endY) addVerticalLine(builder, x, startY, endY - step, step)
addCell(builder, {
x,
y: endY,
...(targetIsChoice ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
})
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
}
function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): StateTransitionRenderPlan {
const builder: StateTransitionRenderBuilder = { route, cells: [], path: [] }
switch (route.kind) {
case "self":
addSelfTransition(builder)
break
case "horizontal-forward":
addHorizontalForward(builder)
break
case "bottom-feedback":
case "bottom-parallel":
addBottomLaneTransition(builder)
break
case "top-feedback":
addTopFeedbackTransition(builder)
break
case "vertical-elbow":
addVerticalElbowTransition(builder)
break
case "vertical":
addVerticalTransition(builder)
break
case "side-parallel":
addSideParallelTransition(builder)
break
}
return builder
}
interface StateTransitionLabelRect {
left: number
top: number
width: number
height: number
}
function labelRect(label: StateTransitionRenderLabel, width: number): StateTransitionLabelRect {
return { left: label.x, top: label.y, width, height: label.lines.length }
}
function rectsOverlap(left: StateTransitionLabelRect, right: StateTransitionLabelRect): 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 placeStateTransitionLabels(
plans: readonly StateTransitionRenderPlan[],
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
): StateTransitionRenderPlan[] {
const routeCells = new Set(plans.flatMap((plan) => plan.cells.map((cell) => `${cell.x}:${cell.y}`)))
const placedLabels: StateTransitionLabelRect[] = []
const stateRects = diagram.states.flatMap((state) => {
const bound = bounds.get(state.id)
return bound && !isHiddenCompositeMarker(state)
? [{ left: bound.left, top: bound.top, width: bound.width, height: bound.height }]
: []
})
return plans.map((plan) => {
if (!plan.label) return plan
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
if (plan.label.lines.length === 1) {
placedLabels.push(labelRect(plan.label, width))
return plan
}
const statePadding = 1
const isClear = (x: number, y: number): boolean => {
if (x < 0 || y < 0) return false
const rect = labelRect({ ...plan.label!, x, y }, width)
if (
stateRects.some((state) =>
rectsOverlap(rect, {
left: state.left - statePadding,
top: state.top - statePadding,
width: state.width + statePadding * 2,
height: state.height + statePadding * 2,
}),
)
)
return false
if (placedLabels.some((label) => rectsOverlap(rect, label))) return false
for (let row = rect.top; row < rect.top + rect.height; row++) {
for (let column = rect.left; column < rect.left + rect.width; column++) {
if (routeCells.has(`${column}:${row}`)) return false
}
}
return true
}
let x = plan.label.x
let y = plan.label.y
if (!isClear(x, y)) {
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
x = candidateX
y = candidateY
break search
}
}
}
}
placedLabels.push(labelRect({ ...plan.label, x, y }, width))
return { ...plan, label: { ...plan.label, x, y } }
})
}
export function createStateTransitionRenderPlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
feedbackLaneY: number,
feedbackTopY?: number,
): StateTransitionRenderPlan[] {
return placeStateTransitionLabels(
createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan),
diagram,
bounds,
)
}
function connectionDirection(from: StateTransitionPathPoint, to: StateTransitionPathPoint): DiagramDirection {
const deltaX = to[0] - from[0]
const deltaY = to[1] - from[1]
if (Math.abs(deltaX) >= Math.abs(deltaY) && deltaX !== 0) return deltaX > 0 ? "right" : "left"
if (deltaY !== 0) return deltaY > 0 ? "down" : "up"
return "right"
}
export function createStateTransitionJunctionPlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
renderPlans: readonly StateTransitionRenderPlan[],
): StateTransitionJunctionPlan[] {
return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
const kind =
state.kind === "choice" ? "choice" : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
if (!kind) return []
const stateBounds = bounds.get(state.id)
if (!stateBounds) return []
const connections = new Set<DiagramDirection>()
const transitions: StateVisibleTransition[] = []
for (const transition of diagram.transitions) {
const renderPlan = renderPlans.find((plan) => plan.route.transition === transition)
let connected = false
if (transition.to === state.id) {
const junction = renderPlan?.path.at(-1)
const neighbor = renderPlan?.path.at(-2)
if (junction && neighbor) connections.add(connectionDirection(junction, neighbor))
connected = true
}
if (transition.from === state.id) {
const neighbor = renderPlan?.path[0]
if (neighbor) connections.add(connectionDirection([stateBounds.left, stateBounds.top], neighbor))
connected = true
}
if (connected) transitions.push(transition)
}
return [{ state, bounds: stateBounds, connections, transitions, kind }]
})
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { drawStateDiagramGrid } from "./drawing.js"
import { parseMermaidStateDiagram } from "./parser.js"
import { resolveStateStyleColors } from "./style.js"
describe("state note connector styles", () => {
test("ramps the final connector cells into the note border", () => {
const grid = drawStateDiagramGrid(
parseMermaidStateDiagram(`stateDiagram-v2
direction LR
A --> B
note right of A : note`),
)
const connectorStyles = grid.rows.flatMap((row) =>
row.map((cell) => cell.style).filter((style) => style?.startsWith("noteConnectorRamp")),
)
expect(new Set(connectorStyles)).toEqual(
new Set(["noteConnectorRamp1", "noteConnectorRamp2", "noteConnectorRamp3"]),
)
})
test("resolves the ramp from connector color toward border color", () => {
const colors = resolveStateStyleColors({
noteConnector: RGBA.fromInts(0, 0, 0, 255),
noteBorder: RGBA.fromInts(200, 200, 200, 255),
})
const reds = [
colors.noteConnector.toInts()[0],
colors.noteConnectorRamp1.toInts()[0],
colors.noteConnectorRamp2.toInts()[0],
colors.noteConnectorRamp3.toInts()[0],
colors.noteBorder.toInts()[0],
]
expect(reds).toEqual([...reds].sort((left, right) => left - right))
expect(new Set(reds).size).toBe(5)
})
})
describe("state transition departure styles", () => {
test("ramps once from an ordinary state without restarting at a choice fork", () => {
const grid = drawStateDiagramGrid(
parseMermaidStateDiagram(`stateDiagram-v2
Check --> Decision
state Decision <<choice>>
Decision --> Ready: yes
Decision --> Failed: no`),
)
const rampStyles = grid.rows
.flatMap((row) => row.map((cell) => cell.style))
.filter((style) => style?.startsWith("stateDepartureRamp"))
expect(rampStyles).toHaveLength(3)
expect(new Set(rampStyles)).toEqual(new Set(["stateDepartureRamp1", "stateDepartureRamp2", "stateDepartureRamp3"]))
})
})
+49
View File
@@ -0,0 +1,49 @@
import { RGBA } from "@opentui/core"
import { createColorRampTheme, rgba, type DiagramRgb } from "../core/color/style.js"
import type { BaseStateCellStyle, NoteConnectorRampStyle, StateCellStyle, StateDepartureRampStyle } from "./types.js"
const DEFAULT_THEME_RGB = {
state: [228, 239, 232],
composite: [111, 138, 126],
transition: [134, 225, 200],
label: [134, 225, 200],
noteBorder: [141, 169, 155],
noteText: [215, 229, 221],
noteConnector: [141, 169, 155],
start: [134, 225, 200],
end: [230, 177, 126],
choice: [134, 225, 200],
} as const satisfies Record<BaseStateCellStyle, DiagramRgb>
const NOTE_CONNECTOR_RAMP_STYLES = [
"noteConnectorRamp1",
"noteConnectorRamp2",
"noteConnectorRamp3",
] as const satisfies readonly NoteConnectorRampStyle[]
const STATE_DEPARTURE_RAMP_STYLES = [
"stateDepartureRamp1",
"stateDepartureRamp2",
"stateDepartureRamp3",
] as const satisfies readonly StateDepartureRampStyle[]
export type StateStyleColors = Required<Record<StateCellStyle, RGBA>>
export function resolveStateStyleColors(
colors: Partial<Record<BaseStateCellStyle, RGBA | undefined>> = {},
): StateStyleColors {
const resolved = {
state: colors.state ?? rgba(DEFAULT_THEME_RGB.state),
composite: colors.composite ?? rgba(DEFAULT_THEME_RGB.composite),
transition: colors.transition ?? rgba(DEFAULT_THEME_RGB.transition),
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
noteBorder: colors.noteBorder ?? rgba(DEFAULT_THEME_RGB.noteBorder),
noteText: colors.noteText ?? rgba(DEFAULT_THEME_RGB.noteText),
noteConnector: colors.noteConnector ?? rgba(DEFAULT_THEME_RGB.noteConnector),
start: colors.start ?? rgba(DEFAULT_THEME_RGB.start),
end: colors.end ?? rgba(DEFAULT_THEME_RGB.end),
choice: colors.choice ?? rgba(DEFAULT_THEME_RGB.choice),
}
return {
...resolved,
...createColorRampTheme(NOTE_CONNECTOR_RAMP_STYLES, resolved.noteConnector, resolved.noteBorder),
...createColorRampTheme(STATE_DEPARTURE_RAMP_STYLES, resolved.state, resolved.transition),
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { BorderStyle } from "@opentui/core"
export type StateDiagramDirection = "TB" | "TD" | "LR" | "RL"
export type StateDiagramArrowHeadStyle = "filled" | "line"
export interface StateDiagramState {
id: string
label: string
kind: "state" | "start" | "end" | "choice"
parentId?: string
}
export interface StateDiagramTransition {
from: string
to: string
label: string
}
export interface StateDiagramCompositeState {
id: string
label: string
parentId?: string
}
export interface StateDiagramNote {
target: string
position: "left" | "right"
lines: string[]
}
export interface StateDiagram {
direction: StateDiagramDirection
states: StateDiagramState[]
transitions: StateDiagramTransition[]
composites: StateDiagramCompositeState[]
notes: StateDiagramNote[]
}
export interface StateDiagramRenderOptions {
direction?: StateDiagramDirection
borderStyle?: BorderStyle
arrowHeadStyle?: StateDiagramArrowHeadStyle
minStateGap?: number
}
export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
export type StateDepartureRampStyle = `stateDepartureRamp${1 | 2 | 3}`
export type BaseStateCellStyle =
| "state"
| "composite"
| "transition"
| "label"
| "noteBorder"
| "noteText"
| "noteConnector"
| "start"
| "end"
| "choice"
export type StateCellStyle = BaseStateCellStyle | NoteConnectorRampStyle | StateDepartureRampStyle
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { parseMermaidStateDiagram } from "./parser.js"
import { prepareVisibleStateDiagram } from "./visible-model.js"
describe("prepareVisibleStateDiagram", () => {
test("collapses composite marker transitions", () => {
const parsed = parseMermaidStateDiagram(`stateDiagram-v2
[*] --> Authenticated: login
state Authenticated {
[*] --> Idle
Idle --> Editing: open
Editing --> [*]: save
}
Authenticated --> [*]: logout`)
const visible = prepareVisibleStateDiagram(parsed)
const entry = visible.transitions.find((transition) => transition.from === "__start")
const exit = visible.transitions.find((transition) => transition.to === "__end")
expect(visible.states.some((state) => state.id === "Authenticated.__start")).toBe(false)
expect(visible.states.some((state) => state.id === "Authenticated.__end")).toBe(false)
expect(entry).toMatchObject({ from: "__start", to: "Idle", label: "login" })
expect(exit).toMatchObject({ from: "Editing", to: "__end", label: "save" })
})
})
@@ -0,0 +1,82 @@
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
export type StateVisibleTransition = StateDiagramTransition
export interface StateVisibleDiagram extends Omit<StateDiagram, "transitions"> {
transitions: StateVisibleTransition[]
}
export function isHiddenCompositeMarker(state: StateDiagramState | undefined): boolean {
return Boolean(state?.parentId && (state.kind === "start" || state.kind === "end"))
}
function composeTransitionLabel(incoming: StateDiagramTransition, outgoing: StateDiagramTransition): string {
return incoming.label || outgoing.label
}
function collapseHiddenCompositeMarkerTransitionsOnce(
transitions: readonly StateVisibleTransition[],
statesById: ReadonlyMap<string, StateDiagramState>,
): { transitions: StateVisibleTransition[]; changed: boolean } {
const hiddenMarkers = new Set(
[...statesById.values()].filter((state) => isHiddenCompositeMarker(state)).map((state) => state.id),
)
if (hiddenMarkers.size === 0) return { transitions: [...transitions], changed: false }
const skipped = new Set<StateVisibleTransition>()
const collapsed: StateVisibleTransition[] = []
let changed = false
for (const markerId of hiddenMarkers) {
const incoming = transitions.filter((transition) => transition.to === markerId && transition.from !== markerId)
const outgoing = transitions.filter((transition) => transition.from === markerId && transition.to !== markerId)
if (incoming.length === 0 || outgoing.length === 0) continue
changed = true
for (const incomingTransition of incoming) {
skipped.add(incomingTransition)
for (const outgoingTransition of outgoing) {
skipped.add(outgoingTransition)
collapsed.push({
from: incomingTransition.from,
to: outgoingTransition.to,
label: composeTransitionLabel(incomingTransition, outgoingTransition),
})
}
}
}
return {
transitions: [...transitions.filter((transition) => !skipped.has(transition)), ...collapsed],
changed,
}
}
function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
let transitions: StateVisibleTransition[] = diagram.transitions.map((transition) => ({ ...transition }))
while (true) {
const result = collapseHiddenCompositeMarkerTransitionsOnce(transitions, statesById)
transitions = result.transitions
if (!result.changed) return transitions
}
}
export function prepareVisibleStateDiagram(diagram: StateDiagram): StateVisibleDiagram {
const transitions = collapseHiddenCompositeMarkerTransitions(diagram)
const referencedHiddenMarkers = new Set<string>()
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
for (const transition of transitions) {
const from = statesById.get(transition.from)
const to = statesById.get(transition.to)
if (from && isHiddenCompositeMarker(from)) referencedHiddenMarkers.add(from.id)
if (to && isHiddenCompositeMarker(to)) referencedHiddenMarkers.add(to.id)
}
return {
...diagram,
states: diagram.states.filter((state) => !isHiddenCompositeMarker(state) || referencedHiddenMarkers.has(state.id)),
transitions,
}
}
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { MermaidSyntaxError } from "../diagnostics.js"
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
import { parseMermaidStateDiagram } from "../state/parser.js"
import { renderSequenceDiagram } from "../sequence/diagram.js"
describe("parser diagnostics", () => {
test("ignores flowchart presentation directives that do not change terminal structure", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A[Start] --> B[Done]
classDef highlight fill:#fff
class A highlight
style B fill:#000
linkStyle 0 stroke:#fff`)
expect(diagram.nodes.map((node) => node.id)).toEqual(["A", "B"])
expect(diagram.edges).toHaveLength(1)
})
test("reports unsupported structural flowchart statements with source location", () => {
expect(() =>
parseMermaidFlowchartDiagram(`flowchart LR
A[Start] --> B[Done]
A --o B`),
).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --o B"')
})
test("exposes structured syntax errors through top-level rendering", () => {
try {
renderSequenceDiagram(`sequenceDiagram
A->>B: request
opt retry`)
throw new Error("expected render to reject unsupported syntax")
} catch (error) {
expect(error).toBeInstanceOf(MermaidSyntaxError)
if (!(error instanceof MermaidSyntaxError)) return
expect(error.kind).toBe("sequence")
expect(error.lineNumber).toBe(3)
expect(error.sourceLine).toBe("opt retry")
}
})
test("reports unclosed state constructs at their opening line", () => {
expect(() =>
parseMermaidStateDiagram(`stateDiagram-v2
state Running {
[*] --> Ready`),
).toThrow('Unclosed composite state; expected "}" in state diagram at line 2: "state Running {"')
})
test("reports unsupported state statements", () => {
expect(() => parseMermaidStateDiagram(`stateDiagram-v2\n hide empty description`)).toThrow(
'Unsupported syntax in state diagram at line 2: "hide empty description"',
)
})
test("reports malformed sequence block endings", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
end`),
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
})
test("does not attach else through an unclosed nested sequence block", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
alt available
loop retry
else fallback`),
).toThrow('Unexpected "else" without an open "alt" block in sequence diagram at line 4: "else fallback"')
})
})
+32
View File
@@ -0,0 +1,32 @@
import { expect } from "bun:test"
function normalizeDiagram(value: string): string {
const lines = value.replace(/\r\n/g, "\n").split("\n")
while (lines[0]?.trim() === "") lines.shift()
while (lines[lines.length - 1]?.trim() === "") lines.pop()
const indentation = lines
.filter((line) => line.trim().length > 0)
.reduce((min, line) => Math.min(min, line.match(/^\s*/)?.[0].length ?? 0), Number.POSITIVE_INFINITY)
const trimBy = Number.isFinite(indentation) ? indentation : 0
return lines.map((line) => line.slice(trimBy)).join("\n")
}
export function expectDiagram(value: string): {
toEqualDiagram(expected: string): void
toContainInOrder(...needles: string[]): void
} {
return {
toEqualDiagram(expected) {
expect(value).toBe(normalizeDiagram(expected))
},
toContainInOrder(...needles) {
let offset = -1
for (const needle of needles) {
const searchSpace = value.slice(offset + 1)
expect(searchSpace).toContain(needle)
const nextOffset = value.indexOf(needle, offset + 1)
offset = nextOffset
}
},
}
}
+274
View File
@@ -0,0 +1,274 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { createMermaidMarkdownRenderer } from "../markdown.js"
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
})
let treeSitterClient: TreeSitterClient
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
beforeAll(async () => {
const dataPath = join(tmpdir(), "merman-markdown-test-data")
await mkdir(dataPath, { recursive: true })
treeSitterClient = new TreeSitterClient({ dataPath })
await treeSitterClient.initialize()
})
afterAll(async () => {
await treeSitterClient.destroy()
})
afterEach(() => {
renderer?.destroy()
renderer = undefined
})
async function renderMarkdown(
markdown: MarkdownRenderable,
renderOnce: () => Promise<void>,
timeoutMs = 2_000,
): Promise<void> {
const hasPendingHighlights = (): boolean => {
const children = [...markdown.getChildren()]
while (children.length > 0) {
const child = children.pop()!
if (child instanceof CodeRenderable && child.isHighlighting) return true
children.push(...child.getChildren())
}
return false
}
const startedAt = Date.now()
await renderOnce()
while (hasPendingHighlights() && Date.now() - startedAt < timeoutMs) {
await Bun.sleep(10)
await renderOnce()
}
if (hasPendingHighlights()) throw new Error("Timed out waiting for Markdown highlights")
await renderOnce()
}
test("renders a Mermaid flowchart fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-mermaid",
content: `\`\`\`mermaid
flowchart LR
A[Start] --> B[Done]
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Start")
expect(frame).toContain("Done")
expect(frame).not.toContain("flowchart LR")
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
})
test("recognizes normalized Mermaid fence info strings", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-normalized-mermaid",
content: `\`\`\`MERMAID title=checkout
flowchart LR
A[Start] --> B[Done]
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Start")
expect(frame).not.toContain("flowchart LR")
})
test("keeps surrounding Markdown content visible around a Mermaid diagram", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-with-mermaid",
content: `Before
\`\`\`mermaid
flowchart LR
A[Start] --> B[Done]
\`\`\`
After`,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Before")
expect(frame).toContain("Start")
expect(frame).toContain("Done")
expect(frame).toContain("After")
})
test("renders an incomplete Mermaid fence as ordinary code", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 10 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-incomplete-mermaid",
content: `\`\`\`mermaid
flowchart LR
A -->
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
expect(captureCharFrame()).toContain("flowchart LR")
})
test("keeps the last valid Mermaid diagram while a fence is streaming", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 12 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-streaming-mermaid",
content: `\`\`\`mermaid
flowchart LR
A[Stable] --> B[Previous]
\`\`\``,
syntaxStyle,
streaming: true,
internalBlockMode: "top-level",
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
expect(testRenderer.captureCharFrame()).toContain("Previous")
markdown.content = `\`\`\`mermaid
flowchart LR
A[Stable] --> B[Previous]
B -->
\`\`\``
await renderMarkdown(markdown, testRenderer.renderOnce)
expect(testRenderer.captureCharFrame()).toContain("Previous")
expect(testRenderer.captureCharFrame()).not.toContain("flowchart LR")
markdown.content = `\`\`\`mermaid
flowchart LR
A[Stable] --> B[Previous]
B --> C[Current]
\`\`\``
await renderMarkdown(markdown, testRenderer.renderOnce)
expect(testRenderer.captureCharFrame()).toContain("Current")
})
test("renders a Mermaid sequence fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-sequence",
content: `\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Alice")
expect(frame).toContain("Bob")
expect(frame).not.toContain("sequenceDiagram")
})
test("wraps wide Mermaid diagrams in a horizontal viewport", async () => {
const testRenderer = await createTestRenderer({ width: 40, height: 14 })
renderer = testRenderer.renderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-wide-sequence",
content: `\`\`\`mermaid
sequenceDiagram
participant A as Alpha participant
participant B as Beta participant
participant C as Gamma participant
A->>B: first
B->>C: second
\`\`\``,
syntaxStyle,
renderNode: createMermaidMarkdownRenderer(renderer, {
compact: true,
colors: { primary: "#ff0000" },
}),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const diagram = markdown.getChildren()[0] as CodeRenderable
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
expect(diagram.scrollX).toBe(0)
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
await testRenderer.renderOnce()
expect(diagram.scrollX).toBeGreaterThan(0)
expect(diagram.hasSelection()).toBe(false)
diagram.scrollX = 0
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
await testRenderer.renderOnce()
expect(diagram.scrollX).toBeGreaterThan(0)
})
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-state",
content: `\`\`\`mermaid
stateDiagram-v2
[*] --> Idle
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})
renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)
const frame = captureCharFrame()
expect(frame).toContain("Idle")
expect(frame).not.toContain("stateDiagram-v2")
})
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+4 -1
View File
@@ -22,7 +22,7 @@ import type {
VcsInfo,
} from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
import type { CliRenderer, KeyEvent, MarkdownCodeBlockRenderer, Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
@@ -406,6 +406,9 @@ export interface Context {
readonly data: Data
readonly attention: Attention
readonly theme: ResolvedTheme
readonly markdown: {
registerCodeBlockRenderer(language: string, render: MarkdownCodeBlockRenderer): () => void
}
readonly keymap: Keymap
readonly storage: Storage
readonly ui: UI
+2 -1
View File
@@ -20,7 +20,8 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
"effect": "catalog:",
"modal": "0.9.0"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+204
View File
@@ -0,0 +1,204 @@
import { Effect, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "@opencode-ai/core/environment"
import type { ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
const INNER_WRAPPER = `
pidfile=$1
shift
printf "%s" "$$" > "$pidfile"
trap 'rm -f -- "$pidfile"' EXIT
"$@"
`
// Modal's VM runtime accepts process-group signals without delivering them
// (kill(-pgid) returns 0 and nothing dies; direct-pid signals work), so the
// group is enumerated from /proc and each member is signalled directly. The
// second pass catches children forked between scan and signal.
const KILL = `
pidfile=$1
sig=$2
i=0
while [ ! -s "$pidfile" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done
[ -s "$pidfile" ] || exit 47
target=$(cat "$pidfile")
pass=0
while [ "$pass" -lt 2 ]; do
for stat in /proc/[0-9]*/stat; do
[ -e "$stat" ] || continue
pid=\${stat#/proc/}
pid=\${pid%/stat}
set -- $(sed "s/.*) //" "$stat" 2>/dev/null)
if [ "\${3:-}" = "$target" ]; then
/bin/kill "-$sig" "$pid" 2>/dev/null || true
fi
done
pass=$((pass + 1))
done
`
export interface ModalImageSpec {
readonly registry: string
readonly dockerfileCommands: ReadonlyArray<string>
}
export interface ModalSandboxOptions {
readonly app: string
readonly client?: ModalClientParams
readonly image?: ModalImageSpec
readonly sandbox?: SandboxCreateParams
}
/**
* Ubuntu supplies the GNU coreutils and findutils required by the derived Files
* scripts. Busybox images do not satisfy the Environment contract.
*/
export const ubuntuImage: ModalImageSpec = {
registry: "ubuntu:24.04",
dockerfileCommands: [
"RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux",
],
}
/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */
export const createModalSandbox = async (options: ModalSandboxOptions) => {
const { ModalClient } = await import("modal")
const client = new ModalClient(options.client)
const app = await client.apps.fromName(options.app, { createIfMissing: true })
const imageSpec = options.image ?? ubuntuImage
const image = client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands])
// Always Modal's Full-VM runtime (beta, enabled per account): a real kernel
// with real device nodes, so workspaces can run Docker and other
// kernel-dependent workloads. Costs versus gVisor, measured Aug 2026:
// per-exec floor ~285-535ms versus ~90-165ms, and filesystem snapshots only
// (no memory snapshots — acceptable; fs-snapshot is the persistence design).
const sandbox = await client.sandboxes.create(app, image, {
...options.sandbox,
experimentalOptions: { ...options.sandbox?.experimentalOptions, vm_runtime: true },
})
return {
driver: makeModalDriver(sandbox),
sandbox,
terminate: () => sandbox.terminate(),
}
}
/**
* Adapts Modal exec to the Environment driver. Files intentionally has no native
* overrides: exec latency dominates payload work (VM runtime floor measured
* ~285-535ms per exec, Aug 2026), so the derived exec defaults are the simplest
* implementation with no measured loss.
*
* Modal cannot signal a ContainerProcess. Each command therefore starts a new
* process group and records its leader in a unique pid file; kill runs a second
* sandbox command that enumerates that group from /proc and signals each member
* directly (see KILL). Pid files are removed best-effort.
*/
export const makeModalDriver = (sandbox: Sandbox): Driver => {
const spawn = Effect.fnUntraced(function* (command: Command) {
if (command._tag === "PipedCommand") {
return yield* Effect.fail(spawnError("spawn", "piped commands unsupported"))
}
if (command.options.additionalFds) {
return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported"))
}
const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid`
const env = compact(command.options.env)
const isolatedEnv =
command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined)
const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : []
const process = yield* Effect.tryPromise({
try: () =>
sandbox.exec(
["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args],
{
mode: "binary",
stdout: "pipe",
stderr: "pipe",
workdir: command.options.cwd,
env: isolatedEnv ? undefined : env,
},
),
catch: (cause) => spawnError("spawn", undefined, cause),
})
const onError = (cause: unknown) => spawnError("process", undefined, cause)
let exited = false
const writer = process.stdin.getWriter()
let closingStdin: Promise<void> | undefined
const waited = process.wait().then((code) => {
exited = true
if (!closingStdin) writer.releaseLock()
return code
})
const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode))
const kill = (options?: KillOptions) => {
if (exited) return Effect.void
return Effect.tryPromise({
try: async () => {
const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], {
stdout: "pipe",
stderr: "pipe",
})
const code = await killer.wait()
if (code !== 0) throw new Error(`modal kill exited ${code}`)
},
catch: onError,
}).pipe(Effect.andThen(exitCode), Effect.asVoid)
}
yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore))
const closeStdin = Effect.tryPromise({
try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())),
catch: onError,
})
const writeStdin = Sink.forEach((chunk: Uint8Array) =>
Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }),
)
const inputConfig = command.options.stdin
const inputOptions =
inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig)
? inputConfig
: undefined
const input = inputOptions?.stream ?? inputConfig
const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin))
if (input === "ignore") {
yield* closeStdin
}
if (Stream.isStream(input)) {
yield* Effect.forkScoped(Stream.run(input, stdin))
}
const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError })
const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError })
return makeHandle({
pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]),
exitCode,
isRunning: Effect.sync(() => !exited),
kill,
stdin,
stdout,
stderr,
all: Stream.merge(stdout, stderr),
getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")),
getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")),
unref: Effect.succeed(Effect.void),
})
})
return { spawner: make(spawn) }
}
const compact = (env: Record<string, string | undefined> | undefined) => {
if (!env) return undefined
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
}
const spawnError = (method: string, description?: string, cause?: unknown) =>
systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause })
export * as ModalDriver from "./modal"
@@ -0,0 +1,67 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { afterAll, expect, test } from "bun:test"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Failed, makeFiles } from "@opencode-ai/core/environment"
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
import { createModalSandbox } from "../src/workspace/modal"
const enabled =
!!process.env.OPENCODE_TEST_MODAL &&
((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) ||
fs.existsSync(path.join(os.homedir(), ".modal.toml")))
const modalTest = enabled ? test : test.skip
const root = `/tmp/opencode-environment-${crypto.randomUUID()}`
const sandbox = enabled
? createModalSandbox({
app: "opencode-environment-tests",
sandbox: { timeoutMs: 10 * 60 * 1000 },
})
: undefined
modalTest(
"kills a running modal process",
async () => {
const value = await sandbox!
const started = performance.now()
const exitCode = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"]))
yield* handle.kill()
return yield* handle.exitCode
}),
).pipe(Effect.timeout("10 seconds")),
)
expect(exitCode).not.toBe(0)
expect(performance.now() - started).toBeLessThan(10_000)
},
15_000,
)
environmentConformance(
"modal environment",
() =>
Effect.promise(async () => {
const value = await sandbox!
return {
files: makeFiles(value.driver),
root,
symlink: (target, link) =>
Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe(
Effect.flatMap((code) =>
code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })),
),
Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))),
),
}
}),
!enabled,
)
afterAll(async () => {
if (!sandbox) return
await (await sandbox).terminate()
})
+1
View File
@@ -82,6 +82,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
-9
View File
@@ -1154,15 +1154,6 @@ function App(props: { pair?: DialogPairCredentials }) {
}
})
event.on("session.execution.failed", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
toast.show({
variant: "error",
message: evt.data.error.message,
duration: 5000,
})
})
// Suppress the full-screen overlay for transient startup and event-stream retry states.
// Initial connection gets a longer grace period; retries surface more quickly.
const [showReconnecting, setShowReconnecting] = createSignal(false)
+14 -1
View File
@@ -51,6 +51,17 @@ export function DialogOpen() {
.catch(() => [] as SessionInfo[]),
{ initialValue: [] },
)
const [matched] = createResource(
() => {
const value = filter().trim()
return /^ses_[0-9A-Za-z]{26}$/.test(value) ? value : undefined
},
(sessionID) =>
client.api.session
.get({ sessionID })
.then((session) => (session.id === sessionID ? session : undefined))
.catch(() => undefined),
)
const openTabs = createMemo(
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
@@ -60,7 +71,8 @@ export function DialogOpen() {
)
const sessions = createMemo(() => {
const seen = new Set<string>()
return [...data.session.list(), ...fetched()]
const match = matched()
return [...data.session.list(), ...fetched(), ...(match ? [match] : [])]
.filter((session) => {
if (session.parentID || seen.has(session.id)) return false
seen.add(session.id)
@@ -87,6 +99,7 @@ export function DialogOpen() {
data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
searchText: session.id,
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
@@ -141,7 +141,12 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const project = data.project.get(session.projectID)
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
const relative = path.relative(project?.canonical ?? directory, directory)
const footer = allProjects()
? Locale.truncate(projectName(project, directory) ?? "", 20)
: relative.startsWith("..") || path.isAbsolute(relative)
? Locale.truncate(path.basename(directory), 20)
: undefined
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
+1 -1
View File
@@ -407,7 +407,7 @@ export function RunCommandMenuBody(props: {
name: "compact",
display: "Compact session",
footer: "/compact",
keywords: "compact summarize session context",
keywords: "compact session context",
},
{
action: "slash",
+1 -1
View File
@@ -406,7 +406,7 @@ export function createPromptState(input: PromptInput): PromptState {
kind: "slash",
name: "compact",
display: "/compact",
description: "summarize the session to reduce context usage",
description: "compact older session context to free space",
} satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
+1 -2
View File
@@ -54,8 +54,7 @@ export function isNewCommand(input: string): boolean {
}
export function isCompactCommand(input: string): boolean {
const text = input.trim().toLowerCase()
return text === "/compact" || text === "/summarize"
return input.trim().toLowerCase() === "/compact"
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
+16 -3
View File
@@ -1,6 +1,7 @@
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { useClient } from "../context/client"
import { useData } from "../context/data"
@@ -26,10 +27,11 @@ export type Dispose = () => Promise<void>
// route/slot registration lands there, but ordering and lifecycle stay owned
// by the provider.
export type Registry = {
has(kind: "routes" | "slots", name: string): boolean
has(kind: "routes" | "slots" | "markdown", name: string): boolean
set(kind: "routes", name: string, page: Page): void
set(kind: "slots", name: string, slot: Slot): void
remove(kind: "routes" | "slots", name: string): void
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
remove(kind: "routes" | "slots" | "markdown", name: string): void
active(): boolean
}
@@ -81,7 +83,7 @@ export function createPluginContext(input: {
}
// Unregistering after deactivation is a no-op: deactivate already resets
// the registration's routes and slots wholesale.
const registration = (kind: "routes" | "slots", name: string) => {
const registration = (kind: "routes" | "slots" | "markdown", name: string) => {
let registered = true
const unregister = () => {
if (!registered) return
@@ -105,6 +107,17 @@ export function createPluginContext(input: {
get theme() {
return host.themes.currentTokens()
},
markdown: {
registerCodeBlockRenderer(language, render) {
const name = infoStringToFiletype(language)
if (!name) throw new Error("Markdown code-block language is required")
if (input.registry.has("markdown", name)) {
throw new Error(`Markdown code-block renderer already registered: ${name}`)
}
input.registry.set("markdown", name, render)
return registration("markdown", name)
},
},
keymap: {
layer: Keymap.createLayer,
dispatch: host.keymap.dispatch,
+2
View File
@@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Storybook from "../feature-plugins/system/storybook"
import Merman from "@opencode-ai/merman/plugin"
export const builtins = [
HomeFooter,
@@ -16,6 +17,7 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
Merman,
// The storybook is a development tool; keep its route and palette commands out of
// normal launches and register it only for OPENCODE_STORY runs.
...(process.env.OPENCODE_STORY ? [Storybook] : []),
+53 -7
View File
@@ -1,5 +1,16 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
batch,
createContext,
createEffect,
createMemo,
on,
onCleanup,
onMount,
useContext,
type ParentProps,
} from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
@@ -35,7 +46,10 @@ type Value = {
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly slot: <Name extends SlotName>(
name: Name,
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
@@ -49,6 +63,7 @@ type Registration = {
active: boolean
routes: Record<string, Page>
slots: Record<string, Slot>
markdown: Record<string, MarkdownCodeBlockRenderer>
cleanups: Dispose[]
}
@@ -57,6 +72,17 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
): MarkdownOptions["renderNode"] {
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
for (const source of sources) {
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
}
if (renderers.size === 0) return undefined
return createMarkdownCodeBlockRenderer(renderers)
}
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
@@ -67,6 +93,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
states: [] as ReadonlyArray<State>,
registrations: {} as Record<string, Registration>,
})
const markdown = createMemo(() =>
combineMarkdownRenderers(
Object.values(store.registrations).flatMap((registration) =>
registration.active ? [registration.markdown] : [],
),
),
)
const activate = async (id: string) => {
const item = store.registrations[id]
@@ -75,6 +108,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
batch(() => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
@@ -85,7 +119,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
owned,
registry: {
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
set: (kind: "routes" | "slots" | "markdown", name: string, value: Page | Slot | MarkdownCodeBlockRenderer) =>
setStore("registrations", id, kind, name, () => value),
remove: (kind, name) =>
setStore(
@@ -101,6 +135,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
throw error
})
if (cleanup) owned.push(async () => cleanup())
@@ -129,6 +164,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (store.registrations[id]) {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "markdown", reconcileStore({}))
}
setStore("states", (items) =>
items.map((state) =>
@@ -193,7 +229,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
const desired = new Map<string, Desired>()
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
for (const plugin of builtins)
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
const failures: State[] = []
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
@@ -256,7 +293,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// generation is a no-op, so spurious watch events cost nothing.
const currentIds = Object.keys(store.registrations)
const desiredIds = [...desired.keys()]
const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
const structural =
currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
if (structural) {
await Promise.all(
Object.entries(store.registrations)
@@ -342,7 +380,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
for (const state of states)
if (
state.status === "failed" &&
!store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
!store.states.some(
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
setStore("states", reconcileStore(states))
@@ -390,7 +430,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
ready: () => store.ready,
list: () => store.states,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
Object.entries(store.registrations).map(([id, plugin]) => ({
id,
source: plugin.source,
active: plugin.active,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.entries(store.registrations).flatMap(([id, registration]) => {
@@ -405,6 +449,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
slotItems.set(render, item)
return [item]
}),
markdown,
// Manual dialog toggles join the same chain as reconciles so a
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
@@ -468,6 +513,7 @@ function toRegistration(item: Desired): Registration {
active: false,
routes: {},
slots: {},
markdown: {},
cleanups: [],
}
}
+12 -16
View File
@@ -83,6 +83,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import {
cacheReuseDrop,
createSessionRows,
@@ -635,7 +636,6 @@ export function Session() {
group: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
@@ -1631,21 +1631,13 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
<Show when={props.message.error && !interrupted()}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.text.feedback.error.default}
>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
<Show when={props.message.error && !interrupted() && !props.message.retry}>
<box paddingLeft={3}>
<text fg={theme.text.feedback.error.default}>Error: {errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
@@ -1739,6 +1731,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
const status = () => props.message.status
const cancelled = () => props.message.status === "failed" && props.message.error.type === "aborted"
const text = () =>
@@ -1779,6 +1772,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
@@ -2043,9 +2037,9 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.text.subdued}>
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
<box paddingLeft={3}>
<text fg={theme.text.feedback.warning.default}>
Retry attempt {retry().attempt} scheduled: {retry().error.message}
</text>
</box>
)}
@@ -2186,6 +2180,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
@@ -2198,6 +2193,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
renderNode={plugins.markdown()}
/>
</box>
</Show>
@@ -54,6 +54,40 @@ test("selecting an unhydrated session preserves its location", async () => {
}
})
test("finds and opens an exact session ID outside the recent list", async () => {
const sessionID = "ses_04a7a3d82ffeIphUJgd3SnEqiv"
const remote = { directory: "/tmp/opencode/archive", workspaceID: "ws_archive" }
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== `/api/session/${sessionID}`) return undefined
return json({
data: {
id: sessionID,
projectID: "proj_archive",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "TUI plugin slot API v2",
location: remote,
},
})
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
await fixture.app.mockInput.typeText(sessionID)
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
}
})
test("shows the current project and opens its root", async () => {
const root = "/tmp/opencode/project"
const subfolder = `${root}/packages/tui`
+1
View File
@@ -138,6 +138,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])
if (url.pathname === "/vcs") return json({ branch: "main" })
if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })
throw new Error(`unexpected request: ${url.pathname}`)
}
fetch.preconnect = () => {}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isCompactCommand,
isExitCommand,
isNewCommand,
movePromptHistory,
@@ -98,4 +99,10 @@ describe("run prompt shared", () => {
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
test("recognizes only the compact command", () => {
expect(isCompactCommand("/compact")).toBe(true)
expect(isCompactCommand(" /COMPACT ")).toBe(true)
expect(isCompactCommand("/summarize")).toBe(false)
})
})
+2 -2
View File
@@ -80,7 +80,7 @@ describe("run runtime queue", () => {
])
})
test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => {
test("treats /compact as a local compaction command", async () => {
const ui = createFooterApiFixture()
const seen: string[] = []
let compacted = 0
@@ -96,7 +96,7 @@ describe("run runtime queue", () => {
},
})
ui.submit(command)
ui.submit("/compact")
ui.submit("hello")
await task
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test"
import { SyntaxStyle, TextRenderable, type MarkdownOptions, type RenderNodeContext } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { combineMarkdownRenderers } from "../src/plugin/context"
const code = (language: string) => ({ type: "code" as const, lang: language, text: "content", raw: "" })
const context: RenderNodeContext = {
syntaxStyle: SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }),
conceal: false,
concealCode: false,
defaultRender: () => null,
}
test("dispatches Markdown code blocks by normalized language", async () => {
const { renderer } = await createTestRenderer({ width: 20, height: 4 })
const expected = new TextRenderable(renderer, { content: "expected" })
const render = (() => expected) satisfies NonNullable<MarkdownOptions["renderNode"]>
const combined = combineMarkdownRenderers([{ mermaid: render }])!
expect(combined(code("mermaid title=example"), context)).toBe(expected)
expect(combined(code("typescript"), context)).toBeUndefined()
renderer.destroy()
})
test("later Markdown renderer registrations take precedence", async () => {
const { renderer } = await createTestRenderer({ width: 20, height: 4 })
const first = new TextRenderable(renderer, { content: "first" })
const second = new TextRenderable(renderer, { content: "second" })
const combined = combineMarkdownRenderers([{ mermaid: () => first }, { mermaid: () => second }])!
expect(combined(code("mermaid"), context)).toBe(second)
expect(combineMarkdownRenderers([])).toBeUndefined()
renderer.destroy()
})