Compare commits

..

18 Commits

Author SHA1 Message Date
Aiden Cline 868a303b31 fix(core): move branch watch into Vcs
LocationWatcher only existed to republish git/hg metadata as
filesystem.changed for Vcs. Nothing else consumed those events.
Vcs now owns the Parcel watch, same as skills and config.
2026-08-10 17:46:45 -05:00
Aiden Cline e5b576ce3a fix(core): restore parcel watch for git HEAD
Bun fs.watch misses git's atomic HEAD.lock rename, so checkout never
refreshed the cached branch. Watch the git dir with Parcel like dev,
keeping only HEAD and HEAD.lock.
2026-08-10 17:35:50 -05: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
Shoubhit Dash 5e16a5dc66 fix(tui): restore web search failure details (#41545) 2026-08-10 19:24:59 +05:30
115 changed files with 4182 additions and 1924 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]))
},
}
}
-29
View File
@@ -1,29 +0,0 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
export type AppAsset = {
readonly key: string
readonly source: string
}
export async function buildAppAssets(channel: string) {
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
return (await files(path.join(root, "dist")))
.filter((key) => !key.endsWith(".map"))
.map((key): AppAsset => ({ key, source: path.join(root, "dist", key) }))
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+2 -15
View File
@@ -12,7 +12,6 @@ import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppAssets } from "./app-assets"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -27,7 +26,6 @@ if (outdir === path.join(dir, "dist-node")) {
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
@@ -57,24 +55,13 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = [
...(await collectNodeAssets(target)),
...appAssets.map((asset) => ({ key: `app/${asset.key}`, source: asset.source })),
]
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appAssets: appAssets.map((asset) => asset.key),
}
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
+1 -20
View File
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppAssets } from "./app-assets"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -24,7 +23,6 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
@@ -56,23 +54,6 @@ const targets = singleFlag
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
setup(build) {
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
path: "opencode-app-assets",
namespace: "opencode",
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `${appAssets
.map((asset, index) => `import asset_${index} from ${JSON.stringify(asset.source)} with { type: "file" }`)
.join("\n")}
export default {${appAssets.map((asset, index) => `${JSON.stringify(asset.key)}: asset_${index}`).join(",")}}`,
}))
},
}
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -99,7 +80,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
plugins: [solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-32
View File
@@ -1,32 +0,0 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
export type AssetMap = Readonly<Record<string, string>>
let result: Promise<AssetMap> | undefined
export function load() {
return (result ??= import("virtual:opencode-app-assets")
.then((module) => module.default)
.catch(() => ({}))
.then((assets) => (Object.keys(assets).length > 0 ? assets : sourceAssets())))
}
async function sourceAssets(): Promise<AssetMap> {
const root = path.resolve(import.meta.dirname, "../../app/dist")
const entries = await files(root).catch(() => [])
return Object.fromEntries(entries.filter((file) => !file.endsWith(".map")).map((file) => [file, path.join(root, file)]))
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+1 -8
View File
@@ -266,15 +266,8 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
],
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("web", {
description: "Start the server and open the web interface",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
},
}),
Spec.make("serve", {
description: "Start the v2 API and web server",
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+3 -11
View File
@@ -4,13 +4,12 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Ref, Scope } from "effect"
import { Context, Effect, FileSystem, Option } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { WebUi } from "../../services/web-ui"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -38,13 +37,11 @@ export default Runtime.handler(Commands, (input) =>
),
)
preflight.loading()
const endpoint = yield* Ref.make(server.endpoint)
const web = yield* Effect.cached(WebUi.start(endpoint))
const config = yield* Config.Service
const npm = yield* Npm.Service
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const context = yield* Effect.context<FileSystem.FileSystem | Scope.Scope>()
const context = yield* Effect.context<FileSystem.FileSystem>()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const service = server.service
@@ -58,16 +55,11 @@ export default Runtime.handler(Commands, (input) =>
endpoint: server.endpoint,
service: service
? {
reconnect: (signal) =>
runServicePromise(
service.reconnect().pipe(Effect.tap((next) => Ref.set(endpoint, next))),
{ signal },
),
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
restart: () => runServicePromise(service.restart()),
}
: undefined,
},
web: () => runPromise(web),
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
-15
View File
@@ -1,15 +0,0 @@
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerProcess } from "../../server-process"
export default Runtime.handler(
Commands.commands.web,
Effect.fnUntraced(function* (input) {
return yield* ServerProcess.run({
mode: "web",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
})
}),
)
-1
View File
@@ -52,7 +52,6 @@ const Handlers = Runtime.handlers(Commands, {
unset: () => import("./commands/handlers/service/unset"),
},
serve: () => import("./commands/handlers/serve"),
web: () => import("./commands/handlers/web"),
})
Effect.logInfo("cli starting", {
+7 -25
View File
@@ -1,22 +1,20 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Ref, Schedule, Schema } from "effect"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import open from "open"
export type Mode = "default" | "service" | "stdio" | "web"
export type Mode = "default" | "service" | "stdio"
export type Options = {
readonly mode: Mode
@@ -44,7 +42,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default" || options.mode === "web"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
@@ -77,8 +74,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname: foreground ? "127.0.0.1" : hostname,
port: foreground ? 0 : port,
hostname,
port,
password,
simulation: truthy(process.env.OPENCODE_SIMULATE),
database: {
@@ -143,24 +140,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
)
if (server === undefined) return
const url =
foreground
? yield* WebUi.serve(
yield* Ref.make<Endpoint>({
url: HttpServer.formatAddress(server.address),
auth: { type: "basic", username: "opencode", password },
}),
{ hostname, port, password },
)
: HttpServer.formatAddress(server.address)
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
if (options.mode === "web") {
const target = new URL(url)
if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost"
target.searchParams.set("auth_token", Buffer.from(`opencode:${password}`).toString("base64"))
yield* Effect.promise(() => open(target.toString()).catch(() => undefined))
}
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
-350
View File
@@ -1,350 +0,0 @@
import { NodeHttpServer, NodeSocket } from "@effect/platform-node"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ServerInfo } from "@opencode-ai/server/server-info"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Context, Effect, Exit, Ref, Scope, Stream } from "effect"
import {
FetchHttpClient,
HttpBody,
HttpClient,
HttpClientRequest,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { load } from "../app-assets"
const UI_UPSTREAM = new URL("https://app.opencode.ai")
const COOKIE = "opencode-web"
const hop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
])
export const start = Effect.fn("cli.web-ui.start")(function* (
endpoint: Ref.Ref<Endpoint>,
options?: { readonly assets?: Readonly<Record<string, string>> },
) {
const token = randomBytes(32).toString("base64url")
const origin = yield* listen(endpoint, {
auth: { type: "cookie", token },
hostname: "127.0.0.1",
port: 0,
assets: options?.assets,
})
return `${origin}/?cli_token=${encodeURIComponent(token)}`
})
export const serve = Effect.fn("cli.web-ui.serve")(function* (
endpoint: Ref.Ref<Endpoint>,
options: {
readonly hostname: string
readonly port?: number
readonly password: string
readonly assets?: Readonly<Record<string, string>>
},
) {
return yield* listen(endpoint, {
auth: { type: "basic", password: options.password },
hostname: options.hostname,
port: options.port,
assets: options.assets,
})
})
const listen = Effect.fnUntraced(function* (
endpoint: Ref.Ref<Endpoint>,
options: {
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
readonly hostname: string
readonly port?: number
readonly assets?: Readonly<Record<string, string>>
},
) {
const assets = options.assets ?? (yield* Effect.promise(load))
const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
const websocket = yield* Socket.WebSocketConstructor.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS))
const server = yield* bind(options.hostname, options.port)
const origin = formatAddress(server.http.address)
const urls = ServerInfo.connectionURLs(origin, options.hostname)
yield* server.http.serve(
handle({ endpoint, auth: options.auth, assets, client, websocket, origin, urls }),
).pipe(Effect.provideService(Scope.Scope, server.scope))
return origin
})
function handle(input: {
readonly endpoint: Ref.Ref<Endpoint>
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
readonly assets: Readonly<Record<string, string>>
readonly client: HttpClient.HttpClient
readonly websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>
readonly origin: string
readonly urls: ReadonlyArray<string>
}) {
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, input.origin)
if (input.auth.type === "cookie" && request.headers.host !== new URL(input.origin).host)
return HttpServerResponse.empty({ status: 403 })
const queryToken = url.searchParams.get(input.auth.type === "cookie" ? "cli_token" : "auth_token")
const queryAuthorized = input.auth.type === "cookie" && matches(queryToken, input.auth.token)
if (input.auth.type === "cookie" && queryToken !== null && request.headers.upgrade?.toLowerCase() !== "websocket") {
if (!queryAuthorized) return HttpServerResponse.empty({ status: 401 })
url.searchParams.delete("cli_token")
return HttpServerResponse.empty({
status: 302,
headers: {
location: url.pathname + url.search + url.hash,
"set-cookie": `${COOKIE}=${input.auth.token}; HttpOnly; SameSite=Strict; Path=/`,
"cache-control": "no-store",
},
})
}
if (input.auth.type === "cookie" && !queryAuthorized && !authorized(request.headers.cookie, input.auth.token))
return unauthorized(false)
if (
input.auth.type === "basic" &&
!hasPtyTicket(url) &&
!basicAuthorized(request.headers.authorization, queryToken, input.auth.password)
)
return unauthorized(true)
url.searchParams.delete("cli_token")
url.searchParams.delete("auth_token")
const requestOrigin = request.headers.host ? `http://${request.headers.host}` : input.origin
if (request.headers.origin !== undefined && request.headers.origin !== requestOrigin)
return HttpServerResponse.empty({ status: 403 })
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
const endpoint = yield* Ref.get(input.endpoint)
const target = new URL(url.pathname + url.search, endpoint.url)
if (request.headers.upgrade?.toLowerCase() === "websocket")
return yield* proxyWebSocket(request, target, input.websocket)
return yield* proxyHttp(input.client, request, target, Service.headers(endpoint), false, input.urls)
}
return yield* serveUI(input.client, request, url, input.assets)
})
}
function serveUI(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
url: URL,
assets: Readonly<Record<string, string>>,
) {
const key = url.pathname.replace(/^\//, "")
const file = assets[key] ?? assets["index.html"]
if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM), undefined, true)
if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
return Effect.tryPromise(() => readFile(file)).pipe(
Effect.map((body) => {
const html = key === "" || file === assets["index.html"]
const headers = {
"content-type": FSUtil.mimeType(file),
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html ? cspForHtml(body.toString()) : csp(),
"x-content-type-options": "nosniff",
}
if (request.method === "HEAD") return HttpServerResponse.empty({ headers })
return HttpServerResponse.raw(body, { headers })
}),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
}
function proxyHttp(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
target: URL,
extra: HeadersInit | undefined,
ui = false,
publicURLs?: ReadonlyArray<string>,
) {
return client
.execute(
HttpClientRequest.make(request.method as never)(target, {
headers: proxyHeaders(request.headers, extra),
body: requestBody(request),
}),
)
.pipe(
Effect.flatMap((response) => {
const headers = new Headers(response.headers)
headers.delete("content-encoding")
headers.delete("content-length")
headers.delete("set-cookie")
if (publicURLs && target.pathname === "/api/server")
return Effect.succeed(HttpServerResponse.jsonUnsafe({ urls: publicURLs }, { status: response.status }))
if (ui && response.headers["content-type"]?.includes("text/html")) {
return response.text.pipe(
Effect.map((body) => {
headers.set("content-security-policy", cspForHtml(body))
headers.set("cache-control", "no-store")
return HttpServerResponse.text(body, { status: response.status, headers })
}),
)
}
if (ui) headers.set("content-security-policy", csp())
return Effect.succeed(
HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
}),
)
}),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 502 }))),
)
}
function proxyWebSocket(
request: HttpServerRequest.HttpServerRequest,
target: URL,
websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>,
) {
target.protocol = target.protocol === "https:" ? "wss:" : "ws:"
return Effect.scoped(
Effect.gen(function* () {
const inbound = yield* Effect.orDie(request.upgrade)
const outbound = yield* Socket.makeWebSocket(target.toString(), {
protocols: protocols(request.headers["sec-websocket-protocol"]),
closeCodeIsError: () => false,
}).pipe(Effect.provideService(Socket.WebSocketConstructor, websocket))
const writeInbound = yield* inbound.writer
const writeOutbound = yield* outbound.writer
const close = Effect.all(
[writeInbound(new Socket.CloseEvent()), writeOutbound(new Socket.CloseEvent())],
{ concurrency: "unbounded", discard: true },
).pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))
yield* Effect.raceFirst(
outbound.runRaw((message) => writeInbound(typeof message === "string" ? message : message.slice())),
inbound.runRaw((message) => writeOutbound(typeof message === "string" ? message : message.slice())),
).pipe(Effect.catch(() => Effect.void), Effect.ensuring(close))
return HttpServerResponse.empty()
}).pipe(Effect.orDie),
)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
const length = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], length ? Number(length) : undefined)
}
function proxyHeaders(input: Record<string, string>, extra?: HeadersInit) {
const headers = new Headers(input)
for (const key of input.connection?.split(",").map((item) => item.trim()) ?? []) headers.delete(key)
for (const key of hop) headers.delete(key)
headers.delete("accept-encoding")
headers.delete("authorization")
headers.delete("cookie")
if (extra) for (const [key, value] of new Headers(extra)) headers.set(key, value)
return headers
}
function authorized(cookie: string | undefined, token: string) {
const value = cookie
?.split(";")
.map((item) => item.trim().split("="))
.find(([key]) => key === COOKIE)?.[1]
return matches(value ?? null, token)
}
function basicAuthorized(header: string | undefined, queryToken: string | null, password: string) {
const expected = Buffer.from(`opencode:${password}`).toString("base64")
if (matches(queryToken, expected)) return true
if (!header?.startsWith("Basic ")) return false
return matches(header.slice("Basic ".length), expected)
}
function hasPtyTicket(url: URL) {
return /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname) && !!url.searchParams.get("ticket")
}
function unauthorized(basic: boolean) {
return HttpServerResponse.empty({
status: 401,
headers: basic ? { "www-authenticate": 'Basic realm="Secure Area"' } : undefined,
})
}
function matches(value: string | null, expected: string) {
if (value === null) return false
const left = Buffer.from(value)
const right = Buffer.from(expected)
return left.length === right.length && timingSafeEqual(left, right)
}
function protocols(value: string | undefined) {
return value
?.split(",")
.map((item) => item.trim())
.filter(Boolean)
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
function bind(hostname: string, port: number | undefined) {
if (port !== undefined) return bindPort(hostname, port)
const next = (candidate: number): ReturnType<typeof bindPort> =>
bindPort(hostname, candidate).pipe(
Effect.catch((error) =>
candidate < 65_535 && addressInUse(error) ? next(candidate + 1) : Effect.fail(error),
),
)
return next(4096)
}
function bindPort(hostname: string, port: number) {
return Effect.gen(function* () {
const sockets = new Set<{ destroy(): void }>()
const server = createServer()
const scope = yield* Scope.make()
server.on("connection", (socket) => {
sockets.add(socket)
socket.once("close", () => sockets.delete(socket))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => sockets.forEach((socket) => socket.destroy())).pipe(
Effect.andThen(Scope.close(scope, Exit.void)),
),
)
const http = yield* NodeHttpServer.make(() => server, { host: hostname, port }).pipe(
Effect.provideService(Scope.Scope, scope),
)
return { http, scope }
})
}
function formatAddress(address: HttpServer.Address) {
if (address._tag === "UnixAddress") return HttpServer.formatAddress(address)
const hostname = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname
return `http://${hostname}:${address.port}`
}
function addressInUse(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false
if ("code" in error && error.code === "EADDRINUSE") return true
return "cause" in error && addressInUse(error.cause)
}
export * as WebUi from "./web-ui"
-4
View File
@@ -1,4 +0,0 @@
declare module "virtual:opencode-app-assets" {
const assets: Readonly<Record<string, string>>
export default assets
}
@@ -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 () => {
-240
View File
@@ -1,240 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test"
import { WebUi } from "../src/services/web-ui"
import type { Endpoint } from "@opencode-ai/client/effect/service"
import { Effect, Ref } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("TUI web UI", () => {
test("bootstraps a private browser session and proxies the current API endpoint", async () => {
const index = path.join(root, "index.html")
await writeFile(index, "<html><body>embedded</body></html>")
const first = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => Response.json({ server: "first" }),
})
const second = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => Response.json({ server: "second" }),
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: first.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const origin = new URL(launch).origin
expect((yield* Effect.promise(() => fetch(origin))).status).toBe(401)
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
expect(bootstrap.status).toBe(302)
expect(bootstrap.headers.get("location")).toBe("/")
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
expect(cookie).toStartWith("opencode-web=")
const page = yield* Effect.promise(() => fetch(origin, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => page.text())).toContain("embedded")
expect(page.headers.get("content-security-policy")).toContain("default-src 'self'")
const before = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => before.json())).toEqual({ server: "first" })
yield* Ref.set(endpoint, { url: second.url.toString() })
const after = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
expect(yield* Effect.promise(() => after.json())).toEqual({ server: "second" })
}),
),
)
} finally {
first.stop(true)
second.stop(true)
}
})
test("rejects foreign origins", async () => {
const index = path.join(root, "origin.html")
await writeFile(index, "embedded")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
const response = yield* Effect.promise(() =>
fetch(new URL(launch).origin, {
headers: { cookie: cookie ?? "", origin: "https://example.com" },
}),
)
expect(response.status).toBe(403)
}),
),
)
})
test("forwards websocket messages", async () => {
const index = path.join(root, "websocket.html")
await writeFile(index, "embedded")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
if (server.upgrade(request)) return
return new Response(null, { status: 426 })
},
websocket: {
message(socket, message) {
socket.send(message)
},
},
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const target = new URL("/api/pty/test/connect?ticket=test", launch)
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
target.protocol = "ws:"
const message = yield* Effect.promise(
() =>
new Promise<string>((resolve, reject) => {
const socket = new WebSocket(target)
socket.addEventListener("open", () => socket.send("hello"), { once: true })
socket.addEventListener("message", (event) => {
resolve(event.data.toString())
socket.close()
}, { once: true })
socket.addEventListener("error", reject, { once: true })
}),
)
expect(message).toBe("hello")
}),
),
)
} finally {
upstream.stop(true)
}
})
test("serves foreground UI with server credentials", async () => {
const index = path.join(root, "serve.html")
await writeFile(index, "<html>foreground</html>")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/api/server"
? Response.json({ urls: ["http://private"] })
: Response.json({ url: request.url }),
})
try {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make<Endpoint>({
url: upstream.url.toString(),
auth: { type: "basic", username: "opencode", password: "private" },
})
const origin = yield* WebUi.serve(endpoint, {
hostname: "127.0.0.1",
port: 0,
password: "secret",
assets: { "index.html": index },
})
const denied = yield* Effect.promise(() => fetch(origin))
expect(denied.status).toBe(401)
expect(denied.headers.get("www-authenticate")).toContain("Basic")
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
const page = yield* Effect.promise(() => fetch(origin, { headers: { authorization } }))
expect(yield* Effect.promise(() => page.text())).toContain("foreground")
const token = Buffer.from("opencode:secret").toString("base64")
const query = yield* Effect.promise(() => fetch(`${origin}/?auth_token=${encodeURIComponent(token)}`))
expect(query.status).toBe(200)
const proxied = yield* Effect.promise(() =>
fetch(`${origin}/api/health?auth_token=${encodeURIComponent(token)}&keep=yes`),
)
const proxiedBody = yield* Effect.promise(() => proxied.json())
expect(new URL(proxiedBody.url).search).toBe("?keep=yes")
const info = yield* Effect.promise(() => fetch(`${origin}/api/server`, { headers: { authorization } }))
expect(yield* Effect.promise(() => info.json())).toEqual({ urls: [origin] })
}),
),
)
} finally {
upstream.stop(true)
}
})
test("formats localhost listeners as valid URLs", async () => {
const index = path.join(root, "localhost.html")
await writeFile(index, "embedded")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
const origin = yield* WebUi.serve(endpoint, {
hostname: "localhost",
port: 0,
password: "secret",
assets: { "index.html": index },
})
expect(new URL(origin).protocol).toBe("http:")
}),
),
)
})
test("shuts down with an active websocket", async () => {
const index = path.join(root, "shutdown.html")
await writeFile(index, "embedded")
const upstream = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
if (server.upgrade(request)) return
return new Response(null, { status: 426 })
},
websocket: { message() {} },
})
let socket: WebSocket | undefined
try {
const run = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
const target = new URL("/api/pty/test/connect?ticket=test", launch)
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
target.protocol = "ws:"
socket = new WebSocket(target)
yield* Effect.promise(
() => new Promise<void>((resolve, reject) => {
socket?.addEventListener("open", () => resolve(), { once: true })
socket?.addEventListener("error", reject, { once: true })
}),
)
}),
),
)
await Promise.race([
run,
new Promise((_, reject) => setTimeout(() => reject(new Error("web UI shutdown timed out")), 2_000)),
])
} finally {
socket?.close()
upstream.stop(true)
}
})
})
-20
View File
@@ -17,23 +17,6 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(assets: readonly string[]): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `import path from "node:path"
const root = process.env.OPENCODE_NODE_ASSETS_DIR
export default root ? {${assets
.map((key) => `${JSON.stringify(key)}: path.join(root, "app", ${JSON.stringify(key)})`)
.join(",")}} : {}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -229,14 +212,12 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appAssets: readonly string[]
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appAssets),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -278,5 +259,4 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appAssets: [],
})
+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,71 +0,0 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Document } from "@opencode-ai/schema/config"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
yield* Effect.gen(function* () {
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
if (location.vcs?.type === "hg") {
const store = location.vcs.store
const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
if (!config.includes(".hg") && !config.includes(vcs)) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
Effect.forkScoped,
)
return Service.of({})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
})
+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)
-3
View File
@@ -15,7 +15,6 @@ import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
@@ -99,8 +98,6 @@ const locationServiceNodes = [
Snapshot.node,
SessionRunnerLLM.node,
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
+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
}),
}
}
+1 -1
View File
@@ -343,7 +343,7 @@ const layer = Layer.effect(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
@@ -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
@@ -92,8 +92,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
progress?: Tool.Metadata
}
>()
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) =>
tool.progress === undefined ? {} : { metadata: tool.progress }
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }, metadata?: Tool.Metadata) => {
if (tool.progress === undefined) return metadata === undefined ? {} : { metadata }
if (metadata === undefined) return { metadata: tool.progress }
return { metadata: { ...tool.progress, ...metadata } }
}
const assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
@@ -272,7 +275,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error, metadata?: Tool.Metadata) {
const tool = tools.get(id)
if (!tool || tool.settled) return false
tool.settled = true
@@ -281,7 +284,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
assistantMessageID: tool.assistantMessageID,
id,
error,
...failureSnapshot(tool),
...failureSnapshot(tool, metadata),
executed: tool.providerExecuted,
})
return true
@@ -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 }
+35 -4
View File
@@ -3,6 +3,7 @@ export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Form } from "../../form"
import { KV } from "../../kv"
import { Permission } from "../../permission"
@@ -52,7 +53,13 @@ export const Plugin = {
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
ctx.websearch.query(input).pipe(
websearch.default().pipe(
Effect.flatMap((provider) => {
if (!provider) return ctx.websearch.query(input)
return context
.progress({ provider: provider.id })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
}),
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
@@ -152,9 +159,33 @@ export const Plugin = {
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
switch (status) {
case 429:
return new ToolFailure({
message: "Web search rate limited (HTTP 429)",
error,
metadata: { provider: error.providerID },
})
case 401:
return new ToolFailure({
message: "Web search authentication failed (HTTP 401)",
error,
metadata: { provider: error.providerID },
})
case undefined:
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
default:
return new ToolFailure({
message: `Web search request failed (HTTP ${status})`,
error,
metadata: { provider: error.providerID },
})
}
}),
),
}),
)
+28 -20
View File
@@ -3,7 +3,6 @@ export * as Vcs from "./vcs"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,6 +10,8 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus"
import { Git } from "./git"
import { Watcher } from "./filesystem/watcher"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
@@ -44,29 +45,36 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const git = yield* Git.Service
const watcher = yield* Watcher.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
if (vcs && impl) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
const isBranchMetadata =
vcs.type === "git"
? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
: (file: string) => path.resolve(file) === path.join(store, "branch")
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.gen(function* () {
const discovered = vcs.type === "git" ? (yield* git.repo.discover(location.directory))?.gitDirectory : undefined
const target = discovered ?? vcs.store
const dir = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
const keep = vcs.type === "git" ? ["HEAD", "HEAD.lock"] : ["branch"]
const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (keep.includes(entry.name) ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore })
yield* updates.pipe(
Stream.filter((update) => keep.includes(path.basename(update.path))),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: update.path } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to watch vcs metadata", { cause })))
}
return Service.of({
@@ -88,5 +96,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
})
+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)
+7 -232
View File
@@ -1,28 +1,15 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
const it = testEffect(AppNodeBuilder.build(FSUtil.node))
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -40,7 +27,6 @@ describe("Watcher.testLayer", () => {
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
// subscriptions() reports acquired watches, so paths come back resolved.
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
@@ -126,167 +112,20 @@ describe("Watcher lifecycle", () => {
expect(counts.unsubscribes).toBe(0)
return consumer
}).pipe(withNative(native))
// Closing the layer scope tears the native subscription down while the
// consumer still holds a reference; the consumer's own release as its
// stream ends must not tear it down a second time.
yield* Fiber.join(consumer)
expect(counts.unsubscribes).toBe(1)
})
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
) {
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
if (options?.vcs === "hg") {
await fs.mkdir(path.join(tmp.path, ".hg"))
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
}),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
})
}
function maybeNextUpdate<E>(
check: (event: WatcherEvent) => boolean,
trigger: Effect.Effect<void, E>,
timeout: Duration.Input = "5 seconds",
) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
return Effect.gen(function* () {
const result = yield* maybeNextUpdate(check, trigger)
if (Option.isSome(result)) return result.value
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
})
}
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
return Effect.gen(function* () {
while (true) {
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
if (Option.isSome(result)) return result.value
}
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
}),
)
}
function ready(file: string, eventFile = file) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeNative("Watcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -333,68 +172,4 @@ describeNative("LocationWatcher", () => {
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes .git/HEAD events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
}),
{ vcs: "git" },
),
)
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
describeSymlink("symlinked .git", () => {
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate(
(event) => event.file === path.join(actual, "HEAD"),
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
),
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
await fs.symlink(actual, path.join(directory, ".git"))
},
},
),
)
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg" },
),
)
})
+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" })),
@@ -126,6 +126,19 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
})
})
test("local failure metadata completes the progress snapshot", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.progress(call.id, { phase: "running", provider: "old" }))
await Effect.runPromise(
publisher.failTool(call.id, { type: "tool.execution", message: "failed" }, { provider: "exa" }),
)
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
metadata: { phase: "running", provider: "exa" },
})
})
test("failure snapshot retains canonical progress above the default byte limit", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
await Effect.runPromise(publisher.publish(call))
+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(
+56
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
@@ -7,6 +8,7 @@ import { Form } from "@opencode-ai/core/form"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Session } from "@opencode-ai/core/session"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Tool } from "@opencode-ai/core/tool"
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -41,6 +43,7 @@ let formResponse: Form.TerminalState = { status: "cancelled" }
const formResponses: Form.TerminalState[] = []
let queryBarrier: Deferred.Deferred<void> | undefined
let synchronizedQueries = 0
let queryError: WebSearch.Error | undefined
let result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -56,6 +59,7 @@ beforeEach(() => {
formResponses.length = 0
queryBarrier = undefined
synchronizedQueries = 0
queryError = undefined
result = new WebSearch.Response({
providerID: WebSearch.ID.make("exa"),
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
@@ -94,6 +98,7 @@ const websearch = Layer.succeed(
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
yield* Deferred.await(queryBarrier)
}
if (queryError) return yield* queryError
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
if (typeof stored === "string")
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
@@ -376,4 +381,55 @@ describe("WebSearchTool registration", () => {
expect(queries).toHaveLength(1)
}),
)
it.effect("reports safe HTTP failures with the attempted provider", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
const tools = yield* registry.snapshot()
values.set("websearch:provider", "exa")
yield* Effect.forEach(
[
{ status: 403, message: "Web search request failed (HTTP 403)" },
{ status: 429, message: "Web search rate limited (HTTP 429)" },
{ status: 401, message: "Web search authentication failed (HTTP 401)" },
],
({ status, message }, index) =>
Effect.gen(function* () {
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
queryError = new WebSearch.RequestError({
providerID: WebSearch.ID.make("exa"),
cause: new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
description: "non 2xx status code",
}),
}),
})
const progress: Tool.Metadata[] = []
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: `call-http-${index}`,
name: "websearch",
input: { query: "effect" },
},
progress: (metadata) => Effect.sync(() => progress.push(metadata)),
})
.pipe(Effect.flip)
const sessionError = toSessionError(error)
expect(sessionError).toEqual({ type: "tool.execution", message })
expect(sessionError.message).not.toContain("secret")
expect(error.metadata).toEqual({ provider: "exa" })
expect(progress).toEqual([{ provider: "exa" }])
}),
{ discard: true },
)
}),
)
})
+4 -9
View File
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -42,7 +41,9 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
withTmp((directory) =>
Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
Effect.promise(() => hg(directory, "init")).pipe(
Effect.andThen(f(directory).pipe(provide(directory))),
),
)
async function hg(directory: string, ...args: string[]) {
@@ -124,13 +125,7 @@ describeHg("Vcs mercurial", () => {
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
event: "change",
})
expect(yield* Fiber.join(updated)).toMatchObject({
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
+128 -40
View File
@@ -8,27 +8,66 @@ import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const describeNative = process.env.CI ? describe.skip : describe
const locationLayer = (directory: string, git?: boolean) =>
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [[Location.node, locationLayer(directory, input.git)]]),
)
function fakeWatcher() {
const subscriptions: Watcher.WatchInput[] = []
const active = new Set<(update: Watcher.Update) => void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
return Promise.resolve()
},
}
}),
})
return {
subscriptions: () => [...subscriptions],
emit: (update: Watcher.Update) => {
for (const publish of active) publish(update)
},
layer: Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))),
}
}
const provideFake = (directory: string, fake: ReturnType<typeof fakeWatcher>, git = true) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
[Location.node, locationLayer(directory, git)],
[Watcher.node, fake.layer],
]),
)
@@ -93,35 +132,84 @@ describe("Vcs", () => {
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
}),
),
it.live("watches git branch metadata", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(() => initRepo(directory)).pipe(
Effect.andThen(
Effect.gen(function* () {
yield* Vcs.Service
expect(fake.subscriptions()).toHaveLength(1)
const git = fake.subscriptions()[0]
if (git?.type !== "directory") throw new Error("expected a directory watch")
expect(git.path).toBe(path.join(directory, ".git"))
expect(git.ignore ?? []).not.toContain("HEAD")
expect(git.ignore ?? []).toContain("objects")
}).pipe(provideFake(directory, fake)),
),
)
}),
)
it.live("caches branch info and publishes HEAD changes", () =>
withTmp((directory) => {
const fake = fakeWatcher()
return Effect.promise(async () => {
await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
fake.emit({ type: "update", path: path.join(directory, ".git", "HEAD.lock") })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}).pipe(provideFake(directory, fake)),
),
)
}),
)
describeNative("native watches", () => {
it.live("publishes branch updates on git checkout", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toMatchObject({ branch: { current: "main" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
expect(yield* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
_tag: "Some",
value: { data: { branch: "feature" } },
})
expect(yield* vcs.info()).toMatchObject({ branch: { current: "feature" } })
}),
),
{ timeout: 15_000 },
)
})
it.live("diffs the working copy against HEAD with patches", () =>
withGit((directory) =>
Effect.gen(function* () {
+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
+105 -30
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
@@ -42,11 +55,11 @@ sequenceDiagram
Browser Server
GET /
GET /
401 WWW-Auth
401 WWW-Auth
`)
})
@@ -70,15 +83,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 +122,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("")),
)
})
@@ -245,18 +258,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
@@ -294,22 +318,22 @@ sequenceDiagram
A B
open solid
open solid
>
open dashed
open dashed
<
failed solid
failed solid
failed dashed
failed dashed
async solid
async solid
)
async dashed
async dashed
(
"
`)
@@ -533,7 +557,7 @@ sequenceDiagram
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -581,6 +605,42 @@ sequenceDiagram
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
@@ -600,11 +660,11 @@ sequenceDiagram
Browser API Cache DB
GET /users/42
GET /users/42
get user:42
get user:42
"
`)
@@ -619,12 +679,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
@@ -639,12 +712,12 @@ sequenceDiagram
Check Permissions
"
`)
})
test("places two spacer rows above note badges and one below", () => {
test("frames notes in their reserved rows", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -656,9 +729,11 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow - 1]).toContain("")
expect(lines[noteRow]).toContain("│ phase │")
expect(lines[noteRow + 1]).toContain("╰")
expect(lines[noteRow + 1]).toContain("╯")
expect(nextMessageRow).toBe(noteRow + 2)
})
+52 -9
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,32 @@ function renderSelfMessage(
setCell(grid, rightX, bottomRow, SEQUENCE_BORDER.bottomRight, style)
}
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
const width = Math.max(...placement.textLines.map(diagramTextWidth))
const left = placement.textX
const right = left + width - 1
const top = placement.textY - 1
const bottom = placement.textY + placement.textLines.length
for (let x = left + 1; x < right; x++) {
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
}
for (let y = top + 1; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
for (let y = placement.textY; y < bottom; y++) {
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
}
}
export function drawSequenceDiagramGrid(
diagram: SequenceDiagram,
options: SequenceDiagramRenderOptions = {},
@@ -197,11 +228,13 @@ 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 {
for (let x = headerLeftX; x <= headerRightX; x++) {
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
@@ -210,11 +243,15 @@ export function drawSequenceDiagramGrid(
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")
for (let y = participantHeaderY; y < participantRuleY; y++) {
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerRightX, y, 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")
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
@@ -227,9 +264,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 +305,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,34 @@ 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("centers message label blocks over 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")!
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
})
})
+92 -37
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(5, width + 4)
}
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 = Math.floor((leftX + rightX - labelWidth) / 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 participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
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 = Math.floor((leftX + rightX - renderedLabelWidth) / 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:",
+16 -33
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()
@@ -179,7 +180,6 @@ export type TuiInput = {
args: Args
config: Config.Interface
packages: PackageResolver
web?: () => Promise<string>
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
@@ -252,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(() =>
@@ -288,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={{
@@ -337,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}
@@ -376,7 +387,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
directories={pluginDirectories}
>
<App
web={input.web}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@@ -436,7 +446,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string> }) {
function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -506,7 +516,7 @@ function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string>
if (!text || text.length === 0) return
await clipboard
.write?.(text)
.write(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -954,33 +964,6 @@ function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string>
},
category: "System",
},
...(props.web
? [
{
name: "web.open",
title: "Open web interface",
slash: { name: "web" },
run: async () => {
const web = props.web
if (!web) return
const url = await web().catch((error) => {
toast.error(error)
return undefined
})
if (!url) return
await open(url).catch(() =>
toast.show({
title: "Could not open browser",
message: `Open ${url} manually.`,
variant: "warning",
}),
)
dialog.clear()
},
category: "System",
},
]
: []),
{
name: "app.exit",
title: "Exit the app",
+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 -45
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
@@ -72,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
@@ -312,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(
@@ -391,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",
@@ -561,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",
@@ -614,6 +655,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1262,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) => {
@@ -1307,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 + " "
@@ -1344,7 +1413,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1468,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()}
@@ -1496,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
@@ -1506,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
}
@@ -1519,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
@@ -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()
+6 -1
View File
@@ -312,7 +312,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const current = child.tools.get(key)
const output = toolOutputText(part.name, toolDisplayContent(part.state))
if (part.state.status === "running") {
if (!current || current.part.state.status === "streaming")
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
const awaitingProvider =
current?.part.name === "websearch" &&
current.part.state.status === "running" &&
typeof current.part.state.metadata.provider !== "string"
if (ready && (!current || current.part.state.status === "streaming" || awaitingProvider))
setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory))
if (output) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
child.tools.set(key, { part })
+7 -4
View File
@@ -120,6 +120,7 @@ type ToolState = {
part: SessionMessageAssistantTool
output: string
version: number
started: boolean
}
type State = {
@@ -609,7 +610,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
state.toolSources.set(key, part)
if (part.state.status === "streaming") {
state.tools.set(key, { part, output: "", version: 0 })
state.tools.set(key, { part, output: "", version: 0, started: false })
return
}
const current = state.tools.get(key)
@@ -618,16 +619,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
const delta = current && prefix ? output.slice(current.output.length) : output
if (part.state.status === "running") {
if (render && (!current || current.part.state.status === "streaming"))
const started = current?.started === true
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
if (render && !started && ready)
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)], {
phase: "running",
status: `running ${part.name}`,
})
if (render && delta) write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
state.tools.set(key, { part, output, version })
state.tools.set(key, { part, output, version, started: started || (render && ready) })
return
}
if (render && (!current || current.part.state.status === "streaming"))
if (render && !current?.started)
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)])
state.finishedTools.add(key)
state.tools.delete(key)
@@ -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>
+111 -18
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
@@ -847,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()
@@ -865,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" })
@@ -899,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
@@ -1326,18 +1330,20 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const theme = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const visible = 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.some((part) => {
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
}) ?? false
)
const part = current?.content.find((part): part is SessionMessageAssistantTool => {
if (part.type !== "tool" || part.state.status !== "running") return false
const name = canonicalToolName(part.name)
return name === "shell" || name === "subagent"
})
if (!current || !part) return
return `${current.id}:${part.id}`
})
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
return (
<Show when={visible() && shortcut()}>
{(value) => (
@@ -1587,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>
@@ -1597,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")
@@ -1607,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 (
<>
@@ -1893,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
@@ -1914,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)
@@ -2206,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 = {
@@ -2230,7 +2242,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
},
}
return (
const content = (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2276,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),

Some files were not shown because too many files have changed in this diff Show More