Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad 0c34e6669f feat(tui): continue pending work after interrupt 2026-08-10 13:52:13 +00:00
118 changed files with 1191 additions and 4189 deletions
+56 -32
View File
@@ -577,7 +577,6 @@
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"entities": "7.0.1",
"string-width": "catalog:",
},
"devDependencies": {
@@ -613,9 +612,9 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -903,6 +902,7 @@
"@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": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@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.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@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.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": ["@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@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-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@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-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@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-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@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/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@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/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/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=="],
"@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=="],
"@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.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -3622,6 +3622,8 @@
"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=="],
@@ -4038,7 +4040,7 @@
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"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=="],
"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=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
@@ -4320,7 +4322,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@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4464,6 +4466,8 @@
"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=="],
@@ -4802,7 +4806,7 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
@@ -4928,7 +4932,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@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
@@ -4954,7 +4958,7 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
@@ -5556,7 +5560,7 @@
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -5588,6 +5592,8 @@
"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=="],
@@ -6526,6 +6532,8 @@
"@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=="],
@@ -6710,11 +6718,9 @@
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"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=="],
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -6790,6 +6796,8 @@
"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=="],
@@ -7556,6 +7564,20 @@
"@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=="],
@@ -8616,6 +8638,8 @@
"@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.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@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": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
+5 -41
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, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
Array.from(secretValues(request)).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, secrets: ReadonlySet<string>) => {
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
if (body === undefined) return {}
const redacted = redactBody(body, secrets)
const redacted = redactBody(body, request)
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, secretValues(request))
const details = responseBody(body, request)
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
@@ -261,42 +261,6 @@ 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 { Node, Window } from "happy-dom"
import { Window } from "happy-dom"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
@@ -18,7 +18,6 @@ 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")
@@ -41,24 +40,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
try {
mutations.append(targetWindow.document.body, unrelated)
mutations.remove(unrelated)
expect(calls).toEqual([])
targetWindow.document.body.append(unrelated)
unrelated.remove()
await frames(2, targetWindow)
expect(calls).toEqual([])
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 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]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
}
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()
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
@@ -205,33 +204,7 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
}
}
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]))
},
}
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)
}
@@ -11,9 +11,7 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
expect(await acp.close()).toBe(0)
expect(await fixture.spawn().close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
+1 -1
View File
@@ -907,7 +907,7 @@ export type Endpoint5_31Output =
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
@@ -596,7 +596,10 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
@@ -875,6 +875,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
query: { continue: input["continue"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
@@ -3888,7 +3888,10 @@ export type SessionLogInput = {
export type SessionLogOutput = SessionLogItem
export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInterruptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
}
export type SessionInterruptOutput = void
+1 -1
View File
@@ -199,7 +199,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const log = yield* client.session
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
.pipe(Stream.runCollect)
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test"), continue: true })
const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
+2 -2
View File
@@ -543,7 +543,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
await client.session.interrupt({ sessionID: "ses_test" })
await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -568,7 +568,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
+2 -59
View File
@@ -1,7 +1,6 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { APICallError } from "@ai-sdk/provider"
import type {
JSONSchema7,
JSONValue,
@@ -23,7 +22,6 @@ import {
LanguageModel,
ProviderID,
ProviderMetadata,
TransportReason,
ToolResultValue,
UnknownProviderReason,
type ContentPart,
@@ -31,7 +29,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, 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"
@@ -725,9 +723,7 @@ function llmError(method: string, error: unknown) {
const reason =
error instanceof AIError
? new InvalidProviderOutputReason({ message: error.message })
: APICallError.isInstance(error)
? apiCallErrorReason(error)
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
return new AIError({
module: "AISDK",
method,
@@ -735,57 +731,4 @@ 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: [] })
+1 -9
View File
@@ -151,15 +151,7 @@ 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.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
@@ -0,0 +1,71 @@
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,10 +47,6 @@ 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,6 +15,7 @@ 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"
@@ -98,6 +99,8 @@ 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)
+7 -49
View File
@@ -46,24 +46,7 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass<Unsupported
}
}
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 type Error = VariantUnavailableError | UnsupportedPackageError | 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. */
@@ -158,17 +141,12 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): 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
): 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)
})
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
@@ -248,26 +226,6 @@ 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 }
+31 -3
View File
@@ -59,8 +59,36 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
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",
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) } : {}),
})),
}
})
)
}
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
}),
}
}
+4 -2
View File
@@ -267,7 +267,7 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -848,7 +848,9 @@ const layer = Layer.effect(
}),
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID))),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
+2 -2
View File
@@ -20,7 +20,7 @@ export interface Interface {
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -107,7 +107,7 @@ export const layer = Layer.effect(
return Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
interrupt: (sessionID, options) => coordinator.interrupt(sessionID, "user", { preserveWake: options?.continue }),
resume: coordinator.run,
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
+4 -4
View File
@@ -10,8 +10,8 @@ export interface Coordinator<Key, E, Reason = never> {
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Stops the active execution and waits for cleanup. Clears its doorbell unless preservation is requested. */
readonly interrupt: (key: Key, reason?: Reason, options?: { preserveWake?: boolean }) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -124,12 +124,12 @@ export const make = <Key, E, Reason = never>(options: {
start(key, false)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
const interrupt = (key: Key, reason?: Reason, options?: { preserveWake?: boolean }): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
execution.stopping = true
execution.pendingWake = false
if (!options?.preserveWake) execution.pendingWake = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
})
+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), error.metadata).pipe(Effect.asVoid),
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
@@ -34,8 +34,6 @@ 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,11 +92,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
progress?: Tool.Metadata
}
>()
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 failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) =>
tool.progress === undefined ? {} : { metadata: tool.progress }
const assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
@@ -275,7 +272,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* flushFragments()
})
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error, metadata?: Tool.Metadata) {
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
const tool = tools.get(id)
if (!tool || tool.settled) return false
tool.settled = true
@@ -284,7 +281,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
assistantMessageID: tool.assistantMessageID,
id,
error,
...failureSnapshot(tool, metadata),
...failureSnapshot(tool),
executed: tool.providerExecuted,
})
return true
@@ -52,8 +52,7 @@ 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.UnresolvedProviderVariablesError
cause instanceof SessionRunnerModel.UnsupportedPackageError
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
+4 -35
View File
@@ -3,7 +3,6 @@ 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"
@@ -53,13 +52,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
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 })))
}),
ctx.websearch.query(input).pipe(
Effect.catch((error) => {
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
return providerSelectionLock
@@ -159,33 +152,9 @@ export const Plugin = {
: NO_RESULTS
return { output, content, metadata: { provider: output.provider } }
}).pipe(
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 },
})
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
),
}),
)
+20 -28
View File
@@ -3,6 +3,7 @@ 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"
@@ -10,8 +11,6 @@ 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"
@@ -45,36 +44,29 @@ 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) {
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 })))
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 }),
)
}
return Service.of({
@@ -96,5 +88,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node, Git.node, Watcher.node],
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
+1 -171
View File
@@ -1,11 +1,8 @@
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, isContextOverflowFailure } from "@opencode-ai/ai"
import { LLM, AIError, LLMEvent, Message } 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"
@@ -340,170 +337,3 @@ 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,7 +465,6 @@ 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,7 +185,6 @@ 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") })
+3 -8
View File
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
it.live("loads authenticated wellknown config before user configuration", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -322,7 +322,6 @@ describe("Config", () => {
})
const integrationID = Integration.ID.make("https://example.com")
let available = false
let key = "secret"
const credentialNode = makeGlobalNode({
service: Credential.Service,
@@ -362,10 +361,7 @@ describe("Config", () => {
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
resolve: (_entry, variables) =>
available
? Effect.succeed([{ shell: variables.TOKEN }])
: Effect.fail(new Error("expired credential")),
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
}),
),
deps: [],
@@ -378,12 +374,11 @@ describe("Config", () => {
expect(Config.latest(initial, "shell")).toBe("project")
expect(
initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
).toEqual(["global", "project"])
).toEqual(["secret", "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)
+233 -8
View File
@@ -1,15 +1,28 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
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(FSUtil.node))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
const configLayer = Config.testLayer()
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
@@ -27,6 +40,7 @@ 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)),
)
@@ -112,20 +126,167 @@ 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 withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
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)
}
describeNative("Watcher", () => {
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>
},
) {
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))))
}
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", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -172,4 +333,68 @@ describeNative("Watcher", () => {
}).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,27 +42,6 @@ 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* () {
@@ -251,42 +230,6 @@ 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("preserves provider and model URL templates in the catalog", () =>
it.effect("resolves declared environment variables in provider and model URLs", () =>
withEnv(
{
ACME_HOST: "api.acme.test",
@@ -298,10 +298,10 @@ describe("ModelsDevPlugin", () => {
)
expect((yield* catalog.provider.get(providerID))?.settings?.baseURL).toBe(
"https://${ACME_HOST}/${UNDECLARED_HOST}/v1",
"https://api.acme.test/${UNDECLARED_HOST}/v1",
)
expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe(
"https://${ACME_HOST}/${ACME_MODEL_PATH}/v1",
"https://api.acme.test/${ACME_MODEL_PATH}/v1",
)
}),
),
-16
View File
@@ -19,9 +19,6 @@ 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"
@@ -94,19 +91,6 @@ 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" })),
@@ -301,6 +301,37 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("continues pending work after interruption when preserving the wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined),
),
),
})
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session", "user", { preserveWake: true })
yield* Deferred.await(secondStarted)
const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("runs a wake registered during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -132,7 +132,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
interrupt: (sessionID, options) =>
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -126,19 +126,6 @@ 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))
+2 -1
View File
@@ -391,7 +391,8 @@ const execution = Layer.effect(
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
interrupt: (sessionID, options) =>
coordinator.interrupt(sessionID, undefined, { preserveWake: options?.continue }),
awaitIdle: coordinator.awaitIdle,
})
}),
+21 -24
View File
@@ -286,30 +286,27 @@ 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)),
),
{ timeout: 15_000 },
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(
-56
View File
@@ -1,6 +1,5 @@
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"
@@ -8,7 +7,6 @@ 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"
@@ -43,7 +41,6 @@ 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: {} }],
@@ -59,7 +56,6 @@ 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: {} }],
@@ -98,7 +94,6 @@ 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 })
@@ -381,55 +376,4 @@ 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 },
)
}),
)
})
+9 -4
View File
@@ -8,6 +8,7 @@ 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"
@@ -41,9 +42,7 @@ 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[]) {
@@ -125,7 +124,13 @@ 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* Fiber.join(updated).pipe(Effect.timeout("5 seconds"))).toMatchObject({
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({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
+37 -125
View File
@@ -8,66 +8,27 @@ 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 { Watcher } from "@opencode-ai/core/filesystem/watcher"
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"
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, locationLayer(directory, git)],
[Watcher.node, fake.layer],
[
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")) } } : {},
),
),
),
],
]),
)
@@ -132,84 +93,35 @@ describe("Vcs", () => {
),
)
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" } })
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())
fake.emit({ type: "update", path: path.join(directory, ".git", "index.lock") })
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", "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)),
),
)
}),
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" } })
}),
),
)
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,7 +14,6 @@
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"entities": "7.0.1",
"string-width": "catalog:"
},
"devDependencies": {
-87
View File
@@ -7,18 +7,6 @@ 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)
@@ -38,21 +26,6 @@ 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)
@@ -75,9 +48,6 @@ 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", () => {
@@ -123,61 +93,4 @@ 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)
})
})
+22 -64
View File
@@ -47,12 +47,7 @@ export class DiagramCanvasSizeError extends Error {
readonly width: number,
readonly height: number,
) {
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`,
)
super(`Diagram canvas ${width}x${height} exceeds the ${MAX_DIAGRAM_CELLS.toLocaleString()} cell limit`)
this.name = "DiagramCanvasSizeError"
}
}
@@ -66,31 +61,29 @@ function sameKey(left: readonly unknown[] | undefined, right: readonly unknown[]
}
export class DiagramCanvas<Style extends string, Metadata extends object = object> {
private readonly cells: Array<Array<DiagramCanvasCell<Style, Metadata>>>
readonly rows: 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.cells = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
this.rowEnds = new Uint32Array(height)
this.rows = Array.from({ length: height }, () => Array.from({ length: width }, () => createEmptyCell()))
}
get rows(): ReadonlyArray<ReadonlyArray<Readonly<DiagramCanvasCell<Style, Metadata>>>> {
return this.cells
private rowTextEnd(row: Array<DiagramCanvasCell<Style, Metadata>>): number {
let rowEnd = row.length
while (rowEnd > 0 && row[rowEnd - 1]?.char === " ") rowEnd -= 1
return rowEnd
}
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd: number): string {
private rowText(row: Array<DiagramCanvasCell<Style, Metadata>>, rowEnd = this.rowTextEnd(row)): string {
return row
.slice(0, rowEnd)
.map((cell) => cell.char)
@@ -99,58 +92,28 @@ 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.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
let end = this.rows.length
if (trimTop) while (start < end && this.rowTextEnd(this.rows[start]!) === 0) start += 1
if (trimBottom) while (end > start && this.rowTextEnd(this.rows[end - 1]!) === 0) end -= 1
return { start, end }
}
setCell(x: number, y: number, char: string, style?: Style, metadata?: Partial<Metadata>): void {
this.writeCell(x, y, char, style, metadata, true)
}
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
if (y < 0 || y >= this.rows.length || x < 0 || x >= this.rows[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
}
this.rows[y]![x] = this.mergeCell?.(this.rows[y]![x]!, incoming) ?? incoming
}
getCell(x: number, y: number): Readonly<DiagramCanvasCell<Style, Metadata>> | undefined {
return this.cells[y]?.[x]
getCell(x: number, y: number): DiagramCanvasCell<Style, Metadata> | undefined {
return this.rows[y]?.[x]
}
setText(x: number, y: number, text: string, style?: Style, metadata?: DiagramCanvasTextMetadata<Metadata>): void {
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))
@@ -163,7 +126,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.cells[rowIndex]!, this.rowEnds[rowIndex]!))
lines.push(this.rowText(this.rows[rowIndex]!))
}
return lines.join("\n")
}
@@ -172,18 +135,13 @@ 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.cells[rowIndex]!
const rowEnd = this.rowEnds[rowIndex]!
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
if (rowEnd > 0) width = Math.max(width, this.measure(this.rowText(row, rowEnd)))
}
return { width, height: rows.end - rows.start }
}
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,
@@ -193,8 +151,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.cells[rowIndex]!
const rowEnd = this.rowEnds[rowIndex]!
const row = this.rows[rowIndex]!
const rowEnd = this.rowTextEnd(row)
let currentCell: DiagramCanvasCell<Style, Metadata> | undefined
let currentKey: readonly unknown[] | undefined
+2 -7
View File
@@ -27,12 +27,7 @@ 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 decodeMermaidText(trimmed.slice(1, -1))
return trimmed.slice(1, -1)
}
return decodeMermaidText(trimmed)
return trimmed
}
export function decodeMermaidText(value: string): string {
return decodeHTMLStrict(value)
}
import { decodeHTMLStrict } from "entities"
-87
View File
@@ -1,87 +0,0 @@
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
@@ -1,242 +0,0 @@
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 this renderer does not support. */
/** An otherwise valid diagram contains syntax that merman does not support. */
export class MermaidSyntaxError extends Error {
readonly _tag = "MermaidSyntaxError"
+6 -13
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, route.labelAxis)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
for (const [index, line] of label.lines.entries()) {
grid.setText(label.point.x, label.point.y + index, line, style)
}
@@ -249,22 +249,15 @@ function drawSourceConnectors(
if (routeDirection && connectorDirection) {
const cell = grid.getCell(sourcePoint.x, sourcePoint.y)
if (cell) {
grid.replaceCell(
sourcePoint.x,
sourcePoint.y,
diagramLineGlyph(
new Set([routeDirection, connectorDirection]),
"rounded",
route.edge.style === "thick" ? "heavy" : "single",
),
"edge",
cell.char = diagramLineGlyph(
new Set([routeDirection, connectorDirection]),
"rounded",
route.edge.style === "thick" ? "heavy" : "single",
)
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")
}
}
}
+2 -265
View File
@@ -1,6 +1,7 @@
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 {
@@ -8,7 +9,6 @@ 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,63 +55,6 @@ 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(
@@ -265,67 +208,6 @@ 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]
@@ -356,36 +238,6 @@ 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]
@@ -537,14 +389,6 @@ 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)]`
@@ -590,25 +434,6 @@ 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]
@@ -1059,94 +884,6 @@ 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
@@ -1574,6 +1311,6 @@ flowchart LR
const node = parseColor("#ff0000")
const styled = renderGridStyledText(grid, resolveFlowchartStyleColors({ node }))
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && chunk.fg?.equals(node))).toBe(true)
expect(styled.chunks.some((chunk) => chunk.text.includes("Alpha") && colorsEqual(chunk.fg, node))).toBe(true)
})
})
@@ -67,22 +67,6 @@ 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(
[
+6 -17
View File
@@ -67,23 +67,14 @@ function segmentLabelPoint(segment: DiagramSegment, labelWidth: number, labelHei
return clampPoint(shiftPoint(center, "up", Math.floor((labelHeight - 1) / 2)))
}
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
function bestLabelSegment(points: readonly FlowchartPoint[], labelWidth: number): DiagramSegment | undefined {
let roomyHorizontal: DiagramSegment | undefined
let verticalBus: DiagramSegment | undefined
let longest: DiagramSegment | undefined
for (const segment of segments) {
for (let index = 1; index < points.length; index++) {
const segment = segmentBetween(points[index - 1]!, points[index]!)
if (!segment) continue
if (!roomyHorizontal && segment.axis === "x" && inlineLabelSlot(segment, labelWidth).fits) roomyHorizontal = segment
if (!verticalBus && segment.axis === "y") verticalBus = segment
if (!longest || segment.length > longest.length) longest = segment
@@ -96,9 +87,8 @@ function flowchartLabelPoint(
points: readonly FlowchartPoint[],
labelWidth: number,
labelHeight: number,
preferredAxis?: DiagramSegment["axis"],
): FlowchartPoint {
const segment = bestLabelSegment(points, labelWidth, preferredAxis)
const segment = bestLabelSegment(points, labelWidth)
return segment ? segmentLabelPoint(segment, labelWidth, labelHeight) : (points[0] ?? point(0, 0))
}
@@ -106,10 +96,9 @@ 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, preferredAxis), width, height }
return { lines, point: flowchartLabelPoint(points, width, height), width, height }
}
+4 -19
View File
@@ -27,7 +27,6 @@ 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
@@ -317,7 +316,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, route.labelAxis)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, visualLength)
const { point, width, height } = label
return {
left: point.x,
@@ -359,6 +358,9 @@ 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[]>()
@@ -373,23 +375,6 @@ 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)
+5 -100
View File
@@ -8,7 +8,6 @@ import type {
} from "./types.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import {
decodeMermaidText,
firstMeaningfulMermaidLine,
meaningfulNumberedMermaidLines,
stripMermaidQuotes as stripQuotes,
@@ -29,9 +28,8 @@ 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()
@@ -81,20 +79,6 @@ 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())
}
@@ -138,11 +122,9 @@ 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
}
@@ -152,91 +134,25 @@ interface ParsedEdgeOperator {
label: string
style: FlowchartEdgeStyle | undefined
arrowhead: boolean
sourceArrowhead: boolean
orderOnly: boolean
}
function parseEdgeOperators(line: string): ParsedEdgeOperator[] {
return [...maskNodeLabelOperators(line).matchAll(EDGE_OPERATOR_RE)].map((match) => {
return [...line.matchAll(EDGE_OPERATOR_RE)].map((match) => {
const inlineDashedArrow = match[1]
const startArrow = inlineDashedArrow ?? match[3] ?? match[6]!
const endArrow = inlineDashedArrow ?? match[5] ?? match[6]!
return {
index: match.index,
end: match.index + match[0].length,
label: decodeMermaidText((match[2] ?? match[4] ?? match[7] ?? "").trim()),
label: (match[2] ?? match[4] ?? match[7] ?? "").trim(),
style: edgeStyleFromArrow(startArrow, endArrow),
arrowhead: endArrow === "~~~" || endArrow.endsWith(">"),
sourceArrowhead: startArrow.startsWith("<"),
arrowhead: endArrow !== "---",
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) ?? "")
}
@@ -250,7 +166,6 @@ 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])
@@ -307,15 +222,6 @@ 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
@@ -333,7 +239,6 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
@@ -341,7 +246,7 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
}
}
if (isSupportedNodeToken(line)) {
if (hasExplicitNodeShape(line) || ID_ONLY_RE.test(stripNodeToken(line))) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
@@ -1,6 +1,4 @@
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"
@@ -23,31 +21,6 @@ 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: "" }
@@ -296,79 +269,4 @@ 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)
})
})
+37 -234
View File
@@ -15,7 +15,6 @@ import {
pathViaLane,
sideForDirection,
snapCoordinate,
shiftPoint,
withCoordinate,
type DiagramAxis,
type DiagramDirection,
@@ -23,7 +22,7 @@ import {
type DiagramSide,
} from "../core/geometry.js"
import { diagramTextWidth, splitDiagramLines } from "../core/text.js"
import { flowchartEdgeLabelLayout, type FlowchartEdgeLabelLayout } from "./labels.js"
import { flowchartEdgeLabelLayout } from "./labels.js"
import type {
FlowchartDiagram,
FlowchartDirection,
@@ -131,9 +130,7 @@ function horizontalEdgePath(
const travel = horizontalTravel(from, to, direction)
const startSide = sideForDirection(travel)
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)), {
preferredAxis: "x",
})
return orthogonalPath(boundsSidePoint(from, startSide), boundsSidePoint(to, oppositeSide(startSide)))
}
function selfEdgePath(bounds: FlowchartNodeBounds): FlowchartPoint[] {
@@ -168,7 +165,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, route.labelAxis)
const label = flowchartEdgeLabelLayout(route.points, route.edge.label, diagramTextWidth)
right = Math.max(right, label.point.x + label.width - 1)
}
return right
@@ -182,14 +179,6 @@ 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)
@@ -222,7 +211,7 @@ function targetFanInLane(
afterFarthestCoordinate(sourcePorts, axis, travel, NODE_CLEARANCE),
travel,
)
return keepBefore(unclamped, advanceCoordinate(targetCoordinate, travel, -1), travel)
return keepBefore(unclamped, targetCoordinate, travel)
}
function portForTravel(bounds: FlowchartNodeBounds, travel: DiagramDirection, role: PortRole): FlowchartPoint {
@@ -479,11 +468,7 @@ 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: FlowchartEdgeRoute = {
edge,
points: parallelEdgePath(from, to, direction, laneCoordinate),
labelAxis: isVerticalDirection(direction) ? "y" : "x",
}
const route = { edge, points: parallelEdgePath(from, to, direction, laneCoordinate) }
routes.push(route)
handled.add(edge)
previousRoute = route
@@ -586,238 +571,59 @@ function routeHorizontalSubgraphEntries(
}
}
function pathIntersectsBounds(
points: readonly FlowchartPoint[],
bounds: { left: number; top: number; width: number; height: number },
allowedContact: "source" | "target" | "both" | undefined = undefined,
): boolean {
function pathIntersectsBounds(points: readonly FlowchartPoint[], bounds: FlowchartNodeBounds): boolean {
const right = bounds.left + bounds.width - 1
const bottom = bounds.top + bounds.height - 1
for (let index = 1; index < points.length; index++) {
const from = points[index - 1]!
const to = points[index]!
if (from.x === to.x) {
if (from.x < bounds.left || from.x > right) 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
if (
from.x >= bounds.left &&
from.x <= right &&
Math.max(from.y, to.y) >= bounds.top &&
Math.min(from.y, to.y) <= bottom
) {
return true
}
continue
}
if (from.y < bounds.top || from.y > bottom) 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
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 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>,
subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds> | undefined,
routeIndex: number,
direction: FlowchartDirection,
): FlowchartEdgeRoute {
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)]
: [],
const obstacle = [...bounds.values()].some(
(bound) => bound.id !== route.edge.from && bound.id !== route.edge.to && pathIntersectsBounds(route.points, bound),
)
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
if (!obstacle) return route
const from = bounds.get(route.edge.from)
const to = bounds.get(route.edge.to)
if (!from || !to) return route
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
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) }
}
return (
candidates.find((candidate) => !intersectsObstacle(candidate)) ?? shortestValid(preservedTargetCandidates) ?? 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) }
}
export function routeFlowchartEdges(
@@ -865,10 +671,7 @@ export function routeFlowchartEdges(
if (!from || !to) continue
routes.push({ edge, points: edgePath(from, to, directionForEdge(edge), leftBoundary) })
}
for (let index = routes.length - 1; index >= 0; index--) {
routes[index] = avoidNodeObstacles(routes[index]!, routes, bounds, subgraphBounds, index)
}
return routes
return routes.map((route) => avoidNodeObstacles(route, bounds, directionForEdge(route.edge)))
}
function sideForOutsidePoint(bounds: FlowchartNodeBounds, sourcePoint: FlowchartPoint): DiagramSide {
+1 -3
View File
@@ -1,4 +1,4 @@
import type { DiagramAxis, DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
import type { DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
export type FlowchartNodeShape = "box" | "rounded" | "database" | "decision" | "subroutine"
@@ -16,7 +16,6 @@ export interface FlowchartEdge {
label: string
style?: FlowchartEdgeStyle
arrowhead?: false
sourceArrowhead?: true
orderOnly?: boolean
}
@@ -56,7 +55,6 @@ export type FlowchartPoint = DiagramPoint
export interface FlowchartEdgeRoute {
edge: FlowchartEdge
points: FlowchartPoint[]
labelAxis?: DiagramAxis
}
export type FlowchartEdgeDirection = DiagramDirection
+30 -105
View File
@@ -1,5 +1,4 @@
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"
@@ -29,18 +28,6 @@ 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
@@ -55,11 +42,11 @@ sequenceDiagram
Browser Server
GET /
GET /
401 WWW-Auth
401 WWW-Auth
`)
})
@@ -83,15 +70,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
`)
})
@@ -122,7 +109,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("")),
)
})
@@ -258,29 +245,18 @@ sequenceDiagram
])
})
test("renders activation syntax as visible intervals", () => {
test("parses activation syntax without rendering activation bars", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>+Server: request
Server-->>-Browser: response
`)
expect(output).toContain("┃")
expect(output).not.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
@@ -318,22 +294,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
(
"
`)
@@ -557,7 +533,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(2)
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
})
test("keeps long notes inside groups and nested fragment frames intact", () => {
@@ -605,42 +581,6 @@ 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
@@ -660,11 +600,11 @@ sequenceDiagram
Browser API Cache DB
GET /users/42
GET /users/42
get user:42
get user:42
"
`)
@@ -679,25 +619,12 @@ 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
@@ -712,12 +639,12 @@ sequenceDiagram
Check Permissions
"
`)
})
test("frames notes in their reserved rows", () => {
test("places two spacer rows above note badges and one below", () => {
const output = renderSequenceDiagram(`
sequenceDiagram
Browser->>Server: one
@@ -729,11 +656,9 @@ sequenceDiagram
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
expect(noteRow).toBeGreaterThan(0)
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(lines[noteRow - 1]?.trim()).toBe("│ │")
expect(lines[noteRow - 2]?.trim()).toBe("│ │")
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
expect(nextMessageRow).toBe(noteRow + 2)
})
+9 -52
View File
@@ -1,6 +1,5 @@
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,
@@ -20,10 +19,6 @@ 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":
@@ -33,7 +28,7 @@ function arrowHeadChar(head: SequenceArrowHead | undefined, direction: 1 | -1):
case "async":
return direction === 1 ? ")" : "("
default:
return direction === 1 ? "" : ""
return direction === 1 ? "" : ""
}
}
@@ -190,32 +185,6 @@ 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 = {},
@@ -228,13 +197,11 @@ export function drawSequenceDiagramGrid(
if (plan.groups.length > 0) renderParticipantGroups(grid, plan.groups, plan.height - 1)
for (const placement of plan.participants) {
const { centerX: center, headerLeftX, headerRightX, labelLines } = placement
const { participant, centerX: center, headerLeftX, headerRightX, labelX } = placement
const { participantHeaderTopY, participantHeaderY, participantRuleY, lifelineStartY, lifelineEndY } = plan.rows
if (options.compact) {
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
setText(grid, labelX, participantHeaderY, participant.label, "participant")
} else {
for (let x = headerLeftX; x <= headerRightX; x++) {
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
@@ -243,15 +210,11 @@ export function drawSequenceDiagramGrid(
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "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, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerRightX, participantHeaderY, SEQUENCE_BORDER.vertical, "participant")
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
labelLines.forEach((line, index) =>
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
)
setText(grid, labelX, participantHeaderY, participant.label, "participant")
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
}
@@ -264,7 +227,9 @@ export function drawSequenceDiagramGrid(
for (const placement of plan.steps) {
if (placement.type === "note") {
renderNote(grid, placement)
for (let lineIndex = 0; lineIndex < placement.textLines.length; lineIndex++) {
setText(grid, placement.textX, placement.textY + lineIndex, placement.textLines[lineIndex]!, "noteBadge")
}
continue
}
@@ -305,13 +270,5 @@ 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,7 +22,6 @@ 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",
@@ -133,9 +132,6 @@ 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,25 +90,6 @@ 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
@@ -188,34 +169,4 @@ ${Array.from(
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)
})
})
+37 -92
View File
@@ -11,7 +11,7 @@ import type {
SequenceStep,
} from "./types.js"
const NOTE_HORIZONTAL_PADDING = 2
const NOTE_HORIZONTAL_PADDING = 1
const GROUP_HORIZONTAL_PADDING = 2
const FRAGMENT_HORIZONTAL_OVERHANG = 3
@@ -25,7 +25,7 @@ export interface SequenceParticipantPlacement {
centerX: number
headerLeftX: number
headerRightX: number
labelLines: string[]
labelX: number
}
export interface SequenceGroupPlacement {
@@ -41,14 +41,6 @@ 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 }
| {
@@ -96,7 +88,6 @@ export interface SequencePlacementPlan {
}
participants: SequenceParticipantPlacement[]
groups: SequenceGroupPlacement[]
activations: SequenceActivationPlacement[]
steps: SequenceStepPlacement[]
}
@@ -141,8 +132,7 @@ function messageLabelText(message: SequenceMessage): string {
}
function participantHeaderWidth(label: string, compact: boolean): number {
const width = labelLinesWidth(mermaidLabelLines(label))
return compact ? width : Math.max(5, width + 4)
return compact ? visualLength(label) : Math.max(5, visualLength(label) + 4)
}
function fragmentLabelText(fragment: SequenceFragment): string {
@@ -246,9 +236,7 @@ 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)
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) }
return { leftX, rightX: Math.max(rightX, leftX + 2 + messageWidth(step.message) - 1) }
}
if (step.type !== "note") return undefined
const indexes = getParticipantIndexes(participantIndexes, step.note.over)
@@ -399,9 +387,7 @@ function resolveParticipantCenters(
if (fromIndex === toIndex && fromIndex >= 0 && fromIndex < diagram.participants.length - 1) {
gaps[fromIndex] = Math.max(
gaps[fromIndex]!,
selfMessageLoopWidth(message) +
Math.ceil(labelLinesWidth(mermaidLabelLines(diagram.participants[fromIndex + 1]!.label)) / 2) +
2,
selfMessageLoopWidth(message) + Math.ceil(visualLength(diagram.participants[fromIndex + 1]!.label) / 2) + 2,
)
continue
}
@@ -437,31 +423,37 @@ function separateExpandedGroupsFromExternalParticipants(
compact: boolean,
): number[] {
const adjusted = [...centers]
for (let boundary = 0; boundary < adjusted.length - 1; boundary++) {
for (let pass = 0; pass < Math.max(1, ranges.length * 2); pass++) {
let changed = false
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()) {
if (range.endIndex === boundary) {
leftRight = Math.max(leftRight, groups[index]!.rightX)
bordersGroup = true
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.startIndex === boundary + 1) {
rightLeft = Math.min(rightLeft, groups[index]!.leftX)
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 (!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
}
if (!changed) return adjusted
}
return adjusted
}
@@ -483,7 +475,6 @@ export function createSequencePlacementPlan(
},
participants: [],
groups: [],
activations: [],
steps: [],
}
}
@@ -520,13 +511,9 @@ 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 ? participantLabelHeight - 1 : participantLabelHeight + 1)
const participantRuleY = participantHeaderTopY + (compact ? 0 : 2)
const lifelineStartY = participantRuleY + 1
const stepStartY = lifelineStartY + 1
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
@@ -538,46 +525,19 @@ 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,
labelLines,
labelX: centeredStart(centerX, participant.label),
}
})
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") {
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
}
if (step.type === "activation") continue
const stepHeight = getStepHeight(step, centers, indexes, compact)
if (step.type === "note") {
const noteIndexes = getParticipantIndexes(indexes, step.note.over)
@@ -628,7 +588,6 @@ 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,
@@ -636,11 +595,8 @@ export function createSequencePlacementPlan(
centerX,
rightX: centerX + selfMessageLoopWidthForLines(labelLines),
topY: stepY,
bottomY,
bottomY: stepY + labelLines.length + 1,
})
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]!
@@ -648,16 +604,13 @@ 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,
labelX: leftX + 2,
labelY: stepY,
arrowY,
arrowY: inlineLabel ? stepY : stepY + labelLines.length,
fromX,
toX,
leftX,
@@ -666,23 +619,15 @@ 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,
}
}
+8 -194
View File
@@ -47,17 +47,6 @@ 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
@@ -66,7 +55,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", () => {
@@ -199,7 +188,7 @@ stateDiagram-v2
Running
💥 sandbox dies BEFORE hook fires
(crash, our bug, race)
Dormant Lost
@@ -395,7 +384,7 @@ stateDiagram-v2
expect(output).toMatchInlineSnapshot(`
" submit ok
Editing Saved
Editing Saved
type fail
@@ -422,7 +411,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", () => {
@@ -455,65 +444,6 @@ 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}
@@ -631,46 +561,15 @@ stateDiagram-v2
})
expect(output).toMatchInlineSnapshot(`
" Authenticated
save
login open logout
Idle Editing
login open save
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
@@ -701,91 +600,6 @@ stateDiagram-v2
state Decision <<choice>>
Decision --> [*]`)
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)
}
}
expect(output).toContain("╰─────────────┬\n")
})
})
+2 -5
View File
@@ -49,9 +49,7 @@ function translateTransitionPlans(
function makeGrid(width: number, height: number): StateGrid {
return new DiagramCanvas(width, height, {
mergeCell: (existing, incoming): StateCell => {
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")
const shouldMerge = existing.style === "transition" && incoming.style === "transition"
return {
...incoming,
char: shouldMerge
@@ -200,8 +198,7 @@ function drawTransitionJunctionPlans(
): void {
for (const plan of createStateTransitionJunctionPlans(diagram, bounds, renderPlans)) {
const style = plan.kind === "choice" ? "choice" : "transition"
const char = plan.kind === "choice" ? "◆" : diagramLineGlyph(plan.connections, "rounded")
setCell(grid, plan.bounds.left, plan.bounds.top, char, style)
setCell(grid, plan.bounds.left, plan.bounds.top, diagramLineGlyph(plan.connections, "rounded"), style)
}
}
+21 -39
View File
@@ -40,6 +40,14 @@ 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[]>()
@@ -80,11 +88,8 @@ function outgoingTransitions(diagram: StateDiagram): Map<string, StateDiagramTra
return outgoing
}
function reaches(
outgoing: ReadonlyMap<string, readonly StateDiagramTransition[]>,
from: string,
target: string,
): boolean {
function reaches(diagram: StateDiagram, from: string, target: string): boolean {
const outgoing = outgoingTransitions(diagram)
const visited = new Set<string>()
const stack = [from]
while (stack.length > 0) {
@@ -99,7 +104,6 @@ function reaches(
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 []
@@ -110,14 +114,9 @@ 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) => 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)
})
candidates.find((transition) => diagram.states.find((state) => state.id === transition.to)?.kind === "end") ??
candidates.find((transition) => !reaches(diagram, transition.to, current)) ??
candidates.find((transition) => !hasReverseTransition(diagram, transition))
if (!next) break
path.push(next.to)
visited.add(next.to)
@@ -133,7 +132,7 @@ function stateSize(state: StateDiagramState): { width: number; height: number; l
}
function noteLines(note: StateDiagramNote): string[] {
const lines = note.lines.flatMap(splitDiagramLines).map((line) => line.trim())
const lines = note.lines.flatMap(splitStateDiagramLines).map((line) => line.trim())
return lines.length > 0 ? lines : [""]
}
@@ -203,7 +202,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, diagramTextWidth(composite.label) + 5)
const width = Math.max(right - left, visualLength(composite.label) + 5)
const bound = {
id: composite.id,
left,
@@ -350,7 +349,7 @@ function expandCompositeBoundsForNotes(diagram: StateDiagram, layout: StateDiagr
bound.left = left
bound.top = top
bound.width = Math.max(right - left, diagramTextWidth(composite.label) + 5)
bound.width = Math.max(right - left, visualLength(composite.label) + 5)
bound.height = bottom - top
bound.centerX = bound.left + Math.floor(bound.width / 2)
bound.centerY = bound.top + Math.floor(bound.height / 2)
@@ -462,8 +461,7 @@ export function createStateDiagramLayout(
x += size.width + options.minStateGap + 8
}
const labelRows = states.reduce((rows, state) => Math.max(rows, outgoingLabelRows.get(state.id) ?? 0), 0)
const pseudoStateApproachClearance = states.some((state) => state.kind === "choice") ? 2 : 0
y += rowHeight + Math.max(4, labelRows + 3) + pseudoStateApproachClearance
y += rowHeight + Math.max(4, labelRows + 3)
}
return finalizeLayout(diagram, emptyLayout(bounds, sizes))
@@ -501,10 +499,7 @@ 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)
const crossesCompositeBoundary = Boolean(
nextId && statesById.get(id)?.parentId !== statesById.get(nextId)?.parentId,
)
x += size.width + Math.max(defaultGap, adjacentLabelWidth + (crossesCompositeBoundary ? 6 : 2))
x += size.width + Math.max(defaultGap, adjacentLabelWidth + 2)
}
const branchesByParent = new Map<string, string[]>()
@@ -555,25 +550,12 @@ function createHorizontalLayout(diagram: StateDiagram, options: StateDiagramLayo
}
const ranks = computeRanks(diagram)
const fallbackStates = diagram.states
.filter((state) => !bounds.has(state.id))
.sort((left, right) => (ranks.get(left.id) ?? 0) - (ranks.get(right.id) ?? 0))
const fallbackStates = diagram.states.filter((state) => !bounds.has(state.id))
for (const state of fallbackStates) {
const size = sizes.get(state.id)!
const top = baselineY + 5
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
}
const top = baselineY + 5
const left = rank * (size.width + defaultGap)
bounds.set(state.id, {
id: state.id,
left,
+7 -10
View File
@@ -1,4 +1,4 @@
import { decodeMermaidText, firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
import { firstMeaningfulMermaidLine, numberedMermaidLines } from "../core/mermaid.js"
import { splitDiagramLines } from "../core/text-lines.js"
import { MermaidSyntaxError } from "../diagnostics.js"
import { normalizeStateDiagramEndpoint, stateDiagramEndMarkerId, stateDiagramStartMarkerId } from "./endpoint.js"
@@ -96,7 +96,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
notes.push({
target: pendingNote.target,
position: pendingNote.position,
lines: pendingNote.lines.map(decodeMermaidText),
lines: pendingNote.lines,
})
pendingNote = undefined
} else if (line || pendingNote.lines.length > 0) {
@@ -119,9 +119,6 @@ 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
}
@@ -131,7 +128,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
notes.push({
position: inlineNoteMatch[1]!.toLowerCase() as "left" | "right",
target: inlineNoteMatch[2]!,
lines: splitDiagramLines(decodeMermaidText(inlineNoteMatch[3]!.trim())),
lines: splitDiagramLines(inlineNoteMatch[3]!.trim()),
})
continue
}
@@ -153,7 +150,7 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
const id = compositeMatch[2]!
composites.push({
id,
label: decodeMermaidText(compositeMatch[1] ?? id),
label: compositeMatch[1] ?? id,
...(parentId ? { parentId } : {}),
})
parentStack.push({ id, lineNumber: source.lineNumber, sourceLine: line })
@@ -162,13 +159,13 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
const stateMatch = line.match(STATE_RE)
if (stateMatch) {
ensureState(states, stateMatch[2]!, decodeMermaidText(stateMatch[1]!), "state", parentId)
ensureState(states, stateMatch[2]!, 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
}
@@ -180,7 +177,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: decodeMermaidText(transitionMatch[3]?.trim() ?? "") })
transitions.push({ from, to, label: transitionMatch[3]?.trim() ?? "" })
continue
}
+1 -37
View File
@@ -1,14 +1,11 @@
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 type { StateVisibleDiagram } from "./visible-model.js"
import { prepareVisibleStateDiagram } from "./visible-model.js"
import { prepareVisibleStateDiagram, type StateVisibleDiagram } from "./visible-model.js"
function bounds(id: string, centerX: number, centerY: number): StateDiagramBoxBounds {
return { id, left: centerX - 2, top: centerY - 1, width: 5, height: 3, centerX, centerY }
@@ -210,39 +207,6 @@ 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", () => {
+95 -270
View File
@@ -1,6 +1,5 @@
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"
@@ -11,15 +10,14 @@ 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; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "top-feedback"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number; approachX: number })
| (StateTransitionRoutePlanBase & { kind: "bottom-parallel"; railY: number })
| (StateTransitionRoutePlanBase & { kind: "vertical-elbow"; hasReverse: boolean; offsetConnector: boolean })
| (StateTransitionRoutePlanBase & { kind: "side-parallel"; railX: number })
| (StateTransitionRoutePlanBase & { kind: "vertical" })
@@ -194,93 +192,6 @@ 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>,
@@ -289,29 +200,16 @@ 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),
)
let nextSideRailX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + 3
const sideLaneX = Math.max(0, ...[...bounds.values()].map((bound) => bound.left + bound.width)) + maxLabelWidth + 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)
@@ -319,9 +217,8 @@ export function createStateTransitionRoutePlans(
if (!from || !to) return []
const targetState = statesById.get(transition.to)
const targetIsChoice = targetState?.kind === "choice"
const targetIsHiddenMarker = isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice, targetIsHiddenMarker }
const targetIsChoice = targetState?.kind === "choice" || isHiddenCompositeMarker(targetState)
const base = { transition, from, to, targetIsChoice }
if (transition.from === transition.to) return [{ ...base, kind: "self" }]
const endpointKey = `${transition.from}\u0000${transition.to}`
const parallelIndex = endpointOccurrences.get(endpointKey) ?? 0
@@ -330,80 +227,30 @@ 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: "top-feedback",
kind: feedbackAllocation.side === "bottom" ? "bottom-feedback" : "top-feedback",
railY: feedbackAllocation.railY,
},
]
}
if (parallelIndex > 0) {
if ((diagram.direction === "LR" || diagram.direction === "RL") && from.centerY === to.centerY) {
const railY = allocateBottomRail()
if (diagram.direction === "LR" || diagram.direction === "RL") {
return [
{
...base,
kind: "bottom-parallel",
railY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, railY),
railY: feedbackLaneY + (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" }]
return [{ ...base, kind: "side-parallel", railX: sideLaneX + (parallelIndex - 1) * parallelLaneGap }]
}
if (diagram.direction !== "LR" && diagram.direction !== "RL") return [{ ...base, kind: "vertical" }]
if (from.centerY !== to.centerY) {
if (from.centerY > to.centerY && feedback)
return [
{
...base,
kind: "bottom-feedback",
railY: feedbackLaneY,
approachX: bottomApproachX(diagram, transition, from, to, bounds, feedbackLaneY),
},
]
if (from.centerY > to.centerY && feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
const hasReverse = hasReverseTransition(diagram, transition)
return [
{
@@ -414,26 +261,7 @@ export function createStateTransitionRoutePlans(
},
]
}
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),
},
]
}
if (feedback) return [{ ...base, kind: "bottom-feedback", railY: feedbackLaneY }]
return [{ ...base, kind: "horizontal-forward", leftToRight: from.centerX <= to.centerX }]
})
}
@@ -505,7 +333,7 @@ function addTopDeparture(builder: StateTransitionRenderBuilder, bounds: BoxBound
}
function addHorizontalForward(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, targetIsHiddenMarker, leftToRight, transition } = builder.route as Extract<
const { from, to, targetIsChoice, leftToRight, transition } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "horizontal-forward" }
>
@@ -515,12 +343,9 @@ 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, 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)
addHorizontalLine(builder, startX, targetIsChoice ? endX : endX - step, y, step)
if (targetIsChoice) addPathPoint(builder, to.left, y)
else addCell(builder, { x: endX, y, arrowDirection: leftToRight ? "right" : "left" })
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const labelX = Math.min(startX, endX) + Math.max(1, Math.floor((Math.abs(endX - startX) - metrics.width) / 2))
@@ -553,14 +378,14 @@ function outsideTopY(bounds: BoxBounds): number {
}
function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY, approachX } = builder.route as Extract<
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "bottom-feedback" | "bottom-parallel" }
>
const sourceX = from.centerX
const targetX = to.width > 1 ? (sourceX > to.centerX ? to.left + 1 : to.left + to.width - 2) : to.centerX
const targetRailCutsSource = targetX >= from.left && targetX <= from.left + from.width - 1
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : approachX
const railTargetX = targetRailCutsSource ? Math.max(from.left + from.width, to.left + to.width) + 2 : targetX
const sourceBottomY = outsideBottomY(from)
const targetBottomY = outsideBottomY(to)
addBottomDeparture(builder, from, sourceX)
@@ -581,13 +406,8 @@ function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
addCell(builder, { x, y: targetBottomY, char: "─" })
}
}
addCell(
builder,
targetIsHiddenMarker
? { x: targetX, y: targetBottomY, char: "│" }
: { x: targetX, y: targetBottomY, arrowDirection: "up" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
addCell(builder, { x: targetX, y: targetBottomY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "up" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - railTargetX) - 2
@@ -599,7 +419,7 @@ function addBottomLaneTransition(builder: StateTransitionRenderBuilder): void {
}
function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railY } = builder.route as Extract<
const { from, to, targetIsChoice, transition, railY } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "top-feedback" }
>
@@ -617,13 +437,8 @@ 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,
targetIsHiddenMarker
? { x: targetX, y: targetTopY, char: "│" }
: { x: targetX, y: targetTopY, arrowDirection: "down" },
)
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
addCell(builder, { x: targetX, y: targetTopY, ...(targetIsChoice ? { char: "│" } : { arrowDirection: "down" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
const horizontalRoom = Math.abs(sourceX - targetX) - 2
@@ -635,7 +450,7 @@ function addTopFeedbackTransition(builder: StateTransitionRenderBuilder): void {
}
function addSideParallelTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, targetIsChoice, targetIsHiddenMarker, transition, railX } = builder.route as Extract<
const { from, to, targetIsChoice, transition, railX } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "side-parallel" }
>
@@ -650,16 +465,9 @@ 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,
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)
}
addCell(builder, { x: endX, y: endY, ...(targetIsChoice ? { char: "─" } : { arrowDirection: "left" }) })
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (transition.label) addLabel(builder, railX + 2, Math.min(startY, endY) + 1, transition.label)
}
function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
@@ -668,8 +476,10 @@ function innerConnectorX(bounds: BoxBounds, preferredX: number): number {
}
function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice, targetIsHiddenMarker, hasReverse, offsetConnector } =
builder.route as Extract<StateTransitionRoutePlan, { kind: "vertical-elbow" }>
const { from, to, transition, targetIsChoice, hasReverse, offsetConnector } = builder.route as Extract<
StateTransitionRoutePlan,
{ kind: "vertical-elbow" }
>
const topToBottom = from.centerY < to.centerY
const offset = offsetConnector ? (topToBottom ? -2 : 2) : 0
const startX = innerConnectorX(from, from.centerX + offset)
@@ -703,19 +513,13 @@ function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void
}
}
}
const targetChar = targetIsHiddenMarker
? hasTargetApproach || startX === endX
? "│"
: topToBottom
? "┬"
: "┴"
: undefined
const targetChar = targetIsChoice ? (hasTargetApproach || startX === endX ? "│" : topToBottom ? "┬" : "┴") : undefined
addCell(builder, {
x: endX,
y: endY,
...(targetChar ? { char: targetChar } : { arrowDirection: topToBottom ? "down" : "up" }),
})
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (!transition.label) return
const metrics = measureStateTransitionLabel(transition.label)
if (topToBottom) {
@@ -739,7 +543,7 @@ function addVerticalElbowTransition(builder: StateTransitionRenderBuilder): void
}
function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
const { from, to, transition, targetIsChoice, targetIsHiddenMarker } = builder.route
const { from, to, transition, targetIsChoice } = builder.route
const topToBottom = from.centerY <= to.centerY
const x = from.centerX
const startY = topToBottom ? from.top + from.height : from.top - 1
@@ -751,9 +555,9 @@ function addVerticalTransition(builder: StateTransitionRenderBuilder): void {
addCell(builder, {
x,
y: endY,
...(targetIsHiddenMarker ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
...(targetIsChoice ? { char: "│" } : { arrowDirection: topToBottom ? "down" : "up" }),
})
if (targetIsChoice || targetIsHiddenMarker) addPathPoint(builder, to.left, to.top)
if (targetIsChoice) addPathPoint(builder, to.left, to.top)
if (transition.label) addLabel(builder, x + 2, Math.min(startY, endY) + 1, transition.label)
}
@@ -786,47 +590,69 @@ 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[] {
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 })),
),
),
)
const routeCells = new Set(plans.flatMap((plan) => plan.cells.map((cell) => `${cell.x}:${cell.y}`)))
const placedLabels: StateTransitionLabelRect[] = []
const stateRects = diagram.states.flatMap((state) => {
const bound = bounds.get(state.id)
return bound && !isHiddenCompositeMarker(state)
? [{ left: bound.left, top: bound.top, width: bound.width, height: bound.height }]
: []
})
return plans.map((plan, planIndex) => {
return plans.map((plan) => {
if (!plan.label) return plan
const width = Math.max(...plan.label.lines.map(diagramTextWidth))
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,
})
if (plan.label.lines.length === 1) {
placedLabels.push(labelRect(plan.label, width))
return plan
}
const statePadding = 1
const isClear = (x: number, y: number): boolean => {
if (x < 0 || y < 0) return false
return space.isFree(labelClaim(x, y), {
clearance: {
body: statePadding,
label: { x: 1, y: 0 },
},
})
const rect = labelRect({ ...plan.label!, x, y }, width)
if (
stateRects.some((state) =>
rectsOverlap(rect, {
left: state.left - statePadding,
top: state.top - statePadding,
width: state.width + statePadding * 2,
height: state.height + statePadding * 2,
}),
)
)
return false
if (placedLabels.some((label) => rectsOverlap(rect, label))) return false
for (let row = rect.top; row < rect.top + rect.height; row++) {
for (let column = rect.left; column < rect.left + rect.width; column++) {
if (routeCells.has(`${column}:${row}`)) return false
}
}
return true
}
let x = plan.label.x
@@ -846,7 +672,7 @@ function placeStateTransitionLabels(
}
}
space = space.add(labelClaim(x, y))
placedLabels.push(labelRect({ ...plan.label, x, y }, width))
return { ...plan, label: { ...plan.label, x, y } }
})
}
@@ -877,7 +703,6 @@ 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
@@ -888,7 +713,7 @@ export function createStateTransitionJunctionPlans(
const connections = new Set<DiagramDirection>()
const transitions: StateVisibleTransition[] = []
for (const transition of diagram.transitions) {
const renderPlan = renderPlanByTransition.get(transition)
const renderPlan = renderPlans.find((plan) => plan.route.transition === transition)
let connected = false
if (transition.to === state.id) {
const junction = renderPlan?.path.at(-1)
@@ -20,43 +20,6 @@ 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<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" })
expect(exit).toMatchObject({ from: "Editing", to: "__end", label: "save" })
})
})
+20 -15
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].filter(Boolean).join("<br/>")
return incoming.label || outgoing.label
}
function collapseHiddenCompositeMarkerTransitionsOnce(
@@ -23,28 +23,33 @@ 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
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,
changed = true
for (const incomingTransition of incoming) {
skipped.add(incomingTransition)
for (const outgoingTransition of outgoing) {
skipped.add(outgoingTransition)
collapsed.push({
from: incomingTransition.from,
to: outgoingTransition.to,
label: composeTransitionLabel(incomingTransition, outgoingTransition),
})
}
}
}
return { transitions: [...transitions], changed: false }
return {
transitions: [...transitions.filter((transition) => !skipped.has(transition)), ...collapsed],
changed,
}
}
function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
@@ -26,21 +26,6 @@ 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
@@ -56,12 +41,6 @@ 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
@@ -76,17 +55,6 @@ 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.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+3 -1
View File
@@ -647,6 +647,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", {
params: { sessionID: Session.ID },
query: { continue: BooleanFromString.pipe(Schema.optional) },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
@@ -655,7 +656,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "v2.session.interrupt",
summary: "Interrupt session execution",
description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.",
description:
"Interrupt active execution owned by this OpenCode process. When continue=true, pending work starts after interruption. Idle interruption is a no-op.",
}),
),
)
+1 -1
View File
@@ -772,7 +772,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.interrupt",
Effect.fn(function* (ctx) {
yield* session.interrupt(ctx.params.sessionID)
yield* session.interrupt(ctx.params.sessionID, { continue: ctx.query.continue })
return HttpApiSchema.NoContent.make()
}),
)
+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] ?? theme.categorical[0])[step], { bold: true }),
rule(["extmark.skill"], theme.categorical[1][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,6 +93,7 @@
"@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:",
+3 -15
View File
@@ -97,7 +97,6 @@ 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()
@@ -252,13 +251,6 @@ 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(() =>
@@ -295,11 +287,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<TuiAppProvider value={input.app}>
<ErrorBoundary
fallback={(error, reset) => (
<ClipboardProvider value={clipboard}>
<ErrorComponent error={error} reset={reset} mode={mode} />
</ClipboardProvider>
)}
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
>
<TuiPathsProvider
value={{
@@ -348,7 +336,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider value={clipboard}>
<ClipboardProvider>
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
@@ -516,7 +504,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (!text || text.length === 0) return
await clipboard
.write(text)
.write?.(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
+118 -59
View File
@@ -1,66 +1,125 @@
import {
createClipboard,
createHostClipboard,
createRendererClipboardAdapter,
decodePasteBytes,
type ClipboardService as CoreClipboardService,
type RendererClipboardBoundary,
} from "@opentui/core"
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
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"
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
const exec = promisify(execFile)
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
return createClipboardAdapter(
createClipboard({
host: createHostClipboard(),
terminal: createRendererClipboardAdapter(renderer),
}),
)
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 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
}
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)
}
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()
},
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)
}
}
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)
}
@@ -84,15 +84,6 @@ 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",
@@ -197,15 +188,6 @@ 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" })
@@ -1,71 +0,0 @@
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,6 +172,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
onMount(() => dialog.setSize("large"))
const copy = () => {
if (!clipboard.write) return
void clipboard
.write(error())
.then(() => setCopied(true))
+8 -25
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 [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
const [copied, setCopied] = createSignal(false)
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
// theme context may be the thing that crashed.
@@ -46,19 +46,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
const issueURL = buildIssueURL(message, stack, app.version)
const copyReport = () => {
void clipboard
.write(issueURL.toString())
.then(() => setCopyState("copied"))
.catch(() => setCopyState("failed"))
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
}
const actions = [
{
key: "c",
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
copy: true,
onUse: copyReport,
},
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
{ key: "r", label: () => "Restart", onUse: props.reset },
{ key: "q", label: () => "Quit", onUse: () => exit() },
]
@@ -143,20 +135,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<For each={actions}>
{(action, index) => {
const isSelected = () => selected() === index()
const copyColor = () =>
action.copy
? copyState() === "copied"
? colors.success
: copyState() === "failed"
? colors.error
: undefined
: undefined
const isCopied = () => action.copy && copied()
return (
<box flexDirection="column" alignItems="center" flexShrink={0}>
<box
onMouseDown={() => setSelected(index())}
onMouseUp={() => action.onUse()}
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
minWidth={15}
alignItems="center"
paddingLeft={2}
@@ -164,7 +149,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
>
<text
attributes={TextAttributes.BOLD}
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
>
{action.label()}
</text>
@@ -204,11 +189,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
<Show when={showFooter()}>
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text fg={colors.muted}>
{copyState() === "copied"
{copied()
? "Report copied — paste it into a new GitHub issue."
: 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."}
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>OpenCode {app.version}</text>
</box>
+46 -180
View File
@@ -7,8 +7,9 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } 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"
@@ -47,20 +48,13 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { readLocalAttachment } 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
@@ -78,6 +72,17 @@ 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
@@ -307,41 +312,6 @@ 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(
@@ -421,32 +391,22 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: (_input: string | undefined, event?: KeyEvent) => {
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
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)
}
})
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)
}
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -472,6 +432,7 @@ export function Prompt(props: PromptProps) {
if (store.interrupt >= 2) {
void client.api.session.interrupt({
sessionID: props.sessionID,
continue: true,
})
setStore("interrupt", 0)
}
@@ -601,7 +562,6 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -655,7 +615,6 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1304,39 +1263,27 @@ export function Prompt(props: PromptProps) {
return true
}
async function pasteInputText(text: string, changed: () => boolean) {
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
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 (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
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) => {
@@ -1361,27 +1308,12 @@ export function Prompt(props: PromptProps) {
}, 0)
}
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 }) {
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1413,6 +1345,7 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1536,74 +1469,6 @@ 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()}
@@ -1632,7 +1497,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1642,10 +1507,11 @@ 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 (event.bytes.byteLength === 0) {
if (!pastedContent) {
keymap.dispatch("prompt.paste")
return
}
@@ -1654,7 +1520,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1,12 +1,9 @@
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, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -14,15 +11,14 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
export function readLocalAttachment(file: string) {
return readLocalAttachmentWith(
{
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -37,108 +33,16 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
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> {
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (!mime) return
if (mime === "image/svg+xml") {
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
const content = await files.readText(path).catch(() => undefined)
if (!content) return
return { type: "text", mime, content }
}
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
return { type: "binary", mime, content }
}
+12 -80
View File
@@ -1,5 +1,5 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
@@ -16,19 +16,16 @@ import {
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
import { createAnimatable, spring } 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"> & {
@@ -49,41 +46,6 @@ 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" } = {},
) {
@@ -107,7 +69,6 @@ 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
@@ -183,12 +144,7 @@ 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 scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
@@ -250,15 +206,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
return titleFades()
? fadeTitleColor(
color,
pulseBackground(),
index,
visibleTitleParts().length,
scrolling() ? marquee.leading() : 0,
)
: color
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)))
}
const release = () => {
setDragging(undefined)
@@ -275,10 +225,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
setDragging(tab.sessionID)
}}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
@@ -435,7 +382,6 @@ 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,
@@ -615,12 +561,7 @@ 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 scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
: Locale.takeWidth(title(), availableTitleWidth()),
)
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
@@ -634,15 +575,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
return titleFades()
? fadeTitleColor(
color,
background(),
index,
visibleTitleParts().length,
scrolling() ? marquee.leading() : 0,
)
: color
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)))
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -676,10 +611,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
setDragging(tab.sessionID)
}}
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
-6
View File
@@ -114,9 +114,6 @@ 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(
@@ -131,9 +128,6 @@ 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,7 +165,6 @@ 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"),
@@ -367,7 +366,6 @@ 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",
+8 -9
View File
@@ -1,19 +1,18 @@
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)
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 ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
const value = useContext(ClipboardContext)
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
return value
return useContext(ClipboardContext)
}
+1
View File
@@ -437,6 +437,7 @@ 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()
+2 -2
View File
@@ -374,7 +374,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: state.sdk.session.interrupt({ sessionID: state.sessionID })
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
)
.catch(() => {})
.finally(() => {
@@ -401,7 +401,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
void state.sdk.session.interrupt({ sessionID, continue: true }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
+1 -6
View File
@@ -312,12 +312,7 @@ 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") {
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))
if (!current || current.part.state.status === "streaming")
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 })
+6 -9
View File
@@ -120,7 +120,6 @@ type ToolState = {
part: SessionMessageAssistantTool
output: string
version: number
started: boolean
}
type State = {
@@ -610,7 +609,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, started: false })
state.tools.set(key, { part, output: "", version: 0 })
return
}
const current = state.tools.get(key)
@@ -619,18 +618,16 @@ 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") {
const started = current?.started === true
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
if (render && !started && ready)
if (render && (!current || current.part.state.status === "streaming"))
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, started: started || (render && ready) })
state.tools.set(key, { part, output, version })
return
}
if (render && !current?.started)
if (render && (!current || current.part.state.status === "streaming"))
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)])
state.finishedTools.add(key)
state.tools.delete(key)
@@ -1528,7 +1525,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.wait = active
const interrupt = () => {
active.interrupted = true
void sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
void sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
}
next.signal?.addEventListener("abort", interrupt, { once: true })
try {
@@ -1786,7 +1783,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (state.wait) state.wait.interrupted = true
await sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sdk, sessionID)
@@ -217,7 +217,7 @@ export function SubagentsTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
void client.api.session.interrupt({ sessionID: entry.sessionID })
void client.api.session.interrupt({ sessionID: entry.sessionID, continue: true })
},
},
],
@@ -66,12 +66,8 @@ export function DialogMessage(props: {
: "text" in value
? value.text
: ""
try {
await clipboard.write(text)
dialog.clear()
} catch (error) {
toast.error(error)
}
await clipboard.write?.(text)
dialog.clear()
},
},
{
+2 -2
View File
@@ -366,7 +366,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
function copyExternal() {
const current = externalField()
if (!current) return
if (!current || !clipboard.write) 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()}>
<Show when={externalField() && clipboard.write}>
<text fg={theme.text.default} onMouseUp={copyExternal}>
c <span style={{ fg: theme.text.subdued }}>copy</span>
</text>
+18 -111
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, MouseEvent } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -54,7 +54,6 @@ 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"
@@ -90,7 +89,6 @@ import {
createSessionRows,
messageBoundaryIDs,
resolvePart,
turnDuration,
type CacheUsage,
type PartRef,
type SessionRow,
@@ -104,13 +102,11 @@ 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
@@ -851,7 +847,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()
@@ -869,7 +865,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" })
@@ -903,7 +899,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
@@ -1330,20 +1326,18 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const theme = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const running = createMemo(() => {
if (!shortcut()) return
const visible = createMemo(() => {
const current = props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
)
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}`
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 visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
return (
<Show when={visible() && shortcut()}>
{(value) => (
@@ -1593,9 +1587,8 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
@@ -1604,7 +1597,6 @@ function SessionGroupView(props: {
function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
@@ -1615,7 +1607,9 @@ 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(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
const duration = createMemo(() =>
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
)
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
return (
<>
@@ -1899,11 +1893,6 @@ 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
@@ -1925,7 +1914,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} paddingLeft={2} />
<box
onMouseOver={() => {
setHover(true)
@@ -2218,7 +2206,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
function ToolPart(props: { part: SessionMessageAssistantTool }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
@@ -2242,7 +2230,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
},
}
const content = (
return (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2288,87 +2276,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
</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 = {

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