Compare commits

..

18 Commits

Author SHA1 Message Date
Kit Langton b49bad9a86 fix(merman): remove sequence lifeline fade (#41623) 2026-08-10 19:48:28 -04:00
Kit Langton 2580f880a8 feat(merman): refine sequence diagram styling (#41617) 2026-08-10 19:12:46 -04:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
opencode-agent[bot] 33296e7959 test(app): make offset observer scheduling deterministic (#41602)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 17:24:05 -05:00
Simon Klee 283258e95b feat(tui): add clipboard image previews and transcript rendering (#41603)
Use the OpenTUI clipboard service for image input, show image previews in
the prompt, and render transcript images with interactive previews.

Note this includes upgrade of opentui to 0.5.1 +
anomalyco/opentui#1271
2026-08-10 22:51:39 +02:00
opencode-agent[bot] d7a7256bb6 test: stabilize Windows CI timing (#41600)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-10 15:25:55 -05:00
Aiden Cline eec55fa5f8 fix(tui): keep model variant across new session (#41591) 2026-08-10 15:13:23 -05:00
Aiden Cline 7ff27ca8cf fix(core): resolve provider URL variables at runtime (#41580) 2026-08-10 15:03:47 -05:00
Aiden Cline b62dc4a636 fix(tui): show empty state when a flat picker has no matches (#41585) 2026-08-10 14:26:29 -05:00
opencode-agent[bot] ceec9f5b66 fix(tui): measure complete turn duration (#41508)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-10 20:27:22 +02:00
Kit Langton bbb1b5e7d0 fix(tui): delay background work hint (#41577) 2026-08-10 13:47:31 -04:00
Kit Langton f0b8ad1242 feat(tui): marquee hovered tab titles (#41566) 2026-08-10 13:47:21 -04:00
opencode-agent[bot] be53e3bd81 chore: generate 2026-08-10 17:00:23 +00:00
Dax Raad c22942c1f3 fix(core): tolerate unavailable wellknown config 2026-08-10 12:57:41 -04:00
opencode-agent[bot] 6895728add fix(tui): support single-color themes (#41572) 2026-08-10 12:54:37 -04:00
Aiden Cline 16aad9e6ad fix(core): derive fallback message for empty AI SDK provider errors (#41450) 2026-08-10 11:19:44 -05:00
opencode-agent[bot] 9ab3029947 chore: generate 2026-08-10 15:25:02 +00:00
Kit Langton 3c9f857bf1 fix(tui): sync Mermaid renderer fixes (#41347) 2026-08-10 11:23:10 -04:00
95 changed files with 3933 additions and 814 deletions
+32 -56
View File
@@ -577,6 +577,7 @@
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"entities": "7.0.1",
"string-width": "catalog:",
},
"devDependencies": {
@@ -612,9 +613,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -902,7 +903,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
@@ -1088,9 +1088,9 @@
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
@@ -1108,9 +1108,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2168,27 +2168,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -3538,7 +3538,7 @@
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3622,8 +3622,6 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -4040,7 +4038,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4322,7 +4320,7 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4466,8 +4464,6 @@
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
@@ -4806,7 +4802,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4932,7 +4928,7 @@
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4958,7 +4954,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5560,7 +5556,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5592,8 +5588,6 @@
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
@@ -6532,8 +6526,6 @@
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -6718,9 +6710,11 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6796,8 +6790,6 @@
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -7564,20 +7556,6 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -8638,8 +8616,6 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+6 -6
View File
@@ -46,9 +46,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -150,9 +150,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
+41 -5
View File
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
Array.from(secretValues(request)).reduce(
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
if (body === undefined) return {}
const redacted = redactBody(body, request)
const redacted = redactBody(body, secrets)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
}
@@ -240,7 +240,7 @@ const statusError =
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
const details = responseBody(body, secretValues(request))
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
@@ -261,6 +261,42 @@ const statusError =
})
})
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url: string
readonly status?: number | undefined
readonly code?: string | undefined
readonly responseHeaders?: Record<string, string> | undefined
readonly responseBody?: string | undefined
}) => {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
return classifyProviderFailure({
message: input.message,
status: input.status,
code: input.code,
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
response:
input.status === undefined
? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
}
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { Window } from "happy-dom"
import { Node, Window } from "happy-dom"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const mutations = controlledMutations(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
const unrelated = targetWindow.document.createElement("div")
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
targetWindow.document.body.append(unrelated)
unrelated.remove()
await frames(2, targetWindow)
expect(calls).toEqual([])
try {
mutations.append(targetWindow.document.body, unrelated)
mutations.remove(unrelated)
expect(calls).toEqual([])
route.remove()
targetWindow.document.body.append(route)
await waitFor(() => calls.length === 1, targetWindow)
expect(calls).toEqual([[0, false]])
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
route.remove()
targetWindow.document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3, targetWindow)
expect(calls).toEqual([[0, false]])
cleanup?.()
await targetWindow.happyDOM.close()
mutations.remove(route)
mutations.append(targetWindow.document.body, route)
await frames(2, targetWindow)
expect(calls).toEqual([[0, false]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
}
}
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
const deadline = targetWindow.performance.now() + 1_000
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
function controlledMutations(targetWindow: Window) {
let emit: (record: MutationRecord) => void = () => {
throw new Error("Mutation observer is not active")
}
class ControlledMutationObserver {
constructor(callback: MutationCallback) {
emit = (record) => callback([record], this as unknown as MutationObserver)
}
observe() {}
disconnect() {}
takeRecords() {
return []
}
}
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
return {
append(parent: Node, node: Node) {
parent.appendChild(node)
emit(record(parent, [node], []))
},
remove(node: Node) {
const parent = node.parentNode
if (!parent) throw new Error("Mutation target has no parent")
parent.removeChild(node)
emit(record(parent, [], [node]))
},
}
}
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
expect(await fixture.spawn().close()).toBe(0)
const acp = fixture.spawn()
await initialize(acp)
expect(await acp.close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
+59 -2
View File
@@ -1,6 +1,7 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { APICallError } from "@ai-sdk/provider"
import type {
JSONSchema7,
JSONValue,
@@ -22,6 +23,7 @@ import {
LanguageModel,
ProviderID,
ProviderMetadata,
TransportReason,
ToolResultValue,
UnknownProviderReason,
type ContentPart,
@@ -29,7 +31,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
@@ -723,7 +725,9 @@ function llmError(method: string, error: unknown) {
const reason =
error instanceof AIError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
: APICallError.isInstance(error)
? apiCallErrorReason(error)
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
return new AIError({
module: "AISDK",
method,
@@ -731,4 +735,57 @@ function llmError(method: string, error: unknown) {
})
}
function apiCallErrorReason(error: APICallError) {
const details = providerErrorDetails(error)
const reason = RequestExecutor.classifyHttpFailure({
message: details.message,
url: error.url,
status: error.statusCode,
code: details.code,
responseHeaders: error.responseHeaders,
responseBody: error.responseBody,
})
if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({
message: reason.message,
kind: error.name,
url: error.url,
http: "http" in reason ? reason.http : undefined,
})
}
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
const ProviderErrorDetail = Schema.Struct({
message: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(ProviderErrorCode),
})
const ProviderErrorBody = Schema.Struct({
...ProviderErrorDetail.fields,
error: Schema.optionalKey(ProviderErrorDetail),
})
const decodeProviderError = Schema.decodeUnknownOption(
Schema.Union([ProviderErrorBody, Schema.fromJsonString(ProviderErrorBody)]),
)
function unknownErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return message.trim() === "" ? "Provider request failed" : message
}
function providerErrorDetails(error: APICallError) {
const data = Option.getOrUndefined(decodeProviderError(error.data))
const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
const details = [data?.error, data, body?.error, body]
const message = details.map((detail) => detail?.message).find((value) => value?.trim())
const value = details.map((detail) => detail?.code).find((value) => value !== undefined)
const code = value === undefined ? undefined : String(value)
const prefix =
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
return {
code,
message:
error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
}
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
+9 -1
View File
@@ -151,7 +151,15 @@ export const layer = (options?: Options) =>
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -41,9 +42,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
+4
View File
@@ -47,6 +47,10 @@ export const layer = Layer.effect(
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnresolvedProviderVariablesError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
}),
)
if (!resolved)
+49 -7
View File
@@ -46,7 +46,24 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass<Unsupported
}
}
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
export class UnresolvedProviderVariablesError extends Schema.TaggedErrorClass<UnresolvedProviderVariablesError>()(
"SessionRunnerModel.UnresolvedProviderVariablesError",
{
providerID: Provider.ID,
modelID: ID,
variables: Schema.Array(Schema.String),
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.variables.join(", ")} ${this.variables.length === 1 ? "is" : "are"} required to resolve the provider endpoint`
}
}
export type Error =
| VariantUnavailableError
| UnsupportedPackageError
| UnresolvedProviderVariablesError
| Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
@@ -141,12 +158,17 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
})
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
const prepared = prepareRuntimeModel(model, credential)
if (prepared.unresolved.length > 0)
return Effect.fail(
new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: prepared.unresolved,
}),
)
const resolved = prepared.model
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
@@ -226,6 +248,26 @@ export const fromCatalogModel = (
})
}
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
const prepared = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
return process.env[name] ?? placeholder
})
})
const baseURL = prepared.settings?.baseURL
const unresolved =
typeof baseURL === "string"
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
(name, index, names) => names.indexOf(name) === index,
)
: []
return { model: prepared, unresolved }
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
+3 -31
View File
@@ -59,36 +59,8 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return (
structuredClone(data)
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
.filter(
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
.map((provider) => {
const environment = new Set(provider.environment)
return {
...provider,
info: {
...provider.info,
...(provider.info.settings ? { settings: resolveEnvironment(provider.info.settings, environment) } : {}),
},
models: provider.models.map((model) => ({
...model,
...(model.settings ? { settings: resolveEnvironment(model.settings, environment) } : {}),
})),
}
})
return structuredClone(data).filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
function resolveEnvironment(settings: Readonly<Record<string, unknown>>, environment: Set<string>) {
if (typeof settings.baseURL !== "string") return settings
return {
...settings,
baseURL: settings.baseURL.replace(/\$\{([^}]+)\}/g, (value, name: string) => {
if (!environment.has(name)) return value
return process.env[name] ?? value
}),
}
}
@@ -34,6 +34,8 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
@@ -52,7 +52,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
+171 -1
View File
@@ -1,8 +1,11 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { expect } from "bun:test"
@@ -337,3 +340,170 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
}),
)
const failingModel = (failure: unknown): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: () => Promise.reject(failure),
})
const streamFailure = (failure: unknown) =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => failingModel(failure) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
Effect.provide(client),
Effect.flip,
)
})
it.effect("preserves non-empty AI SDK error messages", () =>
Effect.gen(function* () {
const error = yield* streamFailure(new Error("Bad Request"))
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
}),
)
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
new APICallError({
message: "",
url: "https://api.example.com/chat",
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
responseHeaders: { authorization: "Bearer secret-token" },
...input,
})
it.effect("derives status and code when the AI SDK error message is empty", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
data: { error: { message: "", code: "not_found" } },
}),
)
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
expect(error.reason.message).not.toContain("secret-token")
expect(error.reason.message).not.toContain("private prompt")
const projected = toSessionError(error)
expect(projected.type).toBe("provider.invalid-request")
expect(projected.status).toBe(404)
expect(projected.message).not.toBe("")
}),
)
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
}),
)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
const http = "http" in error.reason ? error.reason.http : undefined
expect(http?.request.url).toBe("https://api.example.com/chat")
expect(http?.response?.status).toBe(404)
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
}),
)
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 429,
responseHeaders: { "retry-after": "7" },
}),
)
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
}),
)
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 400,
data: { error: { code: "api_error" } },
}),
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("classifies data-only AI SDK authentication errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 400,
data: { error: { code: "authentication_error" } },
}),
)
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
}),
)
it.effect("detects context overflow from data-only AI SDK errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 400,
data: { error: { code: "context_length_exceeded" } },
}),
)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
expect(isContextOverflowFailure(error)).toBeTrue()
}),
)
it.effect("retries status-less AI SDK transport failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
message: "Cannot connect to API: connection refused",
isRetryable: true,
}),
)
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
}),
)
it.effect("prefers a structured provider message over the code fallback", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
data: { error: { code: "not_found" } },
responseBody: '{"message":"The requested model does not exist"}',
}),
)
expect(error.reason.message).toBe("The requested model does not exist")
}),
)
it.effect("falls back to the status alone for malformed response bodies", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 502,
isRetryable: false,
responseBody: "<html>Bad Gateway</html>",
}),
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
}),
)
+1
View File
@@ -465,6 +465,7 @@ Use native v2 fields.`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
@@ -185,6 +185,7 @@ Review files`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
+8 -3
View File
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("loads authenticated wellknown config before user configuration", () =>
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -322,6 +322,7 @@ describe("Config", () => {
})
const integrationID = Integration.ID.make("https://example.com")
let available = false
let key = "secret"
const credentialNode = makeGlobalNode({
service: Credential.Service,
@@ -361,7 +362,10 @@ describe("Config", () => {
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
resolve: (_entry, variables) =>
available
? Effect.succeed([{ shell: variables.TOKEN }])
: Effect.fail(new Error("expired credential")),
}),
),
deps: [],
@@ -374,11 +378,12 @@ describe("Config", () => {
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
).toEqual(["secret", "global", "project"])
).toEqual(["global", "project"])
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
available = true
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
@@ -164,6 +164,20 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json")))
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
+57
View File
@@ -42,6 +42,27 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
limit: options.limit ?? { context: 100, output: 20 },
})
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
Object.entries(variables).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
return previous
}),
effect,
(previous) =>
Effect.sync(() => {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
})
}),
)
}
describe("ModelResolver", () => {
it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
Effect.gen(function* () {
@@ -230,6 +251,42 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves provider URLs from environment without mutating the catalog model", () =>
withEnv({ ACME_HOST: "api.acme.test" }, () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://${ACME_HOST}/v1" },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(resolved.route.endpoint.baseURL).toBe("https://api.acme.test/v1")
expect(catalog.settings?.baseURL).toBe("https://${ACME_HOST}/v1")
}),
),
)
it.effect("rejects unresolved provider URL variables before route construction", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://${REQUIRED_HOST}/${REQUIRED_PATH}/v1" },
}),
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
providerID: "test-provider",
modelID: "test-model",
variables: ["REQUIRED_HOST", "REQUIRED_PATH"],
})
expect(failure.message).toBe(
"Cannot initialize test-provider/test-model: REQUIRED_HOST, REQUIRED_PATH are required to resolve the provider endpoint",
)
}),
),
)
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
+3 -3
View File
@@ -242,7 +242,7 @@ describe("ModelsDevPlugin", () => {
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
)
it.effect("resolves declared environment variables in provider and model URLs", () =>
it.effect("preserves provider and model URL templates in the catalog", () =>
withEnv(
{
ACME_HOST: "api.acme.test",
@@ -298,10 +298,10 @@ describe("ModelsDevPlugin", () => {
)
expect((yield* catalog.provider.get(providerID))?.settings?.baseURL).toBe(
"https://api.acme.test/${UNDECLARED_HOST}/v1",
"https://${ACME_HOST}/${UNDECLARED_HOST}/v1",
)
expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe(
"https://api.acme.test/${ACME_MODEL_PATH}/v1",
"https://${ACME_HOST}/${ACME_MODEL_PATH}/v1",
)
}),
),
+16
View File
@@ -19,6 +19,9 @@ import {
HttpResponseDetails,
} from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission"
import { ID } from "@opencode-ai/core/model"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Provider } from "@opencode-ai/core/provider"
import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
@@ -91,6 +94,19 @@ describe("toSessionError", () => {
})
})
test("preserves unresolved provider endpoint errors", () => {
const error = new ModelResolver.UnresolvedProviderVariablesError({
providerID: Provider.ID.make("cloudflare-workers-ai"),
modelID: ID.make("model"),
variables: ["CLOUDFLARE_ACCOUNT_ID"],
})
expect(toSessionError(error)).toEqual({
type: "provider.no-route",
message:
"Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint",
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
+24 -21
View File
@@ -286,27 +286,30 @@ describe("ShellTool", () => {
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
it.live(
"permissions compound commands separately",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live(
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"entities": "7.0.1",
"string-width": "catalog:"
},
"devDependencies": {
+87
View File
@@ -7,6 +7,18 @@ describe("DiagramCanvas", () => {
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
})
test("rejects invalid canvas dimensions", () => {
for (const [width, height] of [
[-1, 10],
[10, -1],
[1.5, 10],
[Number.NaN, 10],
[Number.POSITIVE_INFINITY, 10],
]) {
expect(() => new DiagramCanvas(width, height)).toThrow(DiagramCanvasSizeError)
}
})
test("writes cells and text while clipping out-of-bounds positions", () => {
const canvas = new DiagramCanvas<"label">(5, 2)
@@ -26,6 +38,21 @@ describe("DiagramCanvas", () => {
expect(stringWidth(canvas.toString())).toBe(4)
})
test("keeps custom measurement for ASCII text", () => {
let measurements = 0
const canvas = new DiagramCanvas<"label">(5, 1, {
measure: () => {
measurements += 1
return 2
},
})
canvas.setText(0, 0, "ab", "label")
expect(measurements).toBe(2)
expect(canvas.getCell(2, 0)?.char).toBe("b")
})
test("preserves combined graphemes while placing later text", () => {
const canvas = new DiagramCanvas<"label">(4, 1)
@@ -48,6 +75,9 @@ describe("DiagramCanvas", () => {
canvas.setCell(1, 0, "│", "line")
expect(canvas.toString()).toBe(" ┼")
canvas.replaceCell(1, 0, "│", "line")
expect(canvas.toString()).toBe(" │")
})
test("iterates style and metadata runs", () => {
@@ -93,4 +123,61 @@ describe("DiagramCanvas", () => {
expect(canvas.toString({ trimTop: true })).toBe("end")
expect(canvas.getTextSize({ trimTop: true })).toEqual({ width: 3, height: 1 })
})
test("measures trim-aware text height without measuring row width", () => {
let measurements = 0
const canvas = new DiagramCanvas(8, 5, {
measure: (text) => {
measurements += 1
return stringWidth(text)
},
})
canvas.setText(1, 2, "middle")
measurements = 0
expect(canvas.getTextHeight({ trimTop: true, trimBottom: true })).toBe(1)
expect(measurements).toBe(0)
})
test("updates tracked row extents when the last visible cell is cleared", () => {
const canvas = new DiagramCanvas(8, 1)
canvas.setText(1, 0, "abc")
canvas.setCell(3, 0, " ")
expect(canvas.toString()).toBe(" ab")
expect(canvas.getTextSize()).toEqual({ width: 3, height: 1 })
})
test("keeps tracked extents equivalent to scanning after mixed writes", () => {
const canvas = new DiagramCanvas<"line">(20, 10, {
mergeCell: (_existing, incoming) => incoming,
})
let seed = 42
const next = (limit: number) => {
seed = (seed * 1_664_525 + 1_013_904_223) >>> 0
return seed % limit
}
for (let index = 0; index < 200; index++) {
const x = next(canvas.width)
const y = next(canvas.height)
const char = [" ", "x", "─"][next(3)]!
if (next(2) === 0) canvas.setCell(x, y, char, "line")
else canvas.replaceCell(x, y, char, "line")
}
const scanned = canvas.rows.map((row) => {
let end = row.length
while (end > 0 && row[end - 1]?.char === " ") end -= 1
return row
.slice(0, end)
.map((cell) => cell.char)
.join("")
})
const first = scanned.findIndex((line) => line.length > 0)
const last = scanned.findLastIndex((line) => line.length > 0)
expect(canvas.toString()).toBe(scanned.join("\n"))
expect(canvas.getTextHeight({ trimTop: true, trimBottom: true })).toBe(first < 0 ? 0 : last - first + 1)
})
})
+66 -24
View File
@@ -47,7 +47,12 @@ export class DiagramCanvasSizeError extends Error {
readonly width: number,
readonly height: number,
) {
super(`Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`)
const invalid = !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 0 || height < 0
super(
invalid
? `Diagram canvas dimensions must be non-negative safe integers, received ${width}x${height}`
: `Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`,
)
this.name = "DiagramCanvasSizeError"
}
}
@@ -61,29 +66,31 @@ function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]
}
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
readonly rows: Array<Array<DiagramCanvasCell<Style, Metadata>>>
private readonly cells: Array<Array<DiagramCanvasCell<Style, Metadata>>>
private readonly measure: (text: string) => number
private readonly mergeCell?: DiagramCanvasOptions<Style, Metadata>["mergeCell"]
private readonly rowEnds: Uint32Array
constructor(
readonly width: number,
readonly height: number,
options: DiagramCanvasOptions<Style, Metadata> = {},
) {
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 0 || height < 0) {
throw new DiagramCanvasSizeError(width, height)
}
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()))
this.cells = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
this.rowEnds = new Uint32Array(height)
}
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
let rowEnd = row.length
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
return rowEnd
get rows(): ReadonlyArray<ReadonlyArray<Readonly<DiagramCanvasCell<Style, Metadata>>>> {
return this.cells
}
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd: number): string {
return row
.slice(0, rowEnd)
.map((cell) => cell.char)
@@ -92,28 +99,58 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
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
let end = this.cells.length
if (trimTop) while (start < end && this.rowEnds[start] === 0) start += 1
if (trimBottom) while (end > start && this.rowEnds[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
this.writeCell(x, y, char, style, metadata, true)
}
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
return this.rows[y]?.[x]
replaceCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
this.writeCell(x, y, char, style, metadata, false)
}
private writeCell(
x: number,
y: number,
char: string,
style: Style | undefined,
metadata: Partial<Metadata> | undefined,
merge: boolean,
): void {
if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
this.cells[y]![x] = cell
if (cell.char !== " ") {
this.rowEnds[y] = Math.max(this.rowEnds[y]!, x + 1)
} else if (this.rowEnds[y] === x + 1) {
let end = x
while (end > 0 && this.cells[y]![end - 1]?.char === " ") end -= 1
this.rowEnds[y] = end
}
}
getCell(x: number, y: number): Readonly<DiagramCanvasCell<Style, Metadata>> | undefined {
return this.cells[y]?.[x]
}
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
const metadataAt = (cellX: number) => (typeof metadata === "function" ? metadata(cellX, y) : metadata)
if (this.measure === stringWidth && /^[\x20-\x7e]*$/.test(text)) {
for (let index = 0; index < text.length; index++) {
this.setCell(x + index, y, text[index]!, style, metadataAt(x + index))
}
return
}
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))
@@ -126,7 +163,7 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
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]!))
lines.push(this.rowText(this.cells[rowIndex]!, this.rowEnds[rowIndex]!))
}
return lines.join("\n")
}
@@ -135,13 +172,18 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
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)
const row = this.cells[rowIndex]!
const rowEnd = this.rowEnds[rowIndex]!
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
}
return { width, height: rows.end - rows.start }
}
getTextHeight(options: DiagramCanvasTextOptions = {}): number {
const rows = this.textRowRange(options.trimTop ?? false, options.trimBottom ?? false)
return rows.end - rows.start
}
forEachRun(
onRun: (run: DiagramCanvasRun<Style, Metadata>) => void,
onLineEnd: () => void,
@@ -151,8 +193,8 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
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)
const row = this.cells[rowIndex]!
const rowEnd = this.rowEnds[rowIndex]!
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
let currentKey: readonly unknown[] | undefined
+7 -2
View File
@@ -27,7 +27,12 @@ export function firstMeaningfulMermaidLine(content: string): string | 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 decodeMermaidText(trimmed.slice(1, -1))
}
return trimmed
return decodeMermaidText(trimmed)
}
export function decodeMermaidText(value: string): string {
return decodeHTMLStrict(value)
}
import { decodeHTMLStrict } from "entities"
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "./spatial.js"
const body = spatialRectClaim("body", "node:A", "body", { left: 2, top: 1, width: 4, height: 3 })
const label = spatialRectClaim("label", "edge:A-B", "label", { left: 8, top: 1, width: 5, height: 1 })
const route = spatialPathClaim("route", "edge:A-B", "route", [
{ x: 5, y: 2 },
{ x: 10, y: 2 },
])
describe("SpatialIndex", () => {
test("composition is associative, commutative, idempotent, and has an identity", () => {
const a = SpatialIndex.empty().add(body)
const b = SpatialIndex.empty().add(label)
const c = SpatialIndex.empty().add(route)
expect(SpatialIndex.empty().overlay(a).claims).toEqual(a.claims)
expect(a.overlay(b).claims).toEqual(b.overlay(a).claims)
expect(a.overlay(b).overlay(c).claims).toEqual(a.overlay(b.overlay(c)).claims)
expect(a.overlay(a).claims).toEqual(a.claims)
})
test("routes may share routes but cannot cross unrelated semantic bodies", () => {
const index = SpatialIndex.empty().add(body, route)
const crossingBody = spatialPathClaim("cross-body", "edge:C-D", "route", [
{ x: 0, y: 2 },
{ x: 8, y: 2 },
])
const crossingRoute = spatialPathClaim("cross-route", "edge:C-D", "route", [
{ x: 7, y: 0 },
{ x: 7, y: 4 },
])
expect(index.isFree(crossingBody)).toBe(false)
expect(index.isFree(crossingRoute)).toBe(true)
})
test("declared endpoint contacts do not permit contact elsewhere", () => {
const index = SpatialIndex.empty().add(body)
const candidate = spatialPathClaim("candidate", "edge:B-A", "route", [
{ x: 0, y: 2 },
{ x: 2, y: 2 },
])
expect(index.isFree(candidate)).toBe(false)
expect(index.isFree(candidate, { contacts: [{ owner: "node:A", points: [{ x: 2, y: 2 }] }] })).toBe(true)
})
test("firstFit chooses the first collision-free candidate", () => {
const index = SpatialIndex.empty().add(body)
const blocked = spatialRectClaim("blocked", "label:B", "label", { left: 3, top: 2, width: 2, height: 1 })
const clear = spatialRectClaim("clear", "label:B", "label", { left: 7, top: 2, width: 2, height: 1 })
expect(index.firstFit([{ claim: blocked }, { claim: clear }])?.claim.id).toBe("clear")
})
test("clearance is symmetric in both axes", () => {
const index = SpatialIndex.empty().add(body)
const touchingRight = spatialRectClaim("right", "label:B", "label", { left: 6, top: 1, width: 2, height: 1 })
const touchingBelow = spatialRectClaim("below", "label:C", "label", { left: 2, top: 4, width: 2, height: 1 })
expect(index.isFree(touchingRight)).toBe(true)
expect(index.isFree(touchingRight, { clearance: 1 })).toBe(false)
expect(index.isFree(touchingBelow)).toBe(true)
expect(index.isFree(touchingBelow, { clearance: 1 })).toBe(false)
})
test("axis-specific clearance does not move unrelated rows", () => {
const index = SpatialIndex.empty().add(body)
const touchingRight = spatialRectClaim("right", "label:B", "label", { left: 6, top: 1, width: 2, height: 1 })
const touchingBelow = spatialRectClaim("below", "label:C", "label", { left: 2, top: 4, width: 2, height: 1 })
expect(index.isFree(touchingRight, { clearance: { x: 1, y: 0 } })).toBe(false)
expect(index.isFree(touchingBelow, { clearance: { x: 1, y: 0 } })).toBe(true)
})
test("rejects malformed geometry instead of weakening collision checks", () => {
expect(() => spatialRectClaim("zero", "node", "body", { left: 0, top: 0, width: 0, height: 1 })).toThrow()
expect(() =>
spatialPathClaim("diagonal", "edge", "route", [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
]),
).toThrow()
expect(() => SpatialIndex.empty().add(body).isFree(label, { clearance: Number.POSITIVE_INFINITY })).toThrow()
})
})
+242
View File
@@ -0,0 +1,242 @@
import { orthogonalPathPoints, type DiagramBounds, type DiagramPoint } from "./geometry.js"
export type SpatialRole = "body" | "boundary" | "terminal" | "route" | "label"
export interface SpatialSpan {
readonly y: number
readonly fromX: number
readonly toX: number
}
export interface SpatialClaim {
readonly id: string
readonly owner: string
readonly role: SpatialRole
readonly spans: readonly SpatialSpan[]
}
export interface SpatialContact {
owner: string
points: readonly DiagramPoint[]
}
export interface SpatialConflict {
moving: SpatialClaim
existing: SpatialClaim
point: DiagramPoint
}
export interface SpatialClearance {
x: number
y: number
}
export interface SpatialCollisionPolicy {
contacts?: readonly SpatialContact[]
clearance?: number | SpatialClearance | Partial<Record<SpatialRole, number | SpatialClearance>>
}
function normalizedSpan(y: number, fromX: number, toX: number): SpatialSpan {
return { y, fromX: Math.min(fromX, toX), toX: Math.max(fromX, toX) }
}
function assertFiniteInteger(value: number, name: string): void {
if (!Number.isFinite(value) || !Number.isInteger(value)) throw new RangeError(`${name} must be a finite integer`)
}
export function spatialRectSpans(bounds: Pick<DiagramBounds, "left" | "top" | "width" | "height">): SpatialSpan[] {
assertFiniteInteger(bounds.left, "bounds.left")
assertFiniteInteger(bounds.top, "bounds.top")
assertFiniteInteger(bounds.width, "bounds.width")
assertFiniteInteger(bounds.height, "bounds.height")
if (bounds.width <= 0 || bounds.height <= 0) throw new RangeError("Spatial bounds must have positive dimensions")
return Array.from({ length: bounds.height }, (_, offset) =>
normalizedSpan(bounds.top + offset, bounds.left, bounds.left + bounds.width - 1),
)
}
export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[] {
for (const [index, point] of points.entries()) {
assertFiniteInteger(point.x, `points[${index}].x`)
assertFiniteInteger(point.y, `points[${index}].y`)
if (index > 0 && point.x !== points[index - 1]!.x && point.y !== points[index - 1]!.y) {
throw new RangeError("Spatial paths must be orthogonal")
}
}
const cells = new Map<number, Set<number>>()
const add = (point: DiagramPoint): void => {
const row = cells.get(point.y) ?? new Set<number>()
row.add(point.x)
cells.set(point.y, row)
}
if (points.length === 1) add(points[0]!)
for (const point of orthogonalPathPoints(points)) add(point)
return [...cells.entries()]
.sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right)
const spans: SpatialSpan[] = []
let start = sorted[0]
let end = start
if (start === undefined) return spans
for (const x of sorted.slice(1)) {
if (x === end! + 1) {
end = x
continue
}
spans.push(normalizedSpan(y, start, end!))
start = x
end = x
}
spans.push(normalizedSpan(y, start, end!))
return spans
})
}
export function spatialRectClaim(
id: string,
owner: string,
role: SpatialRole,
bounds: Pick<DiagramBounds, "left" | "top" | "width" | "height">,
): SpatialClaim {
return { id, owner, role, spans: spatialRectSpans(bounds) }
}
export function spatialPathClaim(
id: string,
owner: string,
role: Extract<SpatialRole, "boundary" | "route">,
points: readonly DiagramPoint[],
): SpatialClaim {
return { id, owner, role, spans: spatialPathSpans(points) }
}
function compareClaims(left: SpatialClaim, right: SpatialClaim): number {
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0
}
function sameClaim(left: SpatialClaim, right: SpatialClaim): boolean {
return (
left.id === right.id &&
left.owner === right.owner &&
left.role === right.role &&
left.spans.length === right.spans.length &&
left.spans.every(
(span, index) =>
span.y === right.spans[index]!.y &&
span.fromX === right.spans[index]!.fromX &&
span.toX === right.spans[index]!.toX,
)
)
}
function pointIsContact(point: DiagramPoint, existing: SpatialClaim, contacts: readonly SpatialContact[]): boolean {
return contacts.some(
(contact) =>
contact.owner === existing.owner &&
contact.points.some((candidate) => candidate.x === point.x && candidate.y === point.y),
)
}
function rolesMayOverlap(moving: SpatialClaim, existing: SpatialClaim): boolean {
if (moving.owner === existing.owner) return true
return moving.role === "route" && existing.role === "route"
}
function normalizeClearance(clearance: number | SpatialClearance | undefined): SpatialClearance {
const x = typeof clearance === "number" ? clearance : (clearance?.x ?? 0)
const y = typeof clearance === "number" ? clearance : (clearance?.y ?? 0)
assertFiniteInteger(x, "clearance.x")
assertFiniteInteger(y, "clearance.y")
if (x < 0 || y < 0) throw new RangeError("Spatial clearance cannot be negative")
return { x, y }
}
function inflateSpan(span: SpatialSpan, clearance: SpatialClearance): SpatialSpan {
return { y: span.y, fromX: span.fromX - clearance.x, toX: span.toX + clearance.x }
}
export class SpatialIndex {
static empty(): SpatialIndex {
return new SpatialIndex([])
}
readonly claims: readonly SpatialClaim[]
private constructor(claims: readonly SpatialClaim[]) {
this.claims = Object.freeze(
claims.map((claim) =>
Object.freeze({
...claim,
spans: Object.freeze(
[...claim.spans]
.map((span) => Object.freeze({ ...span }))
.sort((left, right) => left.y - right.y || left.fromX - right.fromX || left.toX - right.toX),
),
}),
),
)
}
add(...claims: readonly SpatialClaim[]): SpatialIndex {
return this.overlay(new SpatialIndex(claims))
}
overlay(other: SpatialIndex): SpatialIndex {
const claims = new Map(this.claims.map((claim) => [claim.id, claim]))
for (const claim of other.claims) {
const existing = claims.get(claim.id)
if (existing && !sameClaim(existing, claim)) throw new Error(`Conflicting spatial claim id: ${claim.id}`)
claims.set(claim.id, claim)
}
return new SpatialIndex([...claims.values()].sort(compareClaims))
}
conflicts(moving: SpatialClaim, policy: SpatialCollisionPolicy = {}): SpatialConflict[] {
const contacts = policy.contacts ?? []
const conflicts: SpatialConflict[] = []
for (const existing of this.claims) {
if (rolesMayOverlap(moving, existing)) continue
const configuredClearance =
typeof policy.clearance === "number" || (policy.clearance && "x" in policy.clearance)
? policy.clearance
: policy.clearance?.[existing.role]
const clearance = normalizeClearance(configuredClearance)
for (const movingSpan of moving.spans) {
for (let dy = -clearance.y; dy <= clearance.y; dy++) {
const inflated = inflateSpan({ ...movingSpan, y: movingSpan.y + dy }, clearance)
for (const existingSpan of existing.spans) {
if (inflated.y !== existingSpan.y) continue
const fromX = Math.max(inflated.fromX, existingSpan.fromX)
const toX = Math.min(inflated.toX, existingSpan.toX)
for (let x = fromX; x <= toX; x++) {
const point = { x, y: inflated.y }
const movingOccupiesPoint = moving.spans.some(
(span) => span.y === point.y && point.x >= span.fromX && point.x <= span.toX,
)
if (!(movingOccupiesPoint && pointIsContact(point, existing, contacts))) {
conflicts.push({ moving, existing, point })
}
}
}
}
}
}
return conflicts
}
isFree(claim: SpatialClaim, policy: SpatialCollisionPolicy = {}): boolean {
return this.conflicts(claim, policy).length === 0
}
firstFit<T extends { claim: SpatialClaim }>(
candidates: readonly T[],
policy: SpatialCollisionPolicy = {},
): T | undefined {
return candidates.find((candidate) => this.isFree(candidate.claim, policy))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
/** An otherwise valid diagram contains syntax that merman does not support. */
/** An otherwise valid diagram contains syntax that this renderer does not support. */
export class MermaidSyntaxError extends Error {
readonly _tag = "MermaidSyntaxError"
+13 -6
View File
@@ -148,7 +148,7 @@ function drawSubgraphLabel(grid: FlowchartGrid, bounds: FlowchartSubgraphBounds)
}
function drawEdgeLabel(grid: FlowchartGrid, route: FlowchartEdgeRoute, style: FlowchartCellStyle): void {
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis)
for (const [index, line] of label.lines.entries()) {
grid.setText(label.point.x, label.point.y + index, line, style)
}
@@ -249,15 +249,22 @@ function drawSourceConnectors(
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",
grid.replaceCell(
sourcePoint.x,
sourcePoint.y,
diagramLineGlyph(
new Set([routeDirection, connectorDirection]),
"rounded",
route.edge.style === "thick" ? "heavy" : "single",
),
"edge",
)
cell.style = "edge"
}
}
fadeSourcePath(grid, connector, route.points, styles, occupancy)
if (route.edge.sourceArrowhead && route.points[1]) {
grid.setCell(sourcePoint.x, sourcePoint.y, diagramArrowHeadBetween(route.points[1], sourcePoint), "edge")
}
}
}
+265 -2
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { parseColor } from "@opentui/core"
import stringWidth from "string-width"
import { colorsEqual } from "../core/color/style.js"
import { expectDiagram } from "../test/diagram.js"
import { drawFlowchartDiagramGrid as drawParsedFlowchartDiagramGrid } from "./drawing.js"
import {
@@ -9,6 +8,7 @@ import {
DEFAULT_MIN_VERTICAL_RANK_GAP,
layoutFlowchartDiagram as layoutParsedFlowchartDiagram,
} from "./layout.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import { parseMermaidFlowchartDiagram } from "./parser.js"
import { renderFlowchartDiagram } from "./render.js"
import { renderGridStyledText, resolveFlowchartStyleColors } from "./style.js"
@@ -55,6 +55,63 @@ function routeRunsAlongVerticalBorder(
return false
}
function routeIntersectsBounds(
route: { points: readonly { x: number; y: number }[] },
bounds: { left: number; top: number; width: number; height: number },
): boolean {
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
for (let index = 1; index < route.points.length; index++) {
const from = route.points[index - 1]!
const to = route.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
}
} else 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 terminalPointsTowardBounds(
route: { points: readonly { x: number; y: number }[] },
bounds: { left: number; top: number; width: number; height: number },
): boolean {
const before = route.points.at(-2)!
const end = route.points.at(-1)!
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
if (end.x === bounds.left - 1 && end.y >= bounds.top && end.y <= bottom) return before.x < end.x && before.y === end.y
if (end.x === right + 1 && end.y >= bounds.top && end.y <= bottom) return before.x > end.x && before.y === end.y
if (end.y === bounds.top - 1 && end.x >= bounds.left && end.x <= right) return before.y < end.y && before.x === end.x
if (end.y === bottom + 1 && end.x >= bounds.left && end.x <= right) return before.y > end.y && before.x === end.x
return false
}
function boundsIntersect(
left: { left: number; top: number; width: number; height: number },
right: { left: number; top: number; width: number; height: number },
): boolean {
return (
left.left <= right.left + right.width - 1 &&
left.left + left.width - 1 >= right.left &&
left.top <= right.top + right.height - 1 &&
left.top + left.height - 1 >= right.top
)
}
describe("FlowchartDiagram", () => {
test("renders compact horizontal flowcharts with shorter routes", () => {
const output = renderFlowchartDiagram(
@@ -208,6 +265,67 @@ describe("FlowchartDiagram", () => {
`)
})
test("keeps vertical feedback labels clear of unrelated nodes", () => {
const content = `flowchart TD
S[Source] --> A[Alpha]
S --> B{Beta?}
S --> C[(Store)]
A --> J[[Join]]
B --> J
C --> J
J -->|cycle back| S`
const layout = layoutFlowchartDiagram(content)
const feedback = layout.routes.find((route) => route.edge.from === "J" && route.edge.to === "S")!
const label = flowchartEdgeLabelLayout(feedback.points, feedback.edge.label, stringWidth)
const labelBounds = { left: label.point.x, top: label.point.y, width: label.width, height: label.height }
for (const id of ["A", "B", "C"]) expect(boundsIntersect(labelBounds, layout.bounds.get(id)!)).toBe(false)
expect(renderFlowchartDiagram(content)).toContain("cycle back")
})
test("routes horizontal feedback edges around sibling nodes", () => {
for (const direction of ["LR", "RL"] as const) {
const layout = layoutFlowchartDiagram(`flowchart ${direction}
S[Start] --> D{Ready?}
D --> O[Output]
D --> R[Retry]
R --> S`)
const feedback = layout.routes.find((route) => route.edge.from === "R" && route.edge.to === "S")!
expect(routeIntersectsBounds(feedback, layout.bounds.get("O")!)).toBe(false)
}
})
test("keeps compact vertical fan-in arrowheads pointed at the target", () => {
const content = `flowchart TD
A[Left] -->|left| C[Merge]
B[Right] -->|right| C`
const layout = layoutFlowchartDiagram(content, { compact: true })
for (const route of layout.routes) {
const beforeTarget = route.points.at(-2)!
const target = route.points.at(-1)!
expect(beforeTarget.x).toBe(target.x)
expect(beforeTarget.y).toBeLessThan(target.y)
}
expect(renderFlowchartDiagram(content, { compact: true })).toContain("▼")
})
test("routes same-rank vertical-flow edges into the target side", () => {
const layout = layoutFlowchartDiagram(`flowchart TD
B[Start] --> D{Choose}
D --> E[[Primary]]
D --> F[Fallback]
E --> B
F --> E`)
const route = layout.routes.find((candidate) => candidate.edge.from === "F" && candidate.edge.to === "E")!
const beforeTarget = route.points.at(-2)!
const target = route.points.at(-1)!
expect(beforeTarget.y).toBe(target.y)
expect(beforeTarget.x).toBeGreaterThan(target.x)
})
test("renders parallel same-endpoint edges without losing labels", () => {
const content = `flowchart LR
A[Source] -->|first| B[Target]
@@ -238,6 +356,36 @@ describe("FlowchartDiagram", () => {
}
})
test("keeps five parallel multiline edge labels distinct", () => {
const output = renderFlowchartDiagram(`flowchart TD
A[Source] -->|one alpha<br/>one beta| B[Target]
A -->|two alpha<br/>two beta| B
A -->|three alpha<br/>three beta| B
A -->|four alpha<br/>four beta| B
A -->|five alpha<br/>five beta| B`)
for (const number of ["one", "two", "three", "four", "five"]) {
expect(output.match(new RegExp(`${number} alpha`, "g"))).toHaveLength(1)
expect(output.match(new RegExp(`${number} beta`, "g"))).toHaveLength(1)
}
})
test("does not reserve label gaps for unlabeled fan-out", () => {
const output = renderFlowchartDiagram(`flowchart TD
S[The Boss] --> A[A]
S --> B[B]
S --> C[C]
S --> D[D]
S --> E[E]
S --> F[F]
S --> G[G]
S --> H[H]
S --> I[I]
S --> J[J]`)
expect(Math.max(...output.split("\n").map((line) => stringWidth(line)))).toBeLessThanOrEqual(100)
})
test("keeps transitive targets below intermediate vertical stages", () => {
const content = `flowchart TD
A[Start] --> B[Validate]
@@ -389,6 +537,14 @@ flowchart TD
])
})
test("decodes HTML entities in node and edge labels", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A[HMAC verify &lt;3s &amp; continue] -->|result &#x2265; 1| B[Done]`)
expect(diagram.nodes.find((node) => node.id === "A")?.label).toBe("HMAC verify <3s & continue")
expect(diagram.edges[0]?.label).toBe("result ≥ 1")
})
test("parses and renders each edge in a chained flowchart statement", () => {
const content = `flowchart LR
API --> Worker --> DB[(Database)]`
@@ -434,6 +590,25 @@ flowchart TD
])
})
test("parses labeled undirected dashed and bidirectional edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
DB[(Durable Object SQLite)]
API[Slack API]
DB -. no shared transaction .- API
API <--> DB`)
expect(diagram.edges).toEqual([
{ from: "DB", to: "API", label: "no shared transaction", style: "dashed", arrowhead: false },
{ from: "API", to: "DB", label: "", sourceArrowhead: true },
])
const dashedOutput = renderFlowchartDiagram(`flowchart LR
DB[(Durable Object SQLite)] -. no shared transaction .- API[Slack API]`)
const bidirectionalOutput = renderFlowchartDiagram(`flowchart LR
DB[(Durable Object SQLite)] <--> API[Slack API]`)
expect(dashedOutput).toContain("no shared transaction")
expect(bidirectionalOutput.match(/[◀▶▲▼]/g)?.length).toBeGreaterThanOrEqual(2)
})
test("renders the volume persistence diagram with an undirected solid edge", () => {
const content = `flowchart LR
subgraph durable [Durable survives everything]
@@ -884,6 +1059,94 @@ flowchart TD
expect(route.points[0]!.y).toBe(route.points[route.points.length - 1]!.y)
})
test("routes cross-subgraph edges around local-direction siblings", () => {
const layout = layoutFlowchartDiagram(`flowchart TD
subgraph Workers
direction TD
A[Worker one] --> B[Worker two]
end
subgraph Peer
direction RL
C[Store] --> D[Transform]
end
B --> D`)
const route = layout.routes.find((candidate) => candidate.edge.from === "B" && candidate.edge.to === "D")!
expect(routeIntersectsBounds(route, layout.bounds.get("C")!)).toBe(false)
})
test.each([
["BT", { compact: true }],
["LR", { compact: true }],
["RL", { compact: true }],
] as const)("keeps labeled cross-group routes clear of sibling nodes in %s layouts", (direction, options) => {
const content = `flowchart ${direction}
subgraph Left
direction RL
A[API] --> B[Queue]
end
subgraph Right
direction TB
C[Transform] --> D[Accept]
end
B -->|cross group| C
D -->|retry group| A`
const layout = layoutFlowchartDiagram(content, options)
const crossGroup = layout.routes.find((route) => route.edge.from === "B" && route.edge.to === "C")!
if (direction !== "LR") expect(routeIntersectsBounds(crossGroup, layout.bounds.get("A")!)).toBe(false)
expect(renderFlowchartDiagram(content, options)).toContain("cross group")
expect(renderFlowchartDiagram(content, options)).toContain("retry group")
})
test.each(["LR", "RL"] as const)(
"keeps nested result labels and target-facing entry routes in %s layouts",
(direction) => {
const content = `flowchart ${direction}
I[Input] --> A
subgraph Outer
direction LR
subgraph Inner
direction BT
A[Parse] --> B[Valid]
B --> C[Cache]
C --> B
end
B --> D[Dispatch]
end
D -->|result path| O[Output]`
const layout = layoutFlowchartDiagram(content)
const entry = layout.routes.find((route) => route.edge.from === "I" && route.edge.to === "A")!
expect(renderFlowchartDiagram(content)).toContain("result path")
expect(terminalPointsTowardBounds(entry, layout.bounds.get("A")!)).toBe(true)
},
)
test("routes nested RL local edges around outer siblings", () => {
const layout = layoutFlowchartDiagram(
`flowchart RL
I([Input λ]) --> A
subgraph Outer [Outer group ]
direction LR
subgraph Inner [Inner<br/>]
direction BT
A[Parse request] -->|inner edge| B{Valid?}
B --> C[(Cache Ω)]
C --> B
end
B --> D[[Dispatch work]]
end
D -.->|result path| O([Output μ])`,
{ compact: true },
)
for (const route of layout.routes.filter((route) => ["A", "B", "C"].includes(route.edge.from))) {
if (route.edge.to === "D") continue
expect(routeIntersectsBounds(route, layout.bounds.get("D")!)).toBe(false)
}
})
test("compacts stacked subgraph-local direction rows", () => {
const layout = layoutFlowchartDiagram(`
flowchart TD
@@ -1311,6 +1574,6 @@ flowchart LR
const node = parseColor("#ff0000")
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && colorsEqual(chunk.fg, node))).toBe(true)
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
})
})
@@ -67,6 +67,22 @@ describe("flowchart edge labels", () => {
).toEqual({ x: 151, y: 7 })
})
test("keeps side-route labels on the vertical bus when horizontal arms grow", () => {
expect(
flowchartEdgeLabelLayout(
[
{ x: 5, y: 2 },
{ x: 30, y: 2 },
{ x: 30, y: 10 },
{ x: 5, y: 10 },
],
"parallel label",
measure,
"y",
).point,
).toEqual({ x: 31, y: 6 })
})
test("measures br-delimited edge label lines as a block", () => {
const layout = flowchartEdgeLabelLayout(
[
+17 -6
View File
@@ -67,14 +67,23 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
function bestLabelSegment(
points: readonly FlowchartPoint[],
labelWidth: number,
preferredAxis?: DiagramSegment["axis"],
): DiagramSegment | undefined {
const segments = points.slice(1).flatMap((to, index) => {
const segment = segmentBetween(points[index]!, to)
return segment ? [segment] : []
})
const preferred = preferredAxis ? segments.find((segment) => segment.axis === preferredAxis) : undefined
if (preferred) return preferred
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
for (const segment of segments) {
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
@@ -87,8 +96,9 @@ function flowchartLabelPoint(
points: readonly FlowchartPoint[],
labelWidth: number,
labelHeight: number,
preferredAxis?: DiagramSegment["axis"],
): FlowchartPoint {
const segment = bestLabelSegment(points, labelWidth)
const segment = bestLabelSegment(points, labelWidth, preferredAxis)
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
}
@@ -96,9 +106,10 @@ export function flowchartEdgeLabelLayout(
points: readonly FlowchartPoint[],
label: string,
measure: (text: string) => number,
preferredAxis?: DiagramSegment["axis"],
): 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 }
return { lines, point: flowchartLabelPoint(points, width, height, preferredAxis), width, height }
}
+19 -4
View File
@@ -27,6 +27,7 @@ import type {
export const DEFAULT_MIN_NODE_GAP = 5
export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
const DEFAULT_MAX_UNLABELED_RANK_WIDTH = 120
export const DEFAULT_MIN_RANK_GAP = 7
export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
export const COMPACT_MIN_RANK_GAP = 4
@@ -316,7 +317,7 @@ function pathBounds(points: readonly { x: number; y: number }[]): FlowchartBound
function labelBounds(route: FlowchartEdgeRoute): FlowchartBounds | undefined {
if (!route.edge.label) return undefined
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength, route.labelAxis)
const { point, width, height } = label
return {
left: point.x,
@@ -358,9 +359,6 @@ function layoutRankedNodes(
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[]>()
@@ -375,6 +373,23 @@ function layoutRankedNodes(
ranksByIndex.set(normalizedRank, nodes)
}
const spaciousNodeGap = Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP)
const widestUnlabeledRank = Math.max(
0,
...[...ranksByIndex.values()].map(
(nodes) =>
nodes.reduce((total, node) => total + sizes.get(node.id)!.width, 0) +
Math.max(0, nodes.length - 1) * spaciousNodeGap,
),
)
const rankNodeGap = horizontal
? minNodeGap
: widestPaddedEdgeLabel > 0
? Math.max(spaciousNodeGap, flowchartVerticalBranchLabelGap(widestPaddedEdgeLabel))
: widestUnlabeledRank > DEFAULT_MAX_UNLABELED_RANK_WIDTH
? minNodeGap
: spaciousNodeGap
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)
+100 -5
View File
@@ -8,6 +8,7 @@ import type {
} from "./types.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import {
decodeMermaidText,
firstMeaningfulMermaidLine,
meaningfulNumberedMermaidLines,
stripMermaidQuotes as stripQuotes,
@@ -28,8 +29,9 @@ 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 CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
const EDGE_OPERATOR_RE =
/(-\.(?!->)(.+?)\.->)|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)|(-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/g
/(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/g
function normalizeDirection(value?: string): FlowchartDirection {
const upper = value?.toUpperCase()
@@ -79,6 +81,20 @@ function parseNodeToken(token: string): FlowchartNode {
return { id: trimmed, label: trimmed, shape: "box" }
}
function isSupportedNodeToken(token: string): boolean {
const trimmed = stripNodeToken(token)
if (CIRCLE_NODE_RE.test(trimmed)) return false
return (
ID_ONLY_RE.test(trimmed) ||
DATABASE_NODE_RE.test(trimmed) ||
SUBROUTINE_NODE_RE.test(trimmed) ||
ROUNDED_BRACKET_NODE_RE.test(trimmed) ||
ROUNDED_NODE_RE.test(trimmed) ||
DECISION_NODE_RE.test(trimmed) ||
BOX_NODE_RE.test(trimmed)
)
}
function hasExplicitNodeShape(token: string): boolean {
return EXPLICIT_NODE_SHAPE_RE.test(token.trim())
}
@@ -122,9 +138,11 @@ function createEdge(
label: string,
style: FlowchartEdgeStyle | undefined,
arrowhead: boolean,
sourceArrowhead: boolean,
): FlowchartEdge {
const edge: FlowchartEdge = style ? { from, to, label, style } : { from, to, label }
if (!arrowhead) edge.arrowhead = false
if (sourceArrowhead) edge.sourceArrowhead = true
return edge
}
@@ -134,25 +152,91 @@ interface ParsedEdgeOperator {
label: string
style: FlowchartEdgeStyle | undefined
arrowhead: boolean
sourceArrowhead: boolean
orderOnly: boolean
}
function parseEdgeOperators(line: string): ParsedEdgeOperator[] {
return [...line.matchAll(EDGE_OPERATOR_RE)].map((match) => {
return [...maskNodeLabelOperators(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(),
label: decodeMermaidText((match[2] ?? match[4] ?? match[7] ?? "").trim()),
style: edgeStyleFromArrow(startArrow, endArrow),
arrowhead: endArrow !== "---",
arrowhead: endArrow === "~~~" || endArrow.endsWith(">"),
sourceArrowhead: startArrow.startsWith("<"),
orderOnly: endArrow === "~~~",
}
})
}
function maskNodeLabelOperators(line: string): string {
const characters = line.split("")
const stack: string[] = []
let quote: '"' | "'" | undefined
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
for (let index = 0; index < characters.length; index++) {
const character = characters[index]!
if (quote) {
if (character === quote && characters[index - 1] !== "\\") quote = undefined
else if (/[<>=.-]/.test(character)) characters[index] = " "
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length > 0 && /[<>=.-]/.test(character)) characters[index] = " "
}
return characters.join("")
}
function hasInternalStatementSeparator(line: string): boolean {
const stack: string[] = []
let quote: '"' | "'" | undefined
let edgeLabel = false
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
const finalIndex = line.trimEnd().length - 1
for (let index = 0; index < line.length; index++) {
const character = line[index]!
if (quote) {
if (character === quote && line[index - 1] !== "\\") quote = undefined
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length === 0 && character === "|") {
edgeLabel = !edgeLabel
continue
}
if (character === ";" && index < finalIndex && stack.length === 0 && !edgeLabel) return true
}
return false
}
export function isMermaidFlowchartDiagram(content: string): boolean {
return FLOWCHART_HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
}
@@ -166,6 +250,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
if (hasInternalStatementSeparator(line)) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const header = line.match(FLOWCHART_HEADER_RE)
if (header) {
direction = normalizeDirection(header[2])
@@ -222,6 +307,15 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
]
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const unsupportedEndpoint = nodeTokens.find((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return (
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped)
)
})
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
@@ -239,6 +333,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
@@ -246,7 +341,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
}
}
if (hasExplicitNodeShape(line) || ID_ONLY_RE.test(stripNodeToken(line))) {
if (isSupportedNodeToken(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth } from "../core/text.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type { FlowchartDiagram, FlowchartNodeBounds } from "./types.js"
import { routeFlowchartEdges } from "./routing.js"
@@ -21,6 +23,31 @@ function diagram(direction: FlowchartDiagram["direction"], edges: FlowchartDiagr
return { direction, nodes: [], edges, subgraphs: [] }
}
function routeIntersectsBounds(
points: readonly { x: number; y: number }[],
nodeBounds: { left: number; top: number; width: number; height: number },
): boolean {
const right = nodeBounds.left + nodeBounds.width - 1
const bottom = nodeBounds.top + nodeBounds.height - 1
return points.slice(1).some((to, index) => {
const from = points[index]!
if (from.x === to.x) {
return (
from.x >= nodeBounds.left &&
from.x <= right &&
Math.max(from.y, to.y) >= nodeBounds.top &&
Math.min(from.y, to.y) <= bottom
)
}
return (
from.y >= nodeBounds.top &&
from.y <= bottom &&
Math.max(from.x, to.x) >= nodeBounds.left &&
Math.min(from.x, to.x) <= right
)
})
}
describe("flowchart routing", () => {
test("routes a simple horizontal edge from source port to target port", () => {
const edge = { from: "A", to: "B", label: "" }
@@ -269,4 +296,79 @@ describe("flowchart routing", () => {
},
])
})
test("does not route a fallback through its own source node", () => {
const labeled = { from: "A", to: "B", label: "route" }
const crossing = { from: "C", to: "D", label: "" }
const nodeBounds = new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 100, 0)],
["C", bounds("C", 48, -12)],
["D", bounds("D", 48, 12)],
])
const routes = routeFlowchartEdges(diagram("LR", [labeled, crossing]), nodeBounds, undefined, new Map())
const route = routes.find((candidate) => candidate.edge === labeled)!
expect(routeIntersectsBounds(route.points, nodeBounds.get("A")!)).toBe(false)
expect(routeIntersectsBounds(route.points, nodeBounds.get("B")!)).toBe(false)
})
test("ignores zero-width blank label interiors as route obstacles", () => {
const blankLabel = { from: "A", to: "B", label: "<br/>" }
const crossing = { from: "C", to: "D", label: "" }
const routes = routeFlowchartEdges(
diagram("TD", [blankLabel, crossing]),
new Map([
["A", bounds("A", 0, 0)],
["B", bounds("B", 0, 100)],
["C", bounds("C", -20, 50)],
["D", bounds("D", 20, 50)],
]),
(edge) => (edge === blankLabel ? "TD" : "LR"),
new Map(),
)
expect(routes.find((route) => route.edge === blankLabel)!.points).toEqual([
{ x: 2, y: 3 },
{ x: 2, y: 99 },
])
})
test("checks earlier labels against finalized later fallback routes", () => {
const edges = [
{ from: "C", to: "B", label: "alpha" },
{ from: "A", to: "F", label: "beta long" },
{ from: "C", to: "D", label: "gamma" },
{ from: "A", to: "B", label: "" },
]
const directions = ["TD", "RL", "LR", "BT"] as const
const routes = routeFlowchartEdges(
diagram("LR", edges),
new Map([
["A", bounds("A", -24, 6)],
["B", bounds("B", 48, 24)],
["C", bounds("C", -24, 24)],
["D", bounds("D", -24, -18)],
["F", bounds("F", -16, -6)],
]),
(edge) => directions[edges.indexOf(edge)]!,
new Map(),
)
const labeled = routes.find((route) => route.edge === edges[0])!
const laterFallback = routes.find((route) => route.edge === edges[3])!
const label = flowchartEdgeLabelLayout(labeled.points, labeled.edge.label, diagramTextWidth)
expect(labeled.points).toEqual([
{ x: -19, y: 25 },
{ x: 47, y: 25 },
])
expect(
routeIntersectsBounds(laterFallback.points, {
left: label.point.x + 1,
top: label.point.y,
width: label.width - 2,
height: label.height,
}),
).toBe(false)
})
})
+234 -37
View File
@@ -15,6 +15,7 @@ import {
pathViaLane,
sideForDirection,
snapCoordinate,
shiftPoint,
withCoordinate,
type DiagramAxis,
type DiagramDirection,
@@ -22,7 +23,7 @@ import {
type DiagramSide,
} from "../core/geometry.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import { flowchartEdgeLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js"
import type {
FlowchartDiagram,
FlowchartDirection,
@@ -130,7 +131,9 @@ function horizontalEdgePath(
const travel = horizontalTravel(from, to, direction)
const startSide = sideForDirection(travel)
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)), {
preferredAxis: "x",
})
}
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
@@ -165,7 +168,7 @@ function labelHeight(edge: FlowchartEdge): number {
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)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth, route.labelAxis)
right = Math.max(right, label.point.x + label.width - 1)
}
return right
@@ -179,6 +182,14 @@ function edgePath(
): FlowchartPoint[] {
if (from.id === to.id) return selfEdgePath(from)
if (!isVerticalDirection(direction)) return horizontalEdgePath(from, to, direction)
const overlapsVertically = from.top < to.top + to.height && to.top < from.top + from.height
if (overlapsVertically) {
const travel: HorizontalTravel = centerCoordinate(to, "x") >= centerCoordinate(from, "x") ? "right" : "left"
return orthogonalPath(
boundsSidePoint(from, sideForDirection(travel)),
boundsSidePoint(to, oppositeSide(sideForDirection(travel))),
)
}
return isVerticalBackEdge(from, to, direction)
? verticalBackEdgePath(from, to, leftBoundary)
: verticalForwardEdgePath(from, to)
@@ -211,7 +222,7 @@ function targetFanInLane(
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepBefore(unclamped, targetCoordinate, travel)
return keepBefore(unclamped, advanceCoordinate(targetCoordinate, travel, -1), travel)
}
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
@@ -468,7 +479,11 @@ function routeParallelEdges(
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) }
const route: FlowchartEdgeRoute = {
edge,
points: parallelEdgePath(from, to, direction, laneCoordinate),
labelAxis: isVerticalDirection(direction) ? "y" : "x",
}
routes.push(route)
handled.add(edge)
previousRoute = route
@@ -571,59 +586,238 @@ function routeHorizontalSubgraphEntries(
}
}
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
function pathIntersectsBounds(
points: readonly FlowchartPoint[],
bounds: { left: number; top: number; width: number; height: number },
allowedContact: "source" | "target" | "both" | undefined = undefined,
): 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
}
if (from.x < bounds.left || from.x > right) continue
const overlapTop = Math.max(Math.min(from.y, to.y), bounds.top)
const overlapBottom = Math.min(Math.max(from.y, to.y), bottom)
if (overlapTop > overlapBottom) continue
const sourceContact =
(allowedContact === "source" || allowedContact === "both") &&
index === 1 &&
overlapTop === overlapBottom &&
from.x === points[0]!.x &&
overlapTop === points[0]!.y
const targetContact =
(allowedContact === "target" || allowedContact === "both") &&
index === points.length - 1 &&
overlapTop === overlapBottom &&
to.x === points.at(-1)!.x &&
overlapTop === points.at(-1)!.y
if (!sourceContact && !targetContact) 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
}
if (from.y < bounds.top || from.y > bottom) continue
const overlapLeft = Math.max(Math.min(from.x, to.x), bounds.left)
const overlapRight = Math.min(Math.max(from.x, to.x), right)
if (overlapLeft > overlapRight) continue
const sourceContact =
(allowedContact === "source" || allowedContact === "both") &&
index === 1 &&
overlapLeft === overlapRight &&
overlapLeft === points[0]!.x &&
from.y === points[0]!.y
const targetContact =
(allowedContact === "target" || allowedContact === "both") &&
index === points.length - 1 &&
overlapLeft === overlapRight &&
overlapLeft === points.at(-1)!.x &&
to.y === points.at(-1)!.y
if (!sourceContact && !targetContact) return true
}
return false
}
function labelIntersectsBounds(label: FlowchartEdgeLabelLayout | undefined, bounds: FlowchartNodeBounds): boolean {
if (!label) return false
return (
label.point.x <= bounds.left + bounds.width - 1 &&
label.point.x + label.width - 1 >= bounds.left &&
label.point.y <= bounds.top + bounds.height - 1 &&
label.point.y + label.height - 1 >= bounds.top
)
}
function labelIntersectsSubgraphFrame(
label: FlowchartEdgeLabelLayout | undefined,
bounds: FlowchartSubgraphBounds,
): boolean {
if (!label) return false
const labelRight = label.point.x + label.width - 1
const labelBottom = label.point.y + label.height - 1
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
return (
(label.point.x <= right &&
labelRight >= bounds.left &&
((label.point.y <= bounds.top && labelBottom >= bounds.top) ||
(label.point.y <= bottom && labelBottom >= bottom))) ||
(label.point.y <= bottom &&
labelBottom >= bounds.top &&
((label.point.x <= bounds.left && labelRight >= bounds.left) || (label.point.x <= right && labelRight >= right)))
)
}
function routeLength(route: FlowchartEdgeRoute): number {
let length = 0
for (let index = 1; index < route.points.length; index++) {
const from = route.points[index - 1]!
const to = route.points[index]!
length += Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
}
return length
}
function labelIntersectsLabels(
label: FlowchartEdgeLabelLayout | undefined,
otherLabels: readonly FlowchartEdgeLabelLayout[],
): boolean {
if (!label) return false
return otherLabels.some((otherLabel) => {
return label.lines.some((line, lineIndex) => {
const textLeft = label.point.x + 1
const textRight = label.point.x + diagramTextWidth(line) - 2
const y = label.point.y + lineIndex
return otherLabel.lines.some((otherLine, otherLineIndex) => {
const otherLeft = otherLabel.point.x
const otherRight = otherLeft + diagramTextWidth(otherLine) - 1
return y === otherLabel.point.y + otherLineIndex && textLeft <= otherRight && textRight >= otherLeft
})
})
})
}
function labelIntersectsLaterRoutePaths(
label: FlowchartEdgeLabelLayout | undefined,
laterRoutes: readonly FlowchartEdgeRoute[],
): boolean {
if (!label) return false
return label.lines.some((line, lineIndex) => {
const width = diagramTextWidth(line) - 2
if (width <= 0) return false
return laterRoutes.some((other) =>
pathIntersectsBounds(other.points, {
left: label.point.x + 1,
top: label.point.y + lineIndex,
width,
height: 1,
}),
)
})
}
function avoidNodeObstacles(
route: FlowchartEdgeRoute,
routes: readonly FlowchartEdgeRoute[],
bounds: Map<string, FlowchartNodeBounds>,
direction: FlowchartDirection,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
routeIndex: number,
): FlowchartEdgeRoute {
const obstacle = [...bounds.values()].some(
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
const allNodeBounds = [...bounds.values()]
const allSubgraphBounds = [...(subgraphBounds?.values() ?? [])]
const laterRoutes = routes.slice(routeIndex + 1)
const laterLabels = laterRoutes.flatMap((laterRoute) =>
laterRoute.edge.label
? [flowchartEdgeLabelLayout(laterRoute.points, laterRoute.edge.label, diagramTextWidth, laterRoute.labelAxis)]
: [],
)
if (!obstacle) return route
const intersectsObstacle = (candidate: FlowchartEdgeRoute): boolean => {
const label = candidate.edge.label
? flowchartEdgeLabelLayout(candidate.points, candidate.edge.label, diagramTextWidth, candidate.labelAxis)
: undefined
return (
allNodeBounds.some((bound) => {
const isSource = bound.id === route.edge.from
const isTarget = bound.id === route.edge.to
const allowedContact = isSource && isTarget ? "both" : isSource ? "source" : isTarget ? "target" : undefined
return pathIntersectsBounds(candidate.points, bound, allowedContact)
}) ||
allNodeBounds.some((bound) => labelIntersectsBounds(label, bound)) ||
allSubgraphBounds.some((bound) => labelIntersectsSubgraphFrame(label, bound)) ||
(subgraphBounds !== undefined &&
(labelIntersectsLabels(label, laterLabels) || labelIntersectsLaterRoutePaths(label, laterRoutes)))
)
}
if (!intersectsObstacle(route)) 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 routingBounds = [...allNodeBounds, ...allSubgraphBounds]
const rightBusX = Math.max(...routingBounds.map((bound) => bound.left + bound.width - 1)) + BUS_CLEARANCE
const leftBusX = Math.min(...routingBounds.map((bound) => bound.left)) - BUS_CLEARANCE
const topBusY = Math.min(...routingBounds.map((bound) => bound.top)) - BUS_CLEARANCE
const bottomBusY = Math.max(...routingBounds.map((bound) => bound.top + bound.height - 1)) + BUS_CLEARANCE
const start = route.points[0]!
const end = route.points.at(-1)!
const targetSide = sideForOutsidePoint(to, end)
const approach = shiftPoint(
end,
targetSide === "left" ? "left" : targetSide === "right" ? "right" : targetSide === "top" ? "up" : "down",
)
const preservedTargetCandidates: FlowchartEdgeRoute[] = [
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "y",
points: pathThrough([start, { x: leftBusX, y: start.y }, { x: leftBusX, y: approach.y }, approach, end]),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "y",
points: pathThrough([start, { x: rightBusX, y: start.y }, { x: rightBusX, y: approach.y }, approach, end]),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "x",
points: pathThrough([start, { x: start.x, y: topBusY }, { x: approach.x, y: topBusY }, approach, end]),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "x",
points: pathThrough([start, { x: start.x, y: bottomBusY }, { x: approach.x, y: bottomBusY }, approach, end]),
},
]
const candidates: FlowchartEdgeRoute[] = [
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "y",
points: pathViaLane(boundsSidePoint(from, "right"), lane("x", rightBusX), boundsSidePoint(to, "right")),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "y",
points: pathViaLane(boundsSidePoint(from, "left"), lane("x", leftBusX), boundsSidePoint(to, "left")),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "x",
points: pathViaLane(boundsSidePoint(from, "top"), lane("y", topBusY), boundsSidePoint(to, "top")),
},
{
...route,
labelAxis: route.labelAxis === undefined ? undefined : "x",
points: pathViaLane(boundsSidePoint(from, "bottom"), lane("y", bottomBusY), boundsSidePoint(to, "bottom")),
},
]
const shortestValid = (candidateRoutes: FlowchartEdgeRoute[]): FlowchartEdgeRoute | undefined =>
candidateRoutes
.filter((candidate) => !intersectsObstacle(candidate))
.sort((left, right) => routeLength(left) - routeLength(right))[0]
if (subgraphBounds) {
return shortestValid(preservedTargetCandidates) ?? shortestValid(candidates) ?? route
}
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) }
return (
candidates.find((candidate) => !intersectsObstacle(candidate)) ?? shortestValid(preservedTargetCandidates) ?? route
)
}
export function routeFlowchartEdges(
@@ -671,7 +865,10 @@ export function routeFlowchartEdges(
if (!from || !to) continue
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
}
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
for (let index = routes.length - 1; index >= 0; index--) {
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
}
return routes
}
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
+3 -1
View File
@@ -1,4 +1,4 @@
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
import type { DiagramAxis, DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
@@ -16,6 +16,7 @@ export interface FlowchartEdge {
label: string
style?: FlowchartEdgeStyle
arrowhead?: false
sourceArrowhead?: true
orderOnly?: boolean
}
@@ -55,6 +56,7 @@ export type FlowchartPoint = DiagramPoint
export interface FlowchartEdgeRoute {
edge: FlowchartEdge
points: FlowchartPoint[]
labelAxis?: DiagramAxis
}
export type FlowchartEdgeDirection = DiagramDirection
+8 -4
View File
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput
warning?: ColorInput
background?: ColorInput
request?: ColorInput
response?: ColorInput
note?: ColorInput
noteBackground?: ColorInput
}
}
@@ -131,12 +135,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
participant: color(colors.primary),
lifeline: color(colors.muted),
group: color(colors.secondary),
request: color(colors.primary),
response: color(colors.primary),
request: color(colors.request ?? colors.primary),
response: color(colors.response ?? colors.primary),
fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background),
note: color(colors.warning),
noteBg: color(colors.background),
note: color(colors.note ?? colors.warning),
noteBg: color(colors.noteBackground ?? colors.background),
}),
),
height: size.height,
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { blendColor } from "./core/color/style.js"
import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number]
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220)
const success = RGBA.fromInts(80, 180, 120)
const warning = RGBA.fromInts(220, 160, 80)
const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({
text: primary,
subdued: rgb(subdued),
info,
success,
warning,
background,
})
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info)
expect(palette.background).toBe(background)
expect(palette.request).toBe(success)
expect(palette.response).toBe(warning)
expect(palette.note).toBe(primary)
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
})
})
+6
View File
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA
readonly subdued: RGBA
readonly info: RGBA
readonly success: RGBA
readonly warning: RGBA
readonly background: RGBA
}
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info,
background: input.background,
request: input.success,
response: input.warning,
note: input.text,
noteBackground: blendColor(input.background, input.subdued, 0.25),
}
}
+2
View File
@@ -12,6 +12,8 @@ export default Plugin.define({
text: context.theme.text.default,
subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default,
success: context.theme.text.feedback.success.default,
warning: context.theme.text.feedback.warning.default,
background: context.theme.background.default,
}),
})),
+141 -72
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { diagramTextWidth } from "../core/text.js"
import { expectDiagram } from "../test/diagram.js"
import { renderSequenceDiagram } from "./diagram.js"
import { drawSequenceDiagramGrid } from "./drawing.js"
@@ -28,6 +29,18 @@ sequenceDiagram
])
})
test("decodes HTML entities in participant, message, and note labels", () => {
const diagram = parseMermaidSequenceDiagram(`sequenceDiagram
participant A as Worker &amp; signer
participant B
A->>B: ack &lt;3s
Note over A,B: result &#8805; 1`)
expect(diagram.participants[0]?.label).toBe("Worker & signer")
expect(diagram.messages[0]?.label).toBe("ack <3s")
expect(diagram.steps.find((step) => step.type === "note")?.note.label).toBe("result ≥ 1")
})
test("renders a terminal sequence diagram", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
@@ -38,16 +51,15 @@ sequenceDiagram
`)
expectDiagram(output).toEqualDiagram(`
Browser Server
GET /
401 WWW-Auth
Browser Server
GET /
401 WWW-Auth
`)
})
@@ -70,15 +82,15 @@ sequenceDiagram
expectDiagram(output).toEqualDiagram(`
leaf tool LocationMutation FileMutation
resolve(path)
resolve(path)
Plan(target, authority anchor)
Plan(target, authority anchor)
commit(plan)
commit(plan)
revalidate(plan)
revalidate(plan)
same target or reject
same target or reject
`)
})
@@ -109,7 +121,7 @@ sequenceDiagram
const lines = output.split("\n")
expect(lines.findIndex((line) => line.includes("deliberately"))).toBeLessThan(
lines.findIndex((line) => line.includes("")),
lines.findIndex((line) => line.includes("")),
)
})
@@ -127,13 +139,13 @@ sequenceDiagram
`)
const lines = output.split("\n")
const browserCenter = lines[1]!.indexOf("w")
const serverCenter = lines[1]!.indexOf("v")
const browserCenter = lines[0]!.indexOf("w")
const serverCenter = lines[0]!.indexOf("v")
expect(lines[2]?.[browserCenter]).toBe("┬")
expect(lines[3]?.[browserCenter]).toBe("│")
expect(lines[2]?.[serverCenter]).toBe("┬")
expect(lines[3]?.[serverCenter]).toBe("│")
expect(lines[1]?.[browserCenter]).toBe("┬")
expect(lines[2]?.[browserCenter]).toBe("│")
expect(lines[1]?.[serverCenter]).toBe("┬")
expect(lines[2]?.[serverCenter]).toBe("│")
})
test("ramps participant frames into neutral lifelines", () => {
@@ -245,18 +257,29 @@ sequenceDiagram
])
})
test("parses activation syntax without rendering activation bars", () => {
test("renders activation syntax as visible intervals", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
Server-->>-Browser: response
`)
expect(output).not.toContain("┃")
expect(output).toContain("┃")
expect(output).toContain("request")
expect(output).toContain("response")
})
test("renders br-delimited participant aliases on separate lines", () => {
const output = renderSequenceDiagram(`sequenceDiagram
participant A as First line<br/>Second line
participant B as Normal
A->>B: hello`)
expect(output).not.toContain("<br")
expect(output).toContain("First line")
expect(output).toContain("Second line")
})
test("parses Mermaid arrow head variants", () => {
const diagram = parseMermaidSequenceDiagram(`
sequenceDiagram
@@ -290,28 +313,27 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"
A B
open solid
>
open dashed
<
failed solid
failed dashed
async solid
)
async dashed
(
"
" A B
open solid
>
open dashed
<
failed solid
failed dashed
async solid
)
async dashed
(
"
`)
})
@@ -363,7 +385,7 @@ sequenceDiagram
end
`)
const lines = output.split("\n")
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
expect(fragmentStart).toBeLessThan(participantCenter)
@@ -576,11 +598,47 @@ sequenceDiagram
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("")
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("")
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
})
test("keeps adjacent wide participant group frames separate", () => {
const output = renderSequenceDiagram(
`sequenceDiagram
box First very wide group heading
participant A
end
box Second very wide group heading
participant B
end
A->>B: hi`,
{ compact: true },
)
const topRow = output.split("\n")[0]!
expect(topRow).toContain("First very wide group heading")
expect(topRow).toContain("Second very wide group heading")
expect(topRow.indexOf("╮")).toBeLessThan(topRow.lastIndexOf("╭"))
})
test("renders many adjacent wide participant groups without excessive canvas growth", () => {
const groupCount = 16
const output = renderSequenceDiagram(
`sequenceDiagram
${Array.from(
{ length: groupCount },
(_, index) => ` box Group ${index} has a deliberately wide heading
participant P${index}
end`,
).join("\n")}
P0->>P15: hi`,
{ compact: true },
)
expect(Math.max(...output.split("\n").map(diagramTextWidth))).toBeLessThan(groupCount * 60)
})
test("renders full-height participant group boxes", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
@@ -595,18 +653,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
" Backend
Browser API Cache DB
GET /users/42
get user:42
"
" Backend
Browser API Cache DB
GET /users/42
get user:42
"
`)
})
@@ -619,12 +676,25 @@ sequenceDiagram
end
Browser->>API: GET /users/42
`)
const arrowLine = output.split("\n").find((line) => line.includes(""))!
const arrowLine = output.split("\n").find((line) => line.includes(""))!
expect(arrowLine).toContain("───────────────")
expect(arrowLine).toContain("───────────────")
expect(arrowLine).not.toContain("┼")
})
test("keeps filled arrowheads to one terminal column", () => {
const output = renderSequenceDiagram(`sequenceDiagram
box Backend
participant A
participant B
A->>B: request
end`)
const lines = output.split("\n")
const frameWidth = diagramTextWidth(lines.at(-1)!)
expect(Math.max(...lines.map(diagramTextWidth))).toBe(frameWidth)
})
test("renders self messages as loopback arrows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
@@ -633,18 +703,17 @@ sequenceDiagram
`)
expect(output).toMatchInlineSnapshot(`
"
Service
Check Permissions
"
"Service
Check Permissions
"
`)
})
test("places two spacer rows above note badges and one below", () => {
test("renders note badges in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -657,7 +726,7 @@ sequenceDiagram
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
expect(lines[noteRow]).toContain(" phase ")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
+28 -15
View File
@@ -1,5 +1,6 @@
import { BorderChars, type BorderStyle } from "@opentui/core"
import { DiagramCanvas } from "../core/canvas.js"
import { diagramTextWidth } from "../core/text.js"
import { DEFAULT_FRAGMENT_BORDER_STYLE } from "./options.js"
import {
createSequencePlacementPlan,
@@ -19,6 +20,10 @@ import type {
const SEQUENCE_BORDER = BorderChars.rounded
function centeredStart(center: number, text: string): number {
return center - Math.floor(diagramTextWidth(text) / 2)
}
function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1): string {
switch (head) {
case "open":
@@ -28,7 +33,7 @@ function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1):
case "async":
return direction === 1 ? ")" : "("
default:
return direction === 1 ? "" : ""
return direction === 1 ? "" : ""
}
}
@@ -185,6 +190,12 @@ function renderSelfMessage(
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
}
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
placement.textLines.forEach((line, index) =>
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
)
}
export function drawSequenceDiagramGrid(
diagram: SequenceDiagram,
options: SequenceDiagramRenderOptions = {},
@@ -197,24 +208,20 @@ export function drawSequenceDiagramGrid(
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 { centerX: center, headerLeftX, headerRightX, labelLines } = placement
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
if (options.compact) {
setText(grid, labelX, participantHeaderY, participant.label, "participant")
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
} else {
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
)
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")
}
@@ -227,9 +234,7 @@ export function drawSequenceDiagramGrid(
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")
}
renderNote(grid, placement)
continue
}
@@ -270,5 +275,13 @@ export function drawSequenceDiagramGrid(
if (placement.inlineLabel) setText(grid, placement.labelX, placement.labelY, placement.inlineLabel, messageStyle)
}
for (const activation of plan.activations) {
for (let y = activation.startY; y <= activation.endY; y++) {
if (grid.getCell(activation.centerX, y)?.char === SEQUENCE_BORDER.vertical) {
setCell(grid, activation.centerX, y, "┃", "lifeline")
}
}
}
return grid
}
+4
View File
@@ -22,6 +22,7 @@ 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 UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE = /<<-{1,2}>>/
const CSS_COLOR_NAMES = new Set([
"black",
"white",
@@ -132,6 +133,9 @@ export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
for (const source of meaningfulNumberedMermaidLines(content)) {
const line = source.text
if (line.toLowerCase() === "sequencediagram") continue
if (UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE.test(line)) {
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
}
const autonumberMatch = line.match(AUTONUMBER_RE)
if (autonumberMatch) {
@@ -90,6 +90,25 @@ describe("createSequencePlacementPlan", () => {
expect(external.headerLeftX).toBeGreaterThan(group.rightX)
})
test("keeps many adjacent wide groups at a linear width", () => {
const groupCount = 16
const source = `sequenceDiagram
${Array.from(
{ length: groupCount },
(_, index) => ` box Group ${index} has a deliberately wide heading
participant P${index}
end`,
).join("\n")}
P0->>P15: hi`
const plan = createSequencePlacementPlan(parseMermaidSequenceDiagram(source), { compact: true })
expect(plan.groups).toHaveLength(groupCount)
for (let index = 1; index < plan.groups.length; index++) {
expect(plan.groups[index]!.leftX).toBeGreaterThan(plan.groups[index - 1]!.rightX)
}
expect(plan.width).toBeLessThan(groupCount * 60)
})
test("expands group and fragment frames around contained long content", () => {
const groupPlan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
@@ -169,4 +188,32 @@ describe("createSequencePlacementPlan", () => {
expect(starts[0]!.bounds.rightX).toBeGreaterThan(starts[1]!.bounds.rightX)
})
test("aligns explicit and shorthand activation intervals to message events", () => {
const shorthand = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
A->>+B: request
B-->>-A: response`),
)
const explicit = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
A->>B: request
activate B
B-->>A: response
deactivate B`),
)
expect(explicit.activations).toEqual(shorthand.activations)
})
test("left-aligns message label blocks inside their arrow span", () => {
const plan = createSequencePlacementPlan(
parseMermaidSequenceDiagram(`sequenceDiagram
participant A
participant B
A->>B: short<br/>a much longer line`),
)
const message = plan.steps.find((step) => step.type === "message")!
expect(message.labelX).toBe(message.leftX + 2)
})
})
+93 -38
View File
@@ -11,7 +11,7 @@ import type {
SequenceStep,
} from "./types.js"
const NOTE_HORIZONTAL_PADDING = 1
const NOTE_HORIZONTAL_PADDING = 2
const GROUP_HORIZONTAL_PADDING = 2
const FRAGMENT_HORIZONTAL_OVERHANG = 3
@@ -25,7 +25,7 @@ export interface SequenceParticipantPlacement {
centerX: number
headerLeftX: number
headerRightX: number
labelX: number
labelLines: string[]
}
export interface SequenceGroupPlacement {
@@ -41,6 +41,14 @@ export interface SequenceWallPlacement {
endY: number
}
export interface SequenceActivationPlacement {
participant: string
centerX: number
startY: number
endY: number
depth: number
}
export type SequenceStepPlacement =
| { type: "note"; note: SequenceNote; textLines: string[]; textX: number; textY: number }
| {
@@ -88,6 +96,7 @@ export interface SequencePlacementPlan {
}
participants: SequenceParticipantPlacement[]
groups: SequenceGroupPlacement[]
activations: SequenceActivationPlacement[]
steps: SequenceStepPlacement[]
}
@@ -132,7 +141,8 @@ function messageLabelText(message: SequenceMessage): string {
}
function participantHeaderWidth(label: string, compact: boolean): number {
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
const width = labelLinesWidth(mermaidLabelLines(label))
return compact ? width : Math.max(3, width)
}
function fragmentLabelText(fragment: SequenceFragment): string {
@@ -236,7 +246,9 @@ function getStepContentBounds(
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) }
const labelWidth = messageWidth(step.message)
const labelLeftX = leftX + 2
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
}
if (step.type !== "note") return undefined
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
@@ -387,7 +399,9 @@ function resolveParticipantCenters(
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,
selfMessageLoopWidth(message) +
Math.ceil(labelLinesWidth(mermaidLabelLines(diagram.participants[fromIndex + 1]!.label)) / 2) +
2,
)
continue
}
@@ -423,37 +437,31 @@ function separateExpandedGroupsFromExternalParticipants(
compact: boolean,
): number[] {
const adjusted = [...centers]
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
let changed = false
for (let boundary = 0; boundary < adjusted.length - 1; boundary++) {
const groups = resolveGroupBounds(diagram, adjusted, participantIndexes, ranges, compact)
const leftWidth = participantHeaderWidth(diagram.participants[boundary]!.label, compact)
const rightWidth = participantHeaderWidth(diagram.participants[boundary + 1]!.label, compact)
let leftRight = adjusted[boundary]! - Math.floor(leftWidth / 2) + leftWidth - 1
let rightLeft = adjusted[boundary + 1]! - Math.floor(rightWidth / 2)
let bordersGroup = false
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 === boundary) {
leftRight = Math.max(leftRight, groups[index]!.rightX)
bordersGroup = 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 (range.startIndex === boundary + 1) {
rightLeft = Math.min(rightLeft, groups[index]!.leftX)
bordersGroup = true
}
}
if (!changed) return adjusted
if (!bordersGroup) continue
const shift = leftRight + GROUP_HORIZONTAL_PADDING + 1 - rightLeft
if (shift <= 0) continue
for (let participantIndex = boundary + 1; participantIndex < adjusted.length; participantIndex++) {
adjusted[participantIndex]! += shift
}
}
return adjusted
}
@@ -475,6 +483,7 @@ export function createSequencePlacementPlan(
},
participants: [],
groups: [],
activations: [],
steps: [],
}
}
@@ -511,9 +520,13 @@ export function createSequencePlacementPlan(
fragments = fragmentBounds()
}
const hasGroups = groups.length > 0
const participantLabelHeight = Math.max(
1,
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
)
const participantHeaderTopY = hasGroups ? 1 : 0
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
const participantHeaderY = participantHeaderTopY
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
@@ -525,19 +538,46 @@ export function createSequencePlacementPlan(
const centerX = centers[index]!
const width = participantHeaderWidth(participant.label, compact)
const headerLeftX = centerX - Math.floor(width / 2)
const labelLines = mermaidLabelLines(participant.label)
return {
participant,
centerX,
headerLeftX,
headerRightX: headerLeftX + width - 1,
labelX: centeredStart(centerX, participant.label),
labelLines,
}
})
const steps: SequenceStepPlacement[] = []
const activations: SequenceActivationPlacement[] = []
const activeByParticipant = new Map<string, Array<{ startY: number; depth: number }>>()
const lastEventYByParticipant = new Map<string, number>()
const openActivation = (participant: string, y: number): void => {
const active = activeByParticipant.get(participant) ?? []
active.push({ startY: y, depth: active.length })
activeByParticipant.set(participant, active)
}
const closeActivation = (participant: string, y: number): void => {
const active = activeByParticipant.get(participant)
const opened = active?.pop()
const participantIndex = indexes.get(participant)
if (!opened || participantIndex === undefined) return
activations.push({
participant,
centerX: centers[participantIndex]!,
startY: opened.startY,
endY: y,
depth: opened.depth,
})
}
let stepY = stepStartY
const activeFrames: ActiveFragmentFrame[] = []
for (const [stepIndex, step] of diagram.steps.entries()) {
if (step.type === "activation") continue
if (step.type === "activation") {
const eventY = Math.min(lastEventYByParticipant.get(step.activation.participant) ?? stepY, lifelineEndY)
if (step.activation.active) openActivation(step.activation.participant, eventY)
else closeActivation(step.activation.participant, eventY)
continue
}
const stepHeight = getStepHeight(step, centers, indexes, compact)
if (step.type === "note") {
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
@@ -588,6 +628,7 @@ export function createSequencePlacementPlan(
const labelLines = messageLabelLines(messageLabelText(step.message))
if (fromIndex === toIndex) {
const centerX = centers[fromIndex]!
const bottomY = stepY + labelLines.length + 1
steps.push({
type: "selfMessage",
message: step.message,
@@ -595,8 +636,11 @@ export function createSequencePlacementPlan(
centerX,
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
topY: stepY,
bottomY: stepY + labelLines.length + 1,
bottomY,
})
if (step.message.activate) openActivation(step.message.activate, bottomY)
if (step.message.deactivate) closeActivation(step.message.deactivate, bottomY)
lastEventYByParticipant.set(step.message.from, bottomY)
} else {
const fromX = centers[fromIndex]!
const toX = centers[toIndex]!
@@ -604,13 +648,16 @@ export function createSequencePlacementPlan(
const leftX = Math.min(fromX, toX)
const rightX = Math.max(fromX, toX)
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
steps.push({
type: "message",
message: step.message,
labelLines,
labelX: leftX + 2,
labelX,
labelY: stepY,
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
arrowY,
fromX,
toX,
leftX,
@@ -619,15 +666,23 @@ export function createSequencePlacementPlan(
headX: arrowHeadX(toX, direction, step.message.head),
inlineLabel,
})
if (step.message.activate) openActivation(step.message.activate, arrowY)
if (step.message.deactivate) closeActivation(step.message.deactivate, arrowY)
lastEventYByParticipant.set(step.message.from, arrowY)
lastEventYByParticipant.set(step.message.to, arrowY)
}
stepY += stepHeight
}
for (const [participant, active] of activeByParticipant) {
while (active.length > 0) closeActivation(participant, lifelineEndY)
}
return {
width,
height,
rows: { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY },
participants,
groups,
activations,
steps,
}
}
+194 -8
View File
@@ -47,6 +47,17 @@ stateDiagram-v2
})
})
test("decodes HTML entities in state, transition, and note labels", () => {
const diagram = parseMermaidStateDiagram(`stateDiagram-v2
state "Ready &amp; waiting" as Ready
Ready --> Done: elapsed &lt;3s
note right of Done: result &#x2265; 1`)
expect(diagram.states.find((state) => state.id === "Ready")?.label).toBe("Ready & waiting")
expect(diagram.transitions[0]?.label).toBe("elapsed <3s")
expect(diagram.notes[0]?.lines).toEqual(["result ≥ 1"])
})
test("parses choice pseudo-states", () => {
const diagram = parseMermaidStateDiagram(`
stateDiagram-v2
@@ -55,7 +66,7 @@ stateDiagram-v2
Decision --> Accepted: yes
`)
expect(diagram.states).toContainEqual({ id: "Decision", label: "", kind: "choice" })
expect(diagram.states).toContainEqual({ id: "Decision", label: "", kind: "choice" })
})
test("parses composite states and notes", () => {
@@ -188,7 +199,7 @@ stateDiagram-v2
Running
💥 sandbox dies BEFORE hook fires
(crash, our bug, race)
Dormant Lost
@@ -384,7 +395,7 @@ stateDiagram-v2
expect(output).toMatchInlineSnapshot(`
" submit ok
Editing Saved
Editing Saved
type fail
@@ -411,7 +422,7 @@ stateDiagram-v2
Decision --> Done
Done --> [*]`)
expect(output).toContain("Upper ├─────────────┬────────────▶│ Done")
expect(output).toContain("Upper ├────────────▶◆────────────▶│ Done")
})
test("renders self transitions as loops in vertical diagrams", () => {
@@ -444,6 +455,65 @@ stateDiagram-v2
expect(vertical).toContain("second")
})
test("separates labels on four parallel vertical transitions", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
A --> B: one
A --> B: two
A --> B: three
A --> B: four`)
expect(output).not.toContain("twothree")
for (const label of ["one", "two", "three", "four"]) {
expect(output.match(new RegExp(label, "g"))).toHaveLength(1)
}
})
test("keeps explicit choices visible in choice-only cycles", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
state One <<choice>>
state Two <<choice>>
state Three <<choice>>
One --> Two: clockwise
Two --> Three: clockwise
Three --> One: clockwise`)
expect(output.match(/◆/g)).toHaveLength(3)
})
test("routes dense horizontal transitions around unrelated states", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction LR
A --> B: ab
A --> C: ac
A --> D: ad
B --> A: ba
B --> C: bc
B --> D: bd
C --> A: ca
C --> B: cb
C --> D: cd
D --> A: da
D --> B: db
D --> C: dc`)
for (const state of ["A", "B", "C", "D"]) expect(output.match(new RegExp(state, "g"))).toHaveLength(1)
})
test("routes parallel transitions around vertically offset states", () => {
const output = renderStateDiagram(`stateDiagram-v2
A --> B: first<br/>line two
A --> B: second<br/>another line
B --> A: return<br/>with details`)
expect(output).toContain(" A ")
expect(output).toContain("│ B │")
expect(output).toContain("first")
expect(output).toContain("second")
expect(output).toContain("return")
})
test("keeps independent overlapping feedback labels and paths distinct", () => {
const content = (direction: "LR" | "RL") => `stateDiagram-v2
direction ${direction}
@@ -561,15 +631,46 @@ stateDiagram-v2
})
expect(output).toMatchInlineSnapshot(`
" Authenticated
login open save
Idle Editing
save
login open logout
Idle Editing
"
`)
})
test("keeps nested composite entry and exit routes within the outer frame height", () => {
const output = renderStateDiagram(`stateDiagram-v2
state Session {
[*] --> Open
state Open {
[*] --> Clean
Clean --> Dirty: edit
Dirty --> Clean: save
}
note right of Open: document lifecycle
Open --> [*]: close
}
[*] --> Session
Session --> [*]`)
const lines = output.split("\n")
const outerFrameTop = lines.find((line) => line.includes("Session"))!
const frameLeft = outerFrameTop.indexOf("╭")
const frameRight = outerFrameTop.lastIndexOf("╮")
const outerFrameBottom = lines.findIndex((line) => line[frameLeft] === "╰" && line[frameRight] === "╯")
const startColumn = lines.find((line) => line.includes("●"))!.indexOf("●")
const endColumn = lines.find((line) => line.includes("◎"))!.indexOf("◎")
expect(outerFrameBottom).toBeGreaterThan(0)
expect(startColumn).toBeLessThan(frameLeft)
expect(endColumn).toBeGreaterThan(frameRight)
expect(lines.slice(outerFrameBottom + 1).every((line) => line.trim() === "")).toBe(true)
expect(output).toContain("Open")
expect(output).toContain("document lifecycle")
expect(output).toContain("close")
})
test("renders notes attached to states", () => {
const output = renderStateDiagram(`
stateDiagram-v2
@@ -600,6 +701,91 @@ stateDiagram-v2
state Decision <<choice>>
Decision --> [*]`)
expect(output).toContain("╰─────────────┬\n")
expect(output).toContain("╰─────────────")
expect(output).toContain("◆────────────▶◎")
})
test("keeps vertical branch labels from overwriting state labels", () => {
const output = renderStateDiagram(`stateDiagram-v2
direction TB
state "Branch root" as Root
state "Upper branch" as Upper
state "Lower branch" as Lower
state "Merged branch" as Merge
Root --> Upper: branch-up
Root --> Lower: branch-down
Upper --> Merge: merge-up
Lower --> Merge: merge-down
Merge --> Root: branch-feedback`)
for (const text of [
"Branch root",
"Upper branch",
"Lower branch",
"Merged branch",
"branch-up",
"branch-down",
"merge-up",
"merge-down",
"branch-feedback",
]) {
expect(output).toContain(text)
}
})
test("keeps lifecycle states intact around branches and feedback", () => {
const output = renderStateDiagram(`stateDiagram-v2
[*] --> Idle
Idle --> MailboxPending: enqueue + setAlarm
MailboxPending --> PromptSubmitted: drain mailbox
PromptSubmitted --> Polling: prompt admitted
Polling --> Polling: execution still active
Polling --> Completed: terminal log event
Polling --> Polling: retry after transient failure
Completed --> Idle: final Slack projection
Idle --> Expired: 30 days inactive
Expired --> [*]: delete SQLite state`)
for (const state of ["Idle", "MailboxPending", "PromptSubmitted", "Polling", "Completed", "Expired"]) {
expect(output.match(new RegExp(state, "g"))).toHaveLength(1)
}
})
test("keeps composite titles intact under reciprocal composite routes", () => {
const source = `stateDiagram-v2
direction LR
state FirstGroup {
[*] --> FirstInner
FirstInner --> [*]: first-out
}
state SecondGroup {
[*] --> SecondInner
SecondInner --> [*]: second-out
}
FirstGroup --> SecondGroup: group-next
SecondGroup --> FirstGroup: group-back`
for (const direction of ["LR", "TB"] as const) {
const lines = renderStateDiagram(source, { direction }).split("\n")
for (const title of ["FirstGroup", "SecondGroup"]) {
const top = lines.findIndex((line) => line.includes(title))
const left = lines[top]!.lastIndexOf("╭", lines[top]!.indexOf(title))
const right = lines[top]!.indexOf("╮", left)
const bottom = lines.findIndex((line, index) => index > top && line[left] === "╰" && line[right] === "╯")
expect(top).toBeGreaterThanOrEqual(0)
expect(left).toBeGreaterThanOrEqual(0)
expect(right).toBeGreaterThan(left)
expect(bottom).toBeGreaterThan(top)
expect(
lines.slice(top + 1, bottom).every((line) => "│├┤┼".includes(line[left]!) && "│├┤┼".includes(line[right]!)),
).toBe(true)
expect(
lines[bottom]!.slice(left + 1, right)
.split("")
.every((char) => "─┬┴┼".includes(char)),
).toBe(true)
}
}
})
})
+5 -2
View File
@@ -49,7 +49,9 @@ function translateTransitionPlans(
function makeGrid(width: number, height: number): StateGrid {
return new DiagramCanvas(width, height, {
mergeCell: (existing, incoming): StateCell => {
const shouldMerge = existing.style === "transition" && incoming.style === "transition"
const existingIsTransition = existing.style === "transition" || existing.style?.startsWith("stateDepartureRamp")
const incomingIsTransition = incoming.style === "transition" || incoming.style?.startsWith("stateDepartureRamp")
const shouldMerge = incomingIsTransition && (existingIsTransition || existing.style === "composite")
return {
...incoming,
char: shouldMerge
@@ -198,7 +200,8 @@ function drawTransitionJunctionPlans(
): 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)
const char = plan.kind === "choice" ? "◆" : diagramLineGlyph(plan.connections, "rounded")
setCell(grid, plan.bounds.left, plan.bounds.top, char, style)
}
}
+39 -21
View File
@@ -40,14 +40,6 @@ 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[]>()
@@ -88,8 +80,11 @@ function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTra
return outgoing
}
function reaches(diagram: StateDiagram, from: string, target: string): boolean {
const outgoing = outgoingTransitions(diagram)
function reaches(
outgoing: ReadonlyMap<string, readonly StateDiagramTransition[]>,
from: string,
target: string,
): boolean {
const visited = new Set<string>()
const stack = [from]
while (stack.length > 0) {
@@ -104,6 +99,7 @@ function reaches(diagram: StateDiagram, from: string, target: string): boolean {
function computeMainPath(diagram: StateDiagram): string[] {
const outgoing = outgoingTransitions(diagram)
const statesById = new Map(diagram.states.map((state) => [state.id, state]))
const start = diagram.states.find((state) => state.kind === "start")?.id ?? diagram.states[0]?.id
if (!start) return []
@@ -114,9 +110,14 @@ function computeMainPath(diagram: StateDiagram): string[] {
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))
candidates.find((transition) => statesById.get(transition.to)?.kind === "end") ??
candidates.find((transition) => !reaches(outgoing, transition.to, current)) ??
candidates.find((transition) => !hasReverseTransition(diagram, transition)) ??
candidates.find((transition) => {
const fromParent = statesById.get(current)?.parentId
const toParent = statesById.get(transition.to)?.parentId
return Boolean(fromParent && toParent && fromParent !== toParent)
})
if (!next) break
path.push(next.to)
visited.add(next.to)
@@ -132,7 +133,7 @@ function stateSize(state: StateDiagramState): { width: number; height: number; l
}
function noteLines(note: StateDiagramNote): string[] {
const lines = note.lines.flatMap(splitStateDiagramLines).map((line) => line.trim())
const lines = note.lines.flatMap(splitDiagramLines).map((line) => line.trim())
return lines.length > 0 ? lines : [""]
}
@@ -202,7 +203,7 @@ function addCompositeBounds(diagram: StateDiagram, layout: StateDiagramLayout):
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 width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
const bound = {
id: composite.id,
left,
@@ -349,7 +350,7 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
bound.left = left
bound.top = top
bound.width = Math.max(right - left, visualLength(composite.label) + 5)
bound.width = Math.max(right - left, diagramTextWidth(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)
@@ -461,7 +462,8 @@ export function createStateDiagramLayout(
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)
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
}
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
@@ -499,7 +501,10 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo
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 crossesCompositeBoundary = Boolean(
nextId && statesById.get(id)?.parentId !== statesById.get(nextId)?.parentId,
)
x += size.width + Math.max(defaultGap, adjacentLabelWidth + (crossesCompositeBoundary ? 6 : 2))
}
const branchesByParent = new Map<string, string[]>()
@@ -550,12 +555,25 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo
}
const ranks = computeRanks(diagram)
const fallbackStates = diagram.states.filter((state) => !bounds.has(state.id))
const fallbackStates = diagram.states
.filter((state) => !bounds.has(state.id))
.sort((left, right) => (ranks.get(left.id) ?? 0) - (ranks.get(right.id) ?? 0))
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)
const rank = ranks.get(state.id) ?? bounds.size
let left = rank * (size.width + defaultGap)
while (true) {
const collision = [...bounds.values()].find(
(bound) =>
left < bound.left + bound.width + defaultGap &&
left + size.width + defaultGap > bound.left &&
top < bound.top + bound.height &&
top + size.height > bound.top,
)
if (!collision) break
left = collision.left + collision.width + defaultGap
}
bounds.set(state.id, {
id: state.id,
left,
+10 -7
View File
@@ -1,4 +1,4 @@
import { firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
import { decodeMermaidText, 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"
@@ -96,7 +96,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
notes.push({
target: pendingNote.target,
position: pendingNote.position,
lines: pendingNote.lines,
lines: pendingNote.lines.map(decodeMermaidText),
})
pendingNote = undefined
} else if (line || pendingNote.lines.length > 0) {
@@ -119,6 +119,9 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
const directionMatch = line.match(DIRECTION_RE)
if (directionMatch) {
if (parentStack.length > 0) {
throw new MermaidSyntaxError("state", source.lineNumber, line, "Composite-local direction is not supported")
}
direction = normalizeDirection(directionMatch[1])
continue
}
@@ -128,7 +131,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
notes.push({
position: inlineNoteMatch[1]!.toLowerCase() as "left" | "right",
target: inlineNoteMatch[2]!,
lines: splitDiagramLines(inlineNoteMatch[3]!.trim()),
lines: splitDiagramLines(decodeMermaidText(inlineNoteMatch[3]!.trim())),
})
continue
}
@@ -150,7 +153,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
const id = compositeMatch[2]!
composites.push({
id,
label: compositeMatch[1] ?? id,
label: decodeMermaidText(compositeMatch[1] ?? id),
...(parentId ? { parentId } : {}),
})
parentStack.push({ id, lineNumber: source.lineNumber, sourceLine: line })
@@ -159,13 +162,13 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
const stateMatch = line.match(STATE_RE)
if (stateMatch) {
ensureState(states, stateMatch[2]!, stateMatch[1]!, "state", parentId)
ensureState(states, stateMatch[2]!, decodeMermaidText(stateMatch[1]!), "state", parentId)
continue
}
const choiceMatch = line.match(CHOICE_STATE_RE)
if (choiceMatch) {
ensureState(states, choiceMatch[1]!, "", "choice", parentId)
ensureState(states, choiceMatch[1]!, "", "choice", parentId)
continue
}
@@ -177,7 +180,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
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() ?? "" })
transitions.push({ from, to, label: decodeMermaidText(transitionMatch[3]?.trim() ?? "") })
continue
}
+37 -1
View File
@@ -1,11 +1,14 @@
import { describe, expect, test } from "bun:test"
import type { StateDiagramBoxBounds } from "./layout.js"
import { createStateDiagramLayout } from "./layout.js"
import { parseMermaidStateDiagram } from "./parser.js"
import {
createStateTransitionJunctionPlans,
createStateTransitionRenderPlans,
createStateTransitionRoutePlans,
} from "./routing.js"
import { prepareVisibleStateDiagram, type StateVisibleDiagram } from "./visible-model.js"
import type { StateVisibleDiagram } from "./visible-model.js"
import { prepareVisibleStateDiagram } 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 }
@@ -207,6 +210,39 @@ describe("createStateTransitionRenderPlans", () => {
[11, 4],
])
})
test("keeps vertical branch routes out of unrelated state bounds", () => {
const diagram = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
direction TB
state "Branch root" as Root
state "Upper branch" as Upper
state "Lower branch" as Lower
state "Merged branch" as Merge
Root --> Upper: branch-up
Root --> Lower: branch-down
Upper --> Merge: merge-up
Lower --> Merge: merge-down
Merge --> Root: branch-feedback`),
)
const layout = createStateDiagramLayout(diagram, { minStateGap: 4 })
const plans = createStateTransitionRenderPlans(diagram, layout.bounds, 30)
for (const plan of plans) {
const unrelated = diagram.states
.filter((state) => state.id !== plan.route.transition.from && state.id !== plan.route.transition.to)
.map((state) => layout.bounds.get(state.id)!)
expect(
plan.path.some(([x, y]) =>
unrelated.some(
(bound) =>
x >= bound.left && x < bound.left + bound.width && y >= bound.top && y < bound.top + bound.height,
),
),
`${plan.route.transition.from} -> ${plan.route.transition.to}`,
).toBe(false)
}
})
})
describe("createStateTransitionJunctionPlans", () => {
+270 -95
View File
@@ -1,5 +1,6 @@
import { BorderChars } from "@opentui/core"
import type { DiagramDirection } from "../core/geometry.js"
import { SpatialIndex, spatialPathClaim, spatialRectClaim } from "../core/spatial.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import type { StateDiagramBoxBounds as BoxBounds } from "./layout.js"
import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
@@ -10,14 +11,15 @@ interface StateTransitionRoutePlanBase {
from: BoxBounds
to: BoxBounds
targetIsChoice: boolean
targetIsHiddenMarker: boolean
}
export type StateTransitionRoutePlan =
| (StateTransitionRoutePlanBase & { kind: "self" })
| (StateTransitionRoutePlanBase & { kind: "horizontal-forward"; leftToRight: boolean })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
| (StateTransitionRoutePlanBase & { kind: "vertical" })
@@ -192,6 +194,93 @@ function hasOpposingTopConnector(
})
}
function verticalCorridorCrossesUnrelatedState(
diagram: StateVisibleDiagram,
transition: StateVisibleTransition,
from: BoxBounds,
to: BoxBounds,
bounds: ReadonlyMap<string, BoxBounds>,
): boolean {
const top = Math.min(from.top + from.height, to.top + to.height)
const bottom = Math.max(from.top - 1, to.top - 1)
return diagram.states.some((state) => {
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
const bound = bounds.get(state.id)
return Boolean(
bound &&
from.centerX >= bound.left &&
from.centerX < bound.left + bound.width &&
top < bound.top + bound.height &&
bottom >= bound.top,
)
})
}
function horizontalCorridorCrossesUnrelatedState(
diagram: StateVisibleDiagram,
transition: StateVisibleTransition,
from: BoxBounds,
to: BoxBounds,
bounds: ReadonlyMap<string, BoxBounds>,
): boolean {
const leftToRight = from.centerX <= to.centerX
const startX = leftToRight ? from.left + from.width : from.left - 1
const endX = leftToRight ? to.left - 1 : to.left + to.width
const space = SpatialIndex.empty().add(
...diagram.states.flatMap((state) => {
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return []
const bound = bounds.get(state.id)
return bound ? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)] : []
}),
)
const corridor = spatialPathClaim(
`corridor:${transition.from}:${transition.to}`,
`transition:${transition.from}:${transition.to}`,
"route",
[
{ x: startX, y: from.centerY },
{ x: endX, y: from.centerY },
],
)
return !space.isFree(corridor)
}
function bottomApproachX(
diagram: StateVisibleDiagram,
transition: StateVisibleTransition,
from: BoxBounds,
to: BoxBounds,
bounds: ReadonlyMap<string, BoxBounds>,
railY: number,
): number {
const targetX = to.width > 1 ? (from.centerX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
const targetBottomY = to.top + to.height
const top = Math.min(targetBottomY, railY)
const bottom = Math.max(targetBottomY, railY)
const isClear = (x: number): boolean =>
!diagram.states.some((state) => {
if (state.id === transition.from || state.id === transition.to || isHiddenCompositeMarker(state)) return false
const bound = bounds.get(state.id)
return Boolean(
bound &&
x >= bound.left &&
x < bound.left + bound.width &&
top < bound.top + bound.height &&
bottom >= bound.top,
)
})
if (isClear(targetX)) return targetX
const maxX = Math.max(targetX, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 1
for (let distance = 1; distance <= maxX; distance++) {
const right = targetX + distance
if (isClear(right)) return right
const left = targetX - distance
if (left >= 0 && isClear(left)) return left
}
return targetX
}
export function createStateTransitionRoutePlans(
diagram: StateVisibleDiagram,
bounds: ReadonlyMap<string, BoxBounds>,
@@ -200,16 +289,29 @@ export function createStateTransitionRoutePlans(
): 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
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
const feedbackAllocations = createFeedbackAllocations(diagram, bounds, feedbackLaneY, parallelLaneGap, feedbackTopY)
let nextBottomRailY =
Math.max(
feedbackLaneY - parallelLaneGap,
...[...feedbackAllocations.values()]
.filter((allocation) => allocation.side === "bottom")
.map((allocation) => allocation.railY),
) + parallelLaneGap
const allocateSideRail = (label: string): number => {
const railX = nextSideRailX
nextSideRailX += Math.max(3, measureStateTransitionLabel(label).width + 2)
return railX
}
const allocateBottomRail = (): number => {
const railY = nextBottomRailY
nextBottomRailY += parallelLaneGap
return railY
}
return diagram.transitions.flatMap((transition): StateTransitionRoutePlan[] => {
const from = bounds.get(transition.from)
@@ -217,8 +319,9 @@ export function createStateTransitionRoutePlans(
if (!from || !to) return []
const targetState = statesById.get(transition.to)
const targetIsChoice = targetState?.kind === "choice" || isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice }
const targetIsChoice = targetState?.kind === "choice"
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
const endpointKey = `${transition.from}\u0000${transition.to}`
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
@@ -227,30 +330,80 @@ export function createStateTransitionRoutePlans(
(diagram.direction === "LR" || diagram.direction === "RL") && isStateHorizontalFeedback(diagram, from, to)
const feedbackAllocation = feedbackAllocations.get(transition)
if (feedbackAllocation) {
if (feedbackAllocation.side === "bottom") {
return [
{
...base,
kind: "bottom-feedback",
railY: feedbackAllocation.railY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackAllocation.railY),
},
]
}
return [
{
...base,
kind: feedbackAllocation.side === "bottom" ? "bottom-feedback" : "top-feedback",
kind: "top-feedback",
railY: feedbackAllocation.railY,
},
]
}
if (parallelIndex > 0) {
if (diagram.direction === "LR" || diagram.direction === "RL") {
if ((diagram.direction === "LR" || diagram.direction === "RL") && from.centerY === to.centerY) {
const railY = allocateBottomRail()
return [
{
...base,
kind: "bottom-parallel",
railY: feedbackLaneY + (parallelIndex - 1) * parallelLaneGap,
railY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
},
]
}
return [{ ...base, kind: "side-parallel", railX: sideLaneX + (parallelIndex - 1) * parallelLaneGap }]
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
}
if (diagram.direction !== "LR" && diagram.direction !== "RL") {
const fromParent = statesById.get(transition.from)?.parentId
const toParent = statesById.get(transition.to)?.parentId
if (fromParent && toParent && fromParent !== toParent) {
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
}
if (verticalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
}
if (from.centerY > to.centerY) {
return [{ ...base, kind: "side-parallel", railX: allocateSideRail(transition.label) }]
}
if (from.centerY === to.centerY) {
if (hasReverseTransition(diagram, transition) && from.centerX > to.centerX) {
const railY = allocateBottomRail()
return [
{
...base,
kind: "bottom-parallel",
railY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
},
]
}
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
}
if (from.centerX !== to.centerX) {
return [{ ...base, kind: "vertical-elbow", hasReverse: false, offsetConnector: false }]
}
return [{ ...base, kind: "vertical" }]
}
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 }]
if (from.centerY > to.centerY && feedback)
return [
{
...base,
kind: "bottom-feedback",
railY: feedbackLaneY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
},
]
const hasReverse = hasReverseTransition(diagram, transition)
return [
{
@@ -261,7 +414,26 @@ export function createStateTransitionRoutePlans(
},
]
}
if (feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
if (feedback)
return [
{
...base,
kind: "bottom-feedback",
railY: feedbackLaneY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
},
]
if (horizontalCorridorCrossesUnrelatedState(diagram, transition, from, to, bounds)) {
const railY = allocateBottomRail()
return [
{
...base,
kind: "bottom-parallel",
railY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
},
]
}
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
})
}
@@ -333,7 +505,7 @@ function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBound
}
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, leftToRight, transition } = builder.route as Extract<
const { from, to, targetIsChoice, targetIsHiddenMarker, leftToRight, transition } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "horizontal-forward" }
>
@@ -343,9 +515,12 @@ function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
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" })
addHorizontalLine(builder, startX, endX - step, y, step)
addCell(
builder,
targetIsHiddenMarker ? { x: endX, y, char: "─" } : { x: endX, y, arrowDirection: leftToRight ? "right" : "left" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, y)
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))
@@ -378,14 +553,14 @@ function outsideTopY(bounds: BoxBounds): number {
}
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY, approachX } = 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 railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : approachX
const sourceBottomY = outsideBottomY(from)
const targetBottomY = outsideBottomY(to)
addBottomDeparture(builder, from, sourceX)
@@ -406,8 +581,13 @@ function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
addCell(builder, { x, y: targetBottomY, char: "─" })
}
}
addCell(builder, { x: targetX, y: targetBottomY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "up" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
addCell(
builder,
targetIsHiddenMarker
? { x: targetX, y: targetBottomY, char: "│" }
: { x: targetX, y: targetBottomY, arrowDirection: "up" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
@@ -419,7 +599,7 @@ function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
}
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "top-feedback" }
>
@@ -437,8 +617,13 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
}
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)
addCell(
builder,
targetIsHiddenMarker
? { x: targetX, y: targetTopY, char: "│" }
: { x: targetX, y: targetTopY, arrowDirection: "down" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - targetX) - 2
@@ -450,7 +635,7 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
}
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, transition, railX } = builder.route as Extract<
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "side-parallel" }
>
@@ -465,9 +650,16 @@ function addSideParallelTransition(builder: StateTransitionRenderBuilder): void
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)
addCell(
builder,
targetIsHiddenMarker ? { x: endX, y: endY, char: "─" } : { x: endX, y: endY, arrowDirection: "left" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (transition.label) {
const metrics = measureStateTransitionLabel(transition.label)
const labelY = Math.max(0, Math.floor((startY + endY - metrics.height + 1) / 2))
addLabel(builder, railX + 2, labelY, transition.label)
}
}
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
@@ -476,10 +668,8 @@ function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
}
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice, hasReverse, offsetConnector } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "vertical-elbow" }
>
const { from, to, transition, targetIsChoice, targetIsHiddenMarker, 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)
@@ -513,13 +703,19 @@ function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void
}
}
}
const targetChar = targetIsChoice ? (hasTargetApproach || startX === endX ? "│" : topToBottom ? "┬" : "┴") : undefined
const targetChar = targetIsHiddenMarker
? 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 (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
if (topToBottom) {
@@ -543,7 +739,7 @@ function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void
}
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice } = builder.route
const { from, to, transition, targetIsChoice, targetIsHiddenMarker } = builder.route
const topToBottom = from.centerY <= to.centerY
const x = from.centerX
const startY = topToBottom ? from.top + from.height : from.top - 1
@@ -555,9 +751,9 @@ function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
addCell(builder, {
x,
y: endY,
...(targetIsChoice ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
...(targetIsHiddenMarker ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
})
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
}
@@ -590,69 +786,47 @@ function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): State
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 }]
: []
})
let space = SpatialIndex.empty().add(
...diagram.states.flatMap((state) => {
const bound = bounds.get(state.id)
return bound && !isHiddenCompositeMarker(state)
? [spatialRectClaim(`state:${state.id}`, `state:${state.id}`, "body", bound)]
: []
}),
...plans.map((plan, index) =>
spatialPathClaim(
`route:${index}`,
`route:${index}`,
"route",
plan.path.map(([x, y]) => ({ x, y })),
),
),
)
return plans.map((plan) => {
return plans.map((plan, planIndex) => {
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 statePadding = plan.label.lines.length === 1 ? 0 : 1
const labelClaim = (x: number, y: number) =>
spatialRectClaim(`label:${planIndex}`, `label:${planIndex}`, "label", {
left: x,
top: y,
width,
height: plan.label!.lines.length,
})
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
return space.isFree(labelClaim(x, y), {
clearance: {
body: statePadding,
label: { x: 1, y: 0 },
},
})
}
let x = plan.label.x
@@ -672,7 +846,7 @@ function placeStateTransitionLabels(
}
}
placedLabels.push(labelRect({ ...plan.label, x, y }, width))
space = space.add(labelClaim(x, y))
return { ...plan, label: { ...plan.label, x, y } }
})
}
@@ -703,6 +877,7 @@ export function createStateTransitionJunctionPlans(
bounds: ReadonlyMap<string, BoxBounds>,
renderPlans: readonly StateTransitionRenderPlan[],
): StateTransitionJunctionPlan[] {
const renderPlanByTransition = new Map(renderPlans.map((plan) => [plan.route.transition, plan]))
return diagram.states.flatMap((state): StateTransitionJunctionPlan[] => {
const kind =
state.kind === "choice" ? "choice" : isHiddenCompositeMarker(state) ? "hidden-composite-marker" : undefined
@@ -713,7 +888,7 @@ export function createStateTransitionJunctionPlans(
const connections = new Set<DiagramDirection>()
const transitions: StateVisibleTransition[] = []
for (const transition of diagram.transitions) {
const renderPlan = renderPlans.find((plan) => plan.route.transition === transition)
const renderPlan = renderPlanByTransition.get(transition)
let connected = false
if (transition.to === state.id) {
const junction = renderPlan?.path.at(-1)
@@ -20,6 +20,43 @@ describe("prepareVisibleStateDiagram", () => {
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" })
expect(exit).toMatchObject({ from: "Editing", to: "__end", label: "save<br/>logout" })
})
test("collapses nested composite entry chains without retaining scoped markers", () => {
const visible = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
state Session {
[*] --> Open
state Open {
[*] --> Clean
Clean --> Dirty: edit
Dirty --> Clean: save
}
Open --> [*]: close
}
[*] --> Session
Session --> [*]`),
)
expect(visible.states.map((state) => state.id)).toEqual(["Clean", "Dirty", "__start", "__end"])
expect(visible.transitions).toContainEqual({ from: "__start", to: "Clean", label: "" })
expect(visible.transitions.some((transition) => transition.from.includes(".__start"))).toBe(false)
expect(visible.transitions.some((transition) => transition.to.includes(".__start"))).toBe(false)
})
test("preserves labels on both sides of collapsed composite markers", () => {
const visible = prepareVisibleStateDiagram(
parseMermaidStateDiagram(`stateDiagram-v2
[*] --> Session: open session
state Session {
[*] --> Ready: initialize
Ready --> [*]: finalize
}
Session --> [*]: close session`),
)
expect(visible.transitions).toContainEqual({ from: "__start", to: "Ready", label: "open session<br/>initialize" })
expect(visible.transitions).toContainEqual({ from: "Ready", to: "__end", label: "finalize<br/>close session" })
})
})
+15 -20
View File
@@ -11,7 +11,7 @@ export function isHiddenCompositeMarker(state: StateDiagramState | undefined): b
}
function composeTransitionLabel(incoming: StateDiagramTransition, outgoing: StateDiagramTransition): string {
return incoming.label || outgoing.label
return [incoming.label, outgoing.label].filter(Boolean).join("<br/>")
}
function collapseHiddenCompositeMarkerTransitionsOnce(
@@ -23,33 +23,28 @@ function collapseHiddenCompositeMarkerTransitionsOnce(
)
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),
})
}
const skipped = new Set([...incoming, ...outgoing])
return {
transitions: [
...transitions.filter((transition) => !skipped.has(transition)),
...incoming.flatMap((incomingTransition) =>
outgoing.map((outgoingTransition) => ({
from: incomingTransition.from,
to: outgoingTransition.to,
label: composeTransitionLabel(incomingTransition, outgoingTransition),
})),
),
],
changed: true,
}
}
return {
transitions: [...transitions.filter((transition) => !skipped.has(transition)), ...collapsed],
changed,
}
return { transitions: [...transitions], changed: false }
}
function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
@@ -26,6 +26,21 @@ describe("parser diagnostics", () => {
).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --o B"')
})
test("does not partially parse unsupported flowchart syntax", () => {
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
}
})
test("does not treat arrows inside flowchart node labels as edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A["send --> receive"] --> B`)
expect(diagram.nodes.map((node) => node.id)).toEqual(["A", "B"])
expect(diagram.nodes[0]?.label).toBe("send --> receive")
expect(diagram.edges).toHaveLength(1)
})
test("exposes structured syntax errors through top-level rendering", () => {
try {
renderSequenceDiagram(`sequenceDiagram
@@ -41,6 +56,12 @@ describe("parser diagnostics", () => {
}
})
test("rejects unsupported bidirectional sequence arrows without phantom participants", () => {
for (const message of ["A<<->>B: hello", "A<<-->>B: hello"]) {
expect(() => parseMermaidSequenceDiagram(`sequenceDiagram\n ${message}`)).toThrow(MermaidSyntaxError)
}
})
test("reports unclosed state constructs at their opening line", () => {
expect(() =>
parseMermaidStateDiagram(`stateDiagram-v2
@@ -55,6 +76,17 @@ describe("parser diagnostics", () => {
)
})
test("rejects unsupported composite-local state directions", () => {
expect(() =>
parseMermaidStateDiagram(`stateDiagram-v2
direction LR
state Parent {
direction TB
A --> B
}`),
).toThrow("Composite-local direction is not supported")
})
test("reports malformed sequence block endings", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
+3 -3
View File
@@ -31,9 +31,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+1 -1
View File
@@ -12,7 +12,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
rule(["extmark.skill"], theme.categorical[1][step], { bold: true }),
rule(["extmark.skill"], (theme.categorical[1] ?? theme.categorical[0])[step], { bold: true }),
// V1 migration preserves its selected/inverse foreground in this action state.
rule(["extmark.paste"], theme.text.action.primary.focused, {
background: feedback.warning.default,
-1
View File
@@ -93,7 +93,6 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+15 -3
View File
@@ -97,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -251,6 +252,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
})
const clipboard = yield* Effect.acquireRelease(
Effect.sync(() => createTuiClipboard(renderer)),
(clipboard) =>
Effect.tryPromise(() => clipboard.dispose()).pipe(
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
),
)
win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() =>
@@ -287,7 +295,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
fallback={(error, reset) => (
<ClipboardProvider value={clipboard}>
<ErrorComponent error={error} reset={reset} mode={mode} />
</ClipboardProvider>
)}
>
<TuiPathsProvider
value={{
@@ -336,7 +348,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider value={clipboard}>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -504,7 +516,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return
await clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+59 -118
View File
@@ -1,125 +1,66 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
const exec = promisify(execFile)
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard(),
terminal: createRendererClipboardAdapter(renderer),
}),
)
}
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\`
process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence)
}
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
return {
async read(): Promise<ClipboardContent | undefined> {
const result = await clipboard.read({
preferredTypes: ["image/png", "text/plain"],
selection: "clipboard",
})
if (result.status !== "read") {
if (result.status === "failed") throw result.error
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
if (result.status === "limit-exceeded") {
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
}
return undefined
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
if (result.representation.mimeType === "image/png") {
return {
data: Buffer.from(result.representation.bytes).toString("base64"),
mime: result.representation.mimeType,
}
}
if (result.representation.mimeType === "text/plain") {
if (result.representation.bytes.length === 0) return undefined
return {
data: decodePasteBytes(result.representation.bytes),
mime: result.representation.mimeType,
}
}
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
})
if (result.host.status === "written" || result.terminal.status === "attempted") return
if (result.host.status === "failed") throw result.host.error
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`)
},
dispose() {
return clipboard.dispose()
},
}
}
@@ -84,6 +84,15 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "Enabled",
category: "Tabs",
@@ -188,6 +197,15 @@ export const settings: Setting[] = [
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Image previews",
category: "Input",
path: ["prompt", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "clipboard", "images", "prompt"],
},
{
title: "Leader timeout",
category: "Input",
+1 -1
View File
@@ -39,7 +39,7 @@ export function DialogDebug() {
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
void clipboard
.write?.(text)
.write(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
@@ -0,0 +1,71 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
type ImagePreviewItem = Readonly<{
uri: string
mention?: Readonly<{ text: string }>
}>
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
const [failed, setFailed] = createSignal(false)
const current = createMemo(() => props.images[index()])
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
dialog.setSize("xlarge")
dialog.setCentered(true)
function move(direction: number) {
if (props.images.length < 2) return
setFailed(false)
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
],
}))
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="prompt-image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
@@ -492,7 +492,7 @@ function OAuthAuto(props: {
run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)
.write(value)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
@@ -172,7 +172,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then(() => setCopied(true))
+25 -8
View File
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const exit = useExit()
const clipboard = useClipboard()
const app = useTuiApp()
const [copied, setCopied] = createSignal(false)
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@@ -46,11 +46,19 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => {
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
void clipboard
.write(issueURL.toString())
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"))
}
const actions = [
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{
key: "c",
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const isCopied = () => action.copy && copied()
const copyColor = () =>
action.copy
? copyState() === "copied"
? colors.success
: copyState() === "failed"
? colors.error
: undefined
: undefined
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
minWidth={15}
alignItems="center"
paddingLeft={2}
@@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copied()
{copyState() === "copied"
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
: copyState() === "failed"
? "Clipboard write failed. Try again or report the crash manually."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>OpenCode {app.version}</text>
</box>
+180 -47
View File
@@ -7,9 +7,8 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
@@ -48,13 +47,20 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
sessionID?: string
@@ -64,7 +70,6 @@ export type PromptProps = {
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element
runningHint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
@@ -73,17 +78,6 @@ export type PromptProps = {
}
}
function pastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
export type PromptRef = {
focused: boolean
current: PromptInfo
@@ -313,6 +307,41 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
let disposed = false
let pasteQueue = Promise.resolve()
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
)
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
createEffect(
on(
@@ -392,22 +421,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
const content = await clipboard.read?.()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
return enqueuePaste(async (changed) => {
const content = await clipboard.read()
if (changed()) return
if (content?.mime.startsWith("image/")) {
pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data, changed)
}
})
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -562,6 +601,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -615,6 +655,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1263,27 +1304,39 @@ export function Prompt(props: PromptProps) {
return true
}
async function pasteInputText(text: string) {
async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
return
}
if (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
return
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
if (filepaths.length > 1) {
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
for (const candidate of filepaths) {
const next = await readLocalAttachment(candidate, remaining)
if (!next) break
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
attachments.push({ filepath: candidate, attachment: next })
}
if (attachments.length === filepaths.length) {
if (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
}
}
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1308,12 +1361,27 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1345,7 +1413,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1469,6 +1536,74 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={visibleImageAttachments()}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(index())
}}
>
<Show
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
</box>
}
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={imageAttachments().length > visibleImageAttachments().length}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
</Show>
</box>
</Show>
<textarea
width="100%"
placeholder={placeholderText()}
@@ -1497,7 +1632,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
onPaste={(event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1507,11 +1642,10 @@ export function Prompt(props: PromptProps) {
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
if (event.bytes.byteLength === 0) {
keymap.dispatch("prompt.paste")
return
}
@@ -1520,7 +1654,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
await pasteInputText(normalizedText)
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1659,7 +1793,6 @@ export function Prompt(props: PromptProps) {
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
{props.runningHint}
</box>
</Match>
<Match when={move.progress()}>
@@ -1,9 +1,12 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
const MAX_PASTED_FILEPATHS = 32
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -11,14 +14,15 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string) {
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
return readLocalAttachmentWith(
{
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -33,16 +37,108 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
async function readFileBounded(file: string, maxBytes: number) {
const source = Bun.file(file)
if (!(await source.exists())) throw new Error("Attachment does not exist")
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
return content
}
export function normalizePastedFilepath(value: string, platform: string) {
const raw = value.replace(/^['"]+|['"]+$/g, "")
const url = decodeFileURL(raw, platform)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string, platform: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
const url = new URL(value)
if (/%2f|%5c/i.test(url.pathname)) return undefined
const pathname = decodeURIComponent(url.pathname)
if (platform !== "win32") {
if (url.hostname && url.hostname !== "localhost") return undefined
return pathname
}
const local = pathname.replace(/^\/([A-Za-z]:)/, "$1").replaceAll("/", "\\")
if (url.hostname && url.hostname !== "localhost") return `\\\\${url.hostname}${local}`
return local
} catch {
return undefined
}
}
export function parsePastedFilepaths(value: string, platform: string) {
const result: string[] = []
let current = ""
let quote = ""
function push() {
if (!current) return
result.push(decodeFileURL(current, platform) ?? current)
current = ""
}
const input = value.includes("file://")
? value
.split(/\r?\n/)
.filter((line) => !line.trimStart().startsWith("#"))
.join("\n")
: value
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (quote) {
if (character === quote) {
quote = ""
continue
}
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
current += input[++index]
continue
}
current += character
continue
}
if (character === "'" || character === '"') {
quote = character
continue
}
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
current += input[++index]
continue
}
if (/\s/.test(character)) {
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
continue
}
current += character
}
if (quote) return []
push()
if (result.length > MAX_PASTED_FILEPATHS) return []
return result
}
export async function readLocalAttachmentWith(
files: LocalFiles,
path: string,
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (mime === "image/svg+xml") {
const content = await files.readText(path).catch(() => undefined)
if (!content) return
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
return { type: "text", mime, content }
}
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
return { type: "binary", mime, content }
}
+80 -12
View File
@@ -1,5 +1,5 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@@ -16,16 +16,19 @@ import {
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring } from "../ui/animation"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
@@ -46,6 +49,41 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: number, leading: number) {
const fade = (position: number) => (position <= 0 ? 0 : 0.2 + 0.72 * ((position - 1) / Math.max(1, FADE_WIDTH - 1)))
const start = index < FADE_WIDTH ? FADE_WIDTH - index : 0
const end = index - (length - FADE_WIDTH) + 1
const opacity = Math.max(fade(start) * leading, fade(end))
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
createEffect(() => {
if (!hovered()) {
setOffset(0)
leading.jump({ opacity: 0 })
return
}
setOffset(0)
leading.jump({ opacity: 0 })
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
setOffset(1)
leading.animate({ opacity: 1 })
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
}, MARQUEE_DELAY)
onCleanup(() => {
clearTimeout(delay)
if (interval) clearInterval(interval)
})
})
return { offset, leading: () => leading.value().opacity }
}
export function SessionTabs(
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
) {
@@ -69,6 +107,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
@@ -144,7 +183,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
@@ -206,9 +250,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (visibleTitleParts().length - FADE_WIDTH)
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
return titleFades()
? fadeTitleColor(
color,
pulseBackground(),
index,
visibleTitleParts().length,
scrolling() ? marquee.leading() : 0,
)
: color
}
const release = () => {
setDragging(undefined)
@@ -225,7 +275,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
@@ -382,6 +435,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
@@ -561,7 +615,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
: Locale.takeWidth(title(), availableTitleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
@@ -575,9 +634,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
const position = index - (visibleTitleParts().length - FADE_WIDTH)
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
return titleFades()
? fadeTitleColor(
color,
background(),
index,
visibleTitleParts().length,
scrolling() ? marquee.leading() : 0,
)
: color
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -611,7 +676,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
+6
View File
@@ -114,6 +114,9 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show image attachment previews above the prompt input",
}),
}),
).annotate({ description: "Prompt input behavior" }),
session: Schema.optional(
@@ -128,6 +131,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
+2
View File
@@ -165,6 +165,7 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -366,6 +367,7 @@ export const CommandMap = {
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
+9 -8
View File
@@ -1,18 +1,19 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
read(): Promise<ClipboardContent | undefined>
write(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
const ClipboardContext = createContext<ClipboardService>()
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
}
-1
View File
@@ -437,7 +437,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!m) return
if (route.data.type === "session") {
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
@@ -66,8 +66,12 @@ export function DialogMessage(props: {
: "text" in value
? value.text
: ""
await clipboard.write?.(text)
dialog.clear()
try {
await clipboard.write(text)
dialog.clear()
} catch (error) {
toast.error(error)
}
},
},
{
+2 -2
View File
@@ -366,7 +366,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() {
const current = externalField()
if (!current || !clipboard.write) return
if (!current) return
void clipboard
.write(current.url)
.then(() => {
@@ -996,7 +996,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
</text>
<Show when={externalField() && clipboard.write}>
<Show when={externalField()}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+115 -39
View File
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -54,6 +54,7 @@ import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
@@ -89,6 +90,7 @@ import {
createSessionRows,
messageBoundaryIDs,
resolvePart,
turnDuration,
type CacheUsage,
type PartRef,
type SessionRow,
@@ -102,11 +104,13 @@ import { useSessionTabs } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { generateThinkingSyntax } from "./thinking-syntax"
import { createDelayedPresence } from "../../util/delayed-presence"
addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const BACKGROUND_TOOL_HINT_DELAY = 1_000
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
@@ -114,7 +118,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const BACKGROUND_TOOL_HINT_DELAY = 3_000
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
@@ -848,7 +851,7 @@ export function Session() {
}
clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
dialog.clear()
@@ -866,7 +869,7 @@ export function Session() {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
await clipboard.write?.(transcript)
await clipboard.write(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
toast.show({ message: "Failed to copy session transcript", variant: "error" })
@@ -900,7 +903,7 @@ export function Session() {
) + EOL
if (options.action === "copy") {
await clipboard.write?.(content)
await clipboard.write(content)
dialog.clear()
toast.show({ message: "Copied to clipboard", variant: "success" })
return
@@ -1057,6 +1060,7 @@ export function Session() {
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
@@ -1112,7 +1116,6 @@ export function Session() {
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
runningHint={<BackgroundToolHint messages={messages()} />}
/>
</Match>
</Switch>
@@ -1327,42 +1330,28 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const theme = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const tool = createMemo(() => {
const running = createMemo(() => {
if (!shortcut()) return
const current = props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
)
return current?.content.find((part): part is SessionMessageAssistantTool => {
const part = current?.content.find((part): part is SessionMessageAssistantTool => {
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
const name = canonicalToolName(part.name)
return name === "shell" || name === "subagent"
})
if (!current || !part) return
return `${current.id}:${part.id}`
})
const toolID = () => tool()?.id
const toolStartedAt = () => {
const current = tool()
if (!current) return
return current.time.ran ?? current.time.created
}
const [visible, setVisible] = createSignal(false)
createEffect(
on([toolID, toolStartedAt], ([id, startedAt]) => {
setVisible(false)
if (!id || startedAt === undefined) return
const remaining = Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - startedAt))
if (remaining === 0) {
setVisible(true)
return
}
const timer = setTimeout(() => setVisible(true), remaining)
onCleanup(() => clearTimeout(timer))
}),
)
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
return (
<Show when={visible() && shortcut()}>
{(value) => (
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<span style={{ fg: theme.text.default }}>{value()}</span> background
</text>
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={theme.text.subdued}>
Press <span style={{ fg: theme.text.default }}>{value()}</span> to move running work to the background
</text>
</box>
)}
</Show>
)
@@ -1604,8 +1593,9 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
@@ -1614,6 +1604,7 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
@@ -1624,9 +1615,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const duration = createMemo(() =>
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
)
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
@@ -1910,6 +1899,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1931,6 +1925,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} paddingLeft={2} />
<box
onMouseOver={() => {
setHover(true)
@@ -2223,7 +2218,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool }) {
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
@@ -2247,7 +2242,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
}
return (
const content = (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2293,6 +2288,87 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
</Match>
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
return <SessionImages images={images()} />
}
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
<Show when={visible().length > 0}>
<box
flexDirection="row"
flexShrink={0}
paddingTop={1}
paddingLeft={props.paddingLeft ?? 3}
paddingRight={2}
paddingBottom={1}
gap={1}
>
<For each={visible()}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={height() * 2}
height={height()}
flexBasis={height() * 2}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > visible().length}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - visible().length} more
</text>
</box>
</Show>
</box>
</Show>
)
}
function inlineToolImages(part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri }]
: [],
)
}
type ToolProps = {
+9
View File
@@ -348,6 +348,15 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
return drop > 0 ? drop : undefined
}
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
if (message.time.completed === undefined) return 0
const index = messages.findIndex((item) => item.id === message.id)
const input = messages
.slice(0, index === -1 ? messages.length : index)
.findLast((item) => item.type === "user" || item.type === "synthetic")
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
}
function hasTokenUsage(
message: SessionMessageAssistant,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
+1 -1
View File
@@ -212,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const flatten = createMemo(() => props.flat && store.filter.length > 0)
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
if (flatten()) return [["", filtered()]]
if (flatten()) return filtered().length ? [["", filtered()]] : []
const result = pipe(
filtered(),
groupBy((x) => x.category ?? ""),
+1 -1
View File
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
function copySelection() {
const text = renderer.getSelection()?.getSelectedText()
if (!text || !clipboard.write) return false
if (!text) return false
void clipboard.write(text).then(
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
(error) => toast.error(error),
+16
View File
@@ -0,0 +1,16 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
export function createDelayedPresence<T>(source: Accessor<T | undefined>, delay: number) {
const [visible, setVisible] = createSignal(false)
createEffect(() => {
const value = source()
setVisible(false)
if (value === undefined) return
const timer = setTimeout(() => setVisible(true), delay)
onCleanup(() => clearTimeout(timer))
})
return visible
}
+19
View File
@@ -0,0 +1,19 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
const GAP = " "
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
const loop = value + GAP
const cursor = offset % stringWidth(loop)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>
state.width >= cursor ? state : { index: index + 1, width: state.width + stringWidth(segment) },
{ index: 0, width: 0 },
).index
return Locale.takeWidth(segments.slice(start).join(""), width)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.write(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
for (const key of "image preview") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false)
await app.waitFor(() => current.prompt?.image_preview === true)
} finally {
app.renderer.destroy()
}
@@ -87,6 +87,7 @@ async function mountSelect(
initial: DialogSelectOption<string>[],
current?: string,
focusCurrent?: boolean,
select?: { flat?: boolean },
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -124,6 +125,7 @@ async function mountSelect(
options={options()}
current={current}
focusCurrent={focusCurrent}
flat={select?.flat}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
@@ -367,6 +369,34 @@ test("keeps the current option selected when options reorder", async () => {
}
})
test("shows no-match and still closes after a flat filter goes empty", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(
tmp.path,
[
{ title: "models.dev", value: "models.dev", category: "Projects" },
{ title: "opencode2", value: "opencode2", category: "Projects" },
],
undefined,
undefined,
{ flat: true },
)
try {
await select.app.waitForFrame((frame) => frame.includes("models.dev"))
await select.app.mockInput.typeText("models")
await select.app.waitForFrame((frame) => frame.includes("models.dev") && !frame.includes("opencode2"))
await select.app.mockInput.typeText(" missing")
await select.app.waitForFrame((frame) => frame.includes("No results found"))
expect(select.app.captureCharFrame()).not.toContain("models.dev")
select.app.mockInput.pressEscape()
await select.app.waitForFrame((frame) => !frame.includes("Mutable options") && !frame.includes("No results found"))
} finally {
select.app.renderer.destroy()
}
})
test("keeps the first row selected when current is only a marker", async () => {
await using tmp = await tmpdir()
const project = { title: "project", value: "project" }
+3
View File
@@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
>
<ClipboardProvider
value={{
async read() {
return undefined
},
write(text) {
copied.push(text)
return Promise.resolve()
+15 -1
View File
@@ -1,6 +1,20 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
test("measures turn duration from the user prompt across assistant steps", () => {
const first = assistant("assistant-1", [])
first.time = { created: 8_000, completed: 11_000 }
const final = assistant("assistant-2", [])
final.time = { created: 27_000, completed: 30_000 }
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
first,
final,
]
expect(turnDuration(final, messages)).toBe(29_000)
})
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
+121 -11
View File
@@ -1,19 +1,129 @@
import { expect, test } from "bun:test"
import { copyCommand } from "../src/clipboard"
import {
createClipboard,
type ClipboardReadOptions,
type ClipboardReadResult,
type ClipboardService as CoreClipboardService,
type ClipboardWriteOptions,
type ClipboardWriteResult,
type HostClipboardService,
} from "@opentui/core"
import { createClipboardAdapter } from "../src/clipboard"
test("prefers Wayland clipboard when available", () => {
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
function coreClipboard(options: {
read?: ClipboardReadResult
write?: ClipboardWriteResult
onRead?: (input: ClipboardReadOptions) => void
onWrite?: (text: string, input: ClipboardWriteOptions) => void
}): CoreClipboardService {
return {
async read(input) {
options.onRead?.(input)
return options.read ?? { status: "empty" }
},
async writeText(text, input) {
options.onWrite?.(text, input)
return (
options.write ?? {
host: { status: "written" },
terminal: { status: "not-attempted", capability: "unknown" },
}
)
},
async clear() {
return {
host: { status: "cleared" },
terminal: { status: "not-attempted", capability: "unknown" },
}
},
async dispose() {},
}
}
test("adapts OpenTUI image and text reads", async () => {
const requests: ClipboardReadOptions[] = []
const image = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
},
onRead: (input) => requests.push(input),
}),
)
const text = "line 1\r\n\t世界"
const plain = createClipboardAdapter(
coreClipboard({
read: {
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
},
}),
)
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
})
test("uses osascript on macOS", () => {
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
test("uses all available routes but skips the process host remotely", async () => {
const writes = { host: 0, terminal: 0 }
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
return { status: "empty" }
},
async writeText() {
writes.host++
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
const clipboard = createClipboardAdapter(
createClipboard({
host,
terminal: {
remote: true,
writeText() {
writes.terminal++
return { status: "attempted", capability: "supported" }
},
clear() {
return { status: "attempted", capability: "supported" }
},
},
}),
)
expect(await clipboard.write("hello")).toBeUndefined()
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("falls back through X11 clipboard commands", () => {
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
const fallback = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "written" },
terminal: { status: "local-failure", capability: "supported" },
},
}),
)
const clipboard = createClipboardAdapter(
coreClipboard({
write: {
host: { status: "failed", error: failure },
terminal: { status: "local-failure", capability: "supported" },
},
onWrite: (text, input) => writes.push([text, input]),
}),
)
test("returns undefined when native clipboard is unavailable", () => {
expect(copyCommand("linux", false, () => false)).toBeUndefined()
expect(await fallback.write("hello")).toBeUndefined()
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
})
+2
View File
@@ -23,6 +23,8 @@ test("validates the session tabs setting", () => {
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
+12 -1
View File
@@ -7,6 +7,14 @@ import {
} from "../../src/context/runtime"
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
const clipboard: ClipboardService = {
async read() {
return undefined
},
async write() {},
}
export function TestTuiContexts(
props: ParentProps<{
@@ -14,6 +22,7 @@ export function TestTuiContexts(
directory?: string
paths?: Partial<TuiPaths>
log?: LogSink
clipboard?: ClipboardService
}>,
) {
return (
@@ -28,7 +37,9 @@ export function TestTuiContexts(
}}
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</LogProvider>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
@@ -11,6 +11,44 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
}
describe("prompt local attachments", () => {
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two image.webp",
])
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
"/tmp/one.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
"/tmp/one\\image.png",
"/tmp/two.webp",
])
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
"C:\\one image.png",
"C:\\two.webp",
])
expect(parsePastedFilepaths("file:///C:/one%20image.png\r\nfile://server/share/two.webp", "win32")).toEqual([
"C:\\one image.png",
"\\\\server\\share\\two.webp",
])
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
"/tmp/O'Brien.png",
"/tmp/two.webp",
])
})
test("rejects unbounded and malformed multi-file drops", () => {
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
expect(
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
).toEqual([])
})
test("reads SVG attachments as text", async () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text",
@@ -39,5 +77,8 @@ describe("prompt local attachments", () => {
"/tmp/missing.png",
),
).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
})
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import {
DEFAULT_THEME,
generateSyntax,
resolveTheme,
resolveThemeDocument,
selectTheme,
@@ -42,6 +43,14 @@ test("validates and resolves categorical hues in configured order", () => {
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
})
test("generates syntax with one categorical hue", () => {
const theme = resolveSource({ version: 2, light: { categorical: ["red"] } }, "light")
const syntax = generateSyntax(theme, "light")
expect(syntax.getStyleId("extmark.skill")).not.toBeNull()
syntax.destroy()
})
test("uses the default categorical order for direct definitions", () => {
const theme = resolveTheme({ ...light, categorical: undefined })
@@ -0,0 +1,55 @@
import { expect, jest, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createDelayedPresence } from "../../src/util/delayed-presence"
test("shows only after the same value remains present for the delay", async () => {
jest.useFakeTimers()
const scope = createRoot((dispose) => {
const [value, setValue] = createSignal<string>()
return { dispose, setValue, visible: createDelayedPresence(value, 1_000) }
})
try {
scope.setValue("first")
await Promise.resolve()
jest.advanceTimersByTime(500)
expect(scope.visible()).toBe(false)
scope.setValue("second")
await Promise.resolve()
jest.advanceTimersByTime(999)
expect(scope.visible()).toBe(false)
jest.advanceTimersByTime(1)
expect(scope.visible()).toBe(true)
} finally {
scope.dispose()
jest.useRealTimers()
}
})
test("cancels the delay when the value disappears or the owner is disposed", async () => {
jest.useFakeTimers()
const scope = createRoot((dispose) => {
const [value, setValue] = createSignal<string>()
return { dispose, setValue, visible: createDelayedPresence(value, 1_000) }
})
try {
scope.setValue("running")
await Promise.resolve()
jest.advanceTimersByTime(500)
scope.setValue(undefined)
await Promise.resolve()
jest.advanceTimersByTime(1_000)
expect(scope.visible()).toBe(false)
scope.setValue("running")
await Promise.resolve()
scope.dispose()
jest.advanceTimersByTime(1_000)
expect(scope.visible()).toBe(false)
} finally {
scope.dispose()
jest.useRealTimers()
}
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { marqueeText } from "../../src/util/marquee"
import { stringWidth } from "../../src/util/string-width"
describe("marquee text", () => {
test("keeps short text stationary", () => {
expect(marqueeText("Short", 10, 8)).toBe("Short")
})
test("starts clipped and scrolls through a long title", () => {
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
})
test("clips wide graphemes to terminal cells", () => {
const frame = marqueeText("Plan 🧭 the release", 8, 5)
expect(frame).toBe("🧭 the r")
expect(stringWidth(frame)).toBeLessThanOrEqual(8)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { mkdir } from "node:fs/promises"
import { defineScript, Effect, Llm } from "opencode-drive"
const theme = Bun.env.DRIVE_THEME ?? "opencode"
const output = Bun.env.DRIVE_SCREENSHOT ?? `artifacts/mermaid-${theme}.png`
const animate = Bun.env.DRIVE_ANIMATE === "1"
const cycleThemes = Bun.env.DRIVE_CYCLE_THEMES === "1"
const response = `\`\`\`mermaid
sequenceDiagram
participant B as Browser
participant S as Server
participant T as Ticket store
participant P as PTY
B->>S: GET /
S-->>B: 401 WWW-Auth
Note over B,S: native browser Basic prompt
B->>S: GET / · Basic
S-->>B: 200 web UI
Note over B,S: user opens terminal
B->>S: POST connect-token<br/>· Basic (cached by browser)<br/>· X-OpenCode-Ticket: 1
S->>T: issue { ptyID, }
S-->>B: { ticket }
B->>S: WS ?ticket=<br/>Upgrade: websocket
S->>T: consume(token,scope)
T-->>S: ok, delete
S->>P: attach
P-->>B: WS frames
\`\`\``
export default defineScript({
config: {
autoupdate: false,
},
tuiConfig: {
theme: {
name: theme,
mode: "dark",
},
},
tui: {
viewport: { cols: 180, rows: 64 },
},
run: ({ ui, llm }) =>
Effect.gen(function* () {
yield* ui.submit("Show the connection flow as a Mermaid sequence diagram")
yield* llm.send(
Llm.text(response, animate ? { delay: 80, chunkSize: 20 } : { delay: 0, chunkSize: response.length }),
)
yield* ui.waitFor("WS frames", { timeout: 10_000 })
if (cycleThemes) {
yield* Effect.sleep(800)
yield* Effect.forEach(
["everforest", "synthwave84", "matrix", "opencode"],
(next) =>
Effect.gen(function* () {
yield* ui.press("x", { ctrl: true })
yield* ui.press("t")
yield* ui.waitFor("Themes")
yield* ui.type(next)
yield* Effect.sleep(700)
yield* ui.enter()
yield* Effect.sleep(1_200)
}),
{ discard: true },
)
}
const screenshot = yield* ui.screenshot(`mermaid-${theme}`)
yield* Effect.promise(async () => {
await mkdir("artifacts", { recursive: true })
await Bun.write(output, Bun.file(screenshot))
})
yield* Effect.log(`Saved ${output}`)
}),
})