Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 72d9dcf470 fix(tui): preserve model variants across sessions 2026-08-10 04:58:15 +00:00
126 changed files with 2950 additions and 5933 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,3 +1,4 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -12,20 +13,34 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import {
type Accessor,
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { createProviderConnectionController, type ProviderConnectMethod } from "./provider-connection-controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
@@ -370,29 +385,120 @@ function ProviderConnection(props: {
}) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const params = useParams()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
alive.value = false
if (timer.current === undefined) return
clearTimeout(timer.current)
timer.current = undefined
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const controller = createProviderConnectionController({
provider: () => props.provider,
directory,
onComplete: () => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
const fallback = createMemo<ConnectMethod[]>(() => [
{
type: "key" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as undefined | "pending" | "complete" | "error" | "form",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.form") {
draft.state = "form"
draft.error = undefined
return
}
if (action.type === "auth.answer") {
draft.formAnswer = action.answer
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
@@ -414,6 +520,65 @@ function ProviderConnection(props: {
}
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number, answer?: FormAnswer) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
if (method.type === "key") {
dispatch({ type: "auth.answer", answer })
return
}
if (method.type === "oauth") {
if (method.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
return
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
...(answer ? { answer } : {}),
location: location(),
})
.then((x) => {
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
function AuthFormView() {
const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>,
@@ -421,7 +586,7 @@ function ProviderConnection(props: {
})
const fields = createMemo<StringForm[]>(() => {
const value = controller.currentMethod()
const value = method()
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
})
const matches = (field: StringForm, value: Record<string, string>) => {
@@ -434,7 +599,7 @@ function ProviderConnection(props: {
const current = createMemo(() => {
const all = fields()
const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value))
if (index === -1) return undefined
if (index === -1) return
return {
index,
field: all[index],
@@ -448,14 +613,13 @@ function ProviderConnection(props: {
})
async function next(index: number, value: Record<string, string>) {
const selected = controller.methodIndex()
if (selected === undefined) return
if (store.methodIndex === undefined) return
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await controller.auth.select(selected, value)
await selectMethod(store.methodIndex, value)
}
async function handleSubmit(e: SubmitEvent) {
@@ -469,12 +633,12 @@ function ProviderConnection(props: {
const item = () => current()
const text = createMemo(() => {
const field = item()?.field
if (!field || field.options) return undefined
if (!field || field.options) return
return field
})
const select = createMemo(() => {
const field = item()?.field
if (!field?.options) return undefined
if (!field?.options) return
return field
})
@@ -545,9 +709,32 @@ function ProviderConnection(props: {
listRef?.onKeyDown(e)
}
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
void selectMethod(0)
}
})
async function complete() {
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
}
function goBack() {
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
controller.auth.reset()
if (methods().length > 1 && store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
props.onBack()
@@ -563,14 +750,14 @@ function ProviderConnection(props: {
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="flex flex-col">
<For each={controller.methods()}>
<For each={methods()}>
{(item, index) => {
const details = () => methodDetails(item)
return (
<button
type="button"
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => void controller.auth.select(index())}
onClick={() => void selectMethod(index())}
>
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
@@ -598,11 +785,11 @@ function ProviderConnection(props: {
ref={(ref) => {
listRef = ref
}}
items={controller.methods}
items={methods}
key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => {
if (!selected) return
void controller.auth.select(index)
void selectMethod(index)
}}
>
{(i) => (
@@ -635,9 +822,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = typeof value === "string" ? value : ""
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
@@ -645,7 +832,13 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await controller.auth.connectKey(apiKey)
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
})
await complete()
}
if (newLayout())
@@ -760,9 +953,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = typeof value === "string" ? value : ""
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
@@ -770,7 +963,20 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
setFormStore("error", await controller.auth.completeCode(code))
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
if (newLayout())
@@ -778,14 +984,14 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
<ExternalLink href={store.authorization!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
<TextInputV2
ref={codeInput}
class="!w-full"
@@ -817,7 +1023,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -827,9 +1033,7 @@ function ProviderConnection(props: {
autofocus={!newLayout()}
ref={codeInput}
type="text"
label={language.t("provider.connect.oauth.code.label", {
method: controller.currentMethod()?.label ?? "",
})}
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
@@ -847,18 +1051,52 @@ function ProviderConnection(props: {
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
onMount(() => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
<ExternalLink href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
@@ -893,9 +1131,7 @@ function ProviderConnection(props: {
}
>
<Switch>
<Match
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
{language.t("provider.connect.title.anthropicProMax")}
</Match>
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
@@ -906,10 +1142,10 @@ function ProviderConnection(props: {
<div
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && controller.methodIndex() === undefined ? true : undefined}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
>
<Switch>
<Match when={controller.loading()}>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
@@ -917,10 +1153,10 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
@@ -928,26 +1164,26 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<Match when={store.state === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<Match when={method()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<Match when={store.authorization?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<Match when={store.authorization?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
@@ -6,33 +6,21 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { type ServerDomainController } from "@/components/server/server-management-controller"
import { useTabs } from "@/context/tabs"
type ServerConnectionFormController = {
state: {
adding: () => boolean
busy: () => boolean
value: () => string
name: () => string
username: () => string
password: () => string
error: () => string
status: () => boolean | undefined
}
change: {
value: (value: string) => void
name: (value: string) => void
username: (value: string) => void
password: (value: string) => void
}
reset: () => void
submit: () => void
}
const DEFAULT_USERNAME = "opencode"
interface ServerFormProps {
value: string
@@ -51,6 +39,76 @@ interface ServerFormProps {
onBack: () => void
}
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -116,11 +174,387 @@ function ServerForm(props: ServerFormProps) {
)
}
export function ServerConnectionList(props: {
domain: ServerDomainController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
export function DialogSelectServer() {
const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close })
return (
<Dialog title={controller.formTitle()}>
<div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
<ServerConnectionForm controller={controller} />
</Show>
</div>
</Dialog>
)
}
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
const settings = useSettings()
@@ -134,10 +568,10 @@ export function ServerConnectionList(props: {
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
items={props.controller.sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
}}
divider={true}
>
@@ -146,15 +580,15 @@ export function ServerConnectionList(props: {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
<ServerHealthIndicator health={props.controller.status()[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.domain.collection.health()[key]}
dimmed={props.controller.status()[key]?.healthy === false}
status={props.controller.status()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -163,12 +597,7 @@ export function ServerConnectionList(props: {
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
<Icon name="check" class="h-6" />
</Show>
@@ -187,27 +616,27 @@ export function ServerConnectionList(props: {
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
props.onEdit(i)
props.controller.startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.domain.connection.canRemove(key)}>
<Show when={props.controller.canRemove(key)}>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.domain.connection.remove(key)}
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -228,7 +657,7 @@ export function ServerConnectionList(props: {
variant="secondary"
icon="plus-small"
size="large"
onClick={props.onAdd}
onClick={props.controller.startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
@@ -238,38 +667,38 @@ export function ServerConnectionList(props: {
)
}
export function ServerConnectionForm(props: { form: ServerConnectionFormController }) {
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.form.state.value()}
name={props.form.state.name()}
username={props.form.state.username()}
password={props.form.state.password()}
value={props.controller.formValue()}
name={props.controller.formName()}
username={props.controller.formUsername()}
password={props.controller.formPassword()}
placeholder={language.t("dialog.server.add.placeholder")}
busy={props.form.state.busy()}
error={props.form.state.error()}
status={props.form.state.status()}
onChange={props.form.change.value}
onNameChange={props.form.change.name}
onUsernameChange={props.form.change.username}
onPasswordChange={props.form.change.password}
onSubmit={props.form.submit}
onBack={props.form.reset}
busy={props.controller.formBusy()}
error={props.controller.formError()}
status={props.controller.formStatus()}
onChange={props.controller.handleFormChange()}
onNameChange={props.controller.handleFormNameChange()}
onUsernameChange={props.controller.handleFormUsernameChange()}
onPasswordChange={props.controller.handleFormPasswordChange()}
onSubmit={props.controller.submitForm}
onBack={props.controller.resetForm}
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
size="large"
onClick={props.form.submit}
disabled={props.form.state.busy()}
onClick={props.controller.submitForm}
disabled={props.controller.formBusy()}
class="px-3 py-1.5"
>
{props.form.state.busy()
{props.controller.formBusy()
? language.t("dialog.server.add.checking")
: props.form.state.adding()
: props.controller.isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
@@ -1,256 +0,0 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
pollInterval?: number
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const queryClient = useQueryClient()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
}
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK()
.api.integration.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
)
if (values?.length) return [...values]
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
}
const currentMethod = createMemo(() =>
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
)
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.form" }
| { type: "auth.answer"; answer: FormAnswer | undefined }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.formAnswer = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.form") {
draft.state = "form"
draft.error = undefined
return
}
if (action.type === "auth.answer") {
draft.formAnswer = action.answer
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
clearTimeout(polling.timer)
polling.timer = undefined
}
const finish = async () => {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
})
.then((response) => ({ ok: true as const, status: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
})
return
}
if (result.status.status === "complete") {
await finish()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer })
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" })
return
}
dispatch({ type: "auth.pending" })
const result = await serverSDK()
.api.integration.oauth.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.error) })
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK().api.integration.connect.key({
integrationID: options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
})
await finish()
}
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
return message || language.t("provider.connect.oauth.code.invalid")
}
await finish()
return undefined
}
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
})
return {
loading: () => integration.loading,
methods,
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
connectKey,
completeCode,
},
}
}
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
@@ -1,141 +0,0 @@
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -1,59 +0,0 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
domain: ServerActionsController
controller: ReturnType<typeof useServerManagementController>
onEdit: (server: ServerConnection.Http) => void
open?: boolean
onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.domain.defaults.available()}
isDefault={props.domain.defaults.key() === key}
canRemove={props.domain.connection.canRemove(key)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
canRemove={props.controller.canRemove(key)}
onEdit={props.onEdit}
onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.domain.connection.remove(key)}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
@@ -3,63 +3,51 @@ import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@op
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerHealthPreview,
replaceServerConnection,
type ServerFormValues,
} from "@/components/server/server-management"
import { useGlobal } from "@/context/global"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health"
import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import "./settings-v2.css"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
export const DialogServerV2: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
const controller = useServerManagementController({
onSelect: () => dialog.close(),
navigateOnAdd: false,
})
const [opened, setOpened] = createSignal(false)
onMount(() => {
if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) form.start.edit(props.server)
if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
setOpened(true)
})
onCleanup(() => {
form.reset()
controller.resetForm()
})
createEffect(() => {
if (!opened()) return
if (form.state.open()) return
if (controller.isFormMode()) return
dialog.close()
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
form.submit()
controller.submitForm()
}
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => {
if (form.state.busy()) return language.t("dialog.server.add.checking")
if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save")
}
@@ -78,16 +66,16 @@ export const DialogServerV2: Component<{
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
value={controller.formValue()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
invalid={!!controller.formError()}
disabled={controller.formBusy()}
autofocus
onInput={(event) => form.change.value(event.currentTarget.value)}
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
<Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -96,10 +84,10 @@ export const DialogServerV2: Component<{
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.name()}
value={controller.formName()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -110,10 +98,10 @@ export const DialogServerV2: Component<{
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.username()}
value={controller.formUsername()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.username(event.currentTarget.value)}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -123,10 +111,10 @@ export const DialogServerV2: Component<{
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
value={controller.formPassword()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -134,171 +122,13 @@ export const DialogServerV2: Component<{
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
{submitLabel()}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const language = useLanguage()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
})
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
add(connection)
options.onSelect?.()
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { useServerCollectionController } from "../server/server-management-controller"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerCollectionController()
const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.collection.items().filter((item) => !isWslServer(item))
const items = controller.sortedItems().filter((item) => !isWslServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
})
const openAdd = () => {
void dialog.push(() => <DialogServerV2 mode="add" />)
dialog.push(() => <DialogServerV2 mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
}
return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
}
>
<SettingsListV2>
<WslServerSettings domain={controller} servers={wslServers} />
<WslServerSettings controller={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.collection.health()[key]
const isDefault = () => controller.defaults.key() === key
const health = () => controller.status()[key]
const isDefault = () => controller.defaultKey() === key
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={controller.defaults.available() && isDefault()}>
<Show when={controller.canDefault() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
</div>
</div>
)
@@ -1,6 +1,10 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import {
type Accessor,
createEffect,
@@ -12,12 +16,14 @@ import {
Show,
} from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
@@ -100,6 +106,16 @@ const useDefaultServerKey = (
}
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
@@ -109,13 +125,149 @@ type ServerStatusItem = {
onSelect: () => void
}
export function StatusPopoverServerBody() {
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: global.servers.health[key],
blocked: global.servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={props.state.servers()}>
{(item) => {
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const fail = (err: unknown) => {
showToast({
@@ -163,11 +315,17 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="mcp"
defaultValue="mcp"
data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
{!settings.general.newLayoutDesigns() && (
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
)}
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
@@ -184,6 +342,73 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show>
</Tabs.List>
{!settings.general.newLayoutDesigns() && (
<Tabs.Content value="servers">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => global.servers.health[key]?.healthy === false
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !blocked(),
"cursor-not-allowed": blocked(),
}}
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
navigate("/")
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={global.servers.health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={global.servers.health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
>
{language.t("status.popover.action.manageServers")}
</Button>
</div>
</div>
</Tabs.Content>
)}
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+27 -1
View File
@@ -16,6 +16,7 @@ import {
} from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
export function StatusPopover() {
const language = useLanguage()
@@ -81,7 +82,8 @@ export function StatusPopover() {
)
}
export function StatusPopoverV2() {
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
@@ -122,6 +124,30 @@ function DirectoryStatusPopover() {
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
serverHealth: serverHealth(),
attention: false,
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerActionsController } from "@/components/server/server-management-controller"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout"
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
const language = useLanguage()
const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const serverManagement = useServerManagementController({ navigateOnAdd: false })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaults.key,
canDefault: serverManagement.canDefault,
defaultKey: serverManagement.defaultKey,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
},
+142 -130
View File
@@ -58,7 +58,7 @@ import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider } from "@/context/terminal"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useSettingsCommand } from "@/components/settings-dialog"
@@ -71,11 +71,11 @@ import {
createSessionComposerRegionController,
SessionComposerRegion,
} from "@/pages/session/composer"
import { createOpenReviewFile, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createSessionController } from "@/pages/session/session-controller"
import { useSessionLayout } from "@/pages/session/session-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import {
clampSessionPanelWidth,
@@ -102,6 +102,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string }
@@ -366,24 +367,20 @@ export default function Page() {
const prompt = usePrompt()
const comments = useComments()
const command = useCommand()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const navigate = useNavigate()
const isDesktop = createMediaQuery("(min-width: 768px)")
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
const canReview = createMemo(() => !!sync().project)
const controller = createSessionController({
review: isDesktop,
hasReview: canReview,
fileBrowser: (sessionID) => newSessionDesign() && isDesktop() && !!sessionID,
})
const reviewMode = () => controller.layout.view().review.mode() ?? "git"
const reviewFile = () => controller.layout.view().review.file()
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
if (controller.identity.params.id) return
if (params.id) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -404,19 +401,17 @@ export default function Page() {
const composer = createSessionComposerController()
const inputController = createPromptInputController({
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
sessionKey,
sessionID: () => params.id,
queryOptions: serverSync().queryOptions,
})
const workspaceTabs = createMemo(() => layout.tabs(controller.identity.workspaceKey))
const sessionPanelKey = createMemo(() =>
controller.identity.params.id ? `${serverSDK().scope}\0${controller.identity.params.id}` : undefined,
)
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
createEffect(
on(
() => controller.identity.params.id,
() => params.id,
(id, prev) => {
if (!id) return
if (prev) return
@@ -436,13 +431,13 @@ export default function Page() {
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = controller.layout.tabs().tabs()
const current = tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = controller.tabs.normalizeAll(from.all)
const active = from.active ? controller.tabs.normalize(from.active) : undefined
controller.layout.tabs().setAll(all)
controller.layout.tabs().setActive(active && all.includes(active) ? active : all[0])
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
@@ -451,12 +446,11 @@ export default function Page() {
),
)
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const desktopReviewOpen = createMemo(() => isDesktop() && controller.layout.view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(
() => newSessionDesign() && desktopReviewOpen() && !!controller.identity.params.id,
)
const terminalOpen = createMemo(() => controller.layout.view().terminal.opened())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
const terminalOpen = createMemo(() => view().terminal.opened())
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
const desktopInlineTerminalOnlyOpen = createMemo(
() => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
@@ -517,21 +511,53 @@ export default function Page() {
}),
)
const openReviewPanel = () => {
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const timeline = createTimelineModel({ session: controller })
function normalizeTabs(list: string[]) {
const seen = new Set<string>()
const next: string[] = []
for (const item of list) {
const value = normalizeTab(item)
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
return next
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
const canReview = createMemo(() => !!sync().project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: canReview,
})
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
const lastUserMessage = timeline.lastUserMessage
const messages = timeline.messages
const messagesReady = timeline.ready
const sessionSync = timeline.resource
const userMessages = timeline.userMessages
const visibleUserMessages = timeline.visibleUserMessages
createEffect(() => {
const tab = controller.tabs.activeFileTab()
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -551,7 +577,7 @@ export default function Page() {
let restoredModelSession: string | undefined
createEffect(() => {
const id = controller.identity.params.id
const id = params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
@@ -562,7 +588,7 @@ export default function Page() {
createEffect(
on(
() => ({ dir: sdk().directory, id: controller.identity.params.id }),
() => ({ dir: sdk().directory, id: params.id }),
(next, prev) => {
if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return
@@ -594,10 +620,10 @@ export default function Page() {
)
createComputed((prev) => {
const key = controller.identity.sessionKey()
const key = sessionKey()
if (key !== prev) {
setStore("deferRender", true)
const owner = controller.ownership.capture()
const owner = sessionOwnership.capture()
requestAnimationFrame(() => {
setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
})
@@ -644,8 +670,7 @@ export default function Page() {
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() ||
(desktopReviewOpen() &&
(controller.tabs.activeTab() === "review" || (newSessionDesign() && !!controller.tabs.activeFileTab())))
(desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab())))
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
@@ -879,7 +904,7 @@ export default function Page() {
createEffect(
on(
() => {
const id = controller.identity.params.id
const id = params.id
return [
sdk().directory,
id,
@@ -900,7 +925,7 @@ export default function Page() {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk().directory !== dir || controller.identity.params.id !== id) return
if (sdk().directory !== dir || params.id !== id) return
untrack(() => {
void sync().session.todo(id, cached ? { force: true } : undefined)
})
@@ -925,7 +950,7 @@ export default function Page() {
createEffect(
on(
controller.identity.sessionKey,
sessionKey,
() => {
setStore(sessionViewState())
setUi("pendingMessage", undefined)
@@ -1046,7 +1071,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked() || controller.data.isChild()) return
if (composer.blocked() || isChildSession()) return
const input = inputRef
if (!input) return
input.focus()
@@ -1063,12 +1088,12 @@ export default function Page() {
if (list.includes(mode)) return
const next = list[0]
if (!next) return
controller.layout.view().review.setMode(next)
view().review.setMode(next)
})
createEffect(
on(
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
() => sync().data.session_status[params.id ?? ""]?.type,
(next, prev) => {
if (next !== "idle" || prev === undefined || prev === "idle") return
refreshVcs()
@@ -1087,7 +1112,7 @@ export default function Page() {
createEffect(
on(
controller.identity.sessionKey,
sessionKey,
() => {
setTree({
reviewScroll: undefined,
@@ -1104,16 +1129,17 @@ export default function Page() {
}
const focusInput = () => {
if (controller.data.isChild()) return
if (isChildSession()) return
inputRef?.focus()
}
useComposerCommands()
useSessionCommands({
session: controller,
navigateMessageByOffset,
setActiveMessage,
focusInput,
review: reviewTab,
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
})
command.register("session-palette", () => [
{
@@ -1127,8 +1153,8 @@ export default function Page() {
const openReviewFile = createOpenReviewFile({
showAllFiles,
tabForPath: file.tab,
openTab: controller.layout.tabs().open,
setActive: controller.layout.tabs().setActive,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})
@@ -1148,7 +1174,7 @@ export default function Page() {
options={changesOptions()}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && controller.layout.view().review.setMode(option)}
onSelect={(option) => option && view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
@@ -1169,7 +1195,7 @@ export default function Page() {
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && controller.layout.view().review.setMode(option)}
onSelect={(option) => option && view().review.setMode(option)}
/>
)
}
@@ -1239,7 +1265,7 @@ export default function Page() {
title={changesTitle()}
empty={reviewEmpty(input)}
diffs={reviewDiffs}
view={controller.layout.view}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
@@ -1345,7 +1371,7 @@ export default function Page() {
createEffect(
on(
controller.tabs.activeFileTab,
activeFileTab,
(active) => {
if (!active) return
if (fileTreeTab() !== "changes") return
@@ -1384,15 +1410,15 @@ export default function Page() {
const top = reviewDiffTop(path)
if (top === undefined) return false
controller.layout.view().setScroll("review", { x: root.scrollLeft, y: top })
view().setScroll("review", { x: root.scrollLeft, y: top })
root.scrollTo({ top, behavior: "auto" })
return true
}
const focusReviewDiff = (path: string) => {
openReviewPanel()
controller.layout.view().review.openPath(path)
controller.layout.view().review.setFile(path)
view().review.openPath(path)
view().review.setFile(path)
setTree("pendingDiff", path)
}
@@ -1454,7 +1480,7 @@ export default function Page() {
on(
() => sdk().directory,
() => {
const tab = controller.tabs.activeFileTab()
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
@@ -1470,7 +1496,7 @@ export default function Page() {
})
createEffect(
on(
() => controller.identity.params.id,
() => params.id,
(id, previous) => {
if (!id || !previous || id === previous) return
if (location.hash || store.messageId || ui.pendingMessage) return
@@ -1562,7 +1588,7 @@ export default function Page() {
const historyRequests = new Set<string>()
let historyContinuationFrame: number | undefined
const loadOlder = async () => {
const owner = controller.ownership.capture()
const owner = sessionOwnership.capture()
if (historyLoading() || historyRequests.has(owner.key)) return
historyRequests.add(owner.key)
const before = timeline.messages().length
@@ -1584,7 +1610,7 @@ export default function Page() {
}
const onHistoryScroll = () => {
if (
historyRequests.has(controller.ownership.key()) ||
historyRequests.has(sessionOwnership.key()) ||
historyLoading() ||
!autoScroll.userScrolled() ||
!scroller ||
@@ -1604,7 +1630,7 @@ export default function Page() {
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!controller.identity.params.id || !messagesReady()) return
if (!params.id || !messagesReady()) return
if (autoScroll.userScrolled() || historyLoading()) return
const el = scroller
@@ -1620,7 +1646,7 @@ export default function Page() {
on(
() =>
[
controller.identity.params.id,
params.id,
messagesReady(),
historyMore(),
historyLoading(),
@@ -1660,11 +1686,9 @@ export default function Page() {
})
}
const roll = (
sessionID: string,
next: NonNullable<ReturnType<typeof controller.data.info>>["revert"],
target = sync(),
) => {
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const session = target.session.get(sessionID)
if (!session) return
target.session.remember({ ...session, revert: next })
@@ -1673,20 +1697,20 @@ export default function Page() {
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const queuedFollowups = createMemo(() => {
const id = controller.identity.params.id
const id = params.id
if (!id) return emptyFollowups
return followup.items[id] ?? emptyFollowups
})
const editingFollowup = createMemo(() => {
const id = controller.identity.params.id
const id = params.id
if (!id) return
return followup.edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = controller.ownership.capture()
const owner = sessionOwnership.capture()
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
@@ -1716,21 +1740,16 @@ export default function Page() {
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
const sendingFollowup = createMemo(() => {
const id = controller.identity.params.id
const id = params.id
if (!id) return
if (!followupBusy(id)) return
return followupMutation.variables?.id
})
const queueEnabled = createMemo(() => {
const id = controller.identity.params.id
const id = params.id
if (!id) return false
return (
settings.general.followup() === "queue" &&
controller.data.working() &&
!composer.blocked() &&
!controller.data.isChild()
)
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
})
const followupText = (item: FollowupDraft) => {
@@ -1771,7 +1790,7 @@ export default function Page() {
}
const editFollowup = (id: string) => {
const sessionID = controller.identity.params.id
const sessionID = params.id
if (!sessionID) return
if (followupBusy(sessionID)) return
@@ -1788,7 +1807,7 @@ export default function Page() {
}
const clearFollowupEdit = () => {
const id = controller.identity.params.id
const id = params.id
if (!id) return
setFollowup("edit", id, undefined)
}
@@ -1802,7 +1821,7 @@ export default function Page() {
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const api = sdk().api.session
const session = sdk().api.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1812,7 +1831,7 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () => halt(input.sessionID).then(() => api.revert.stage(input)),
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
complete: () => undefined,
rollback: () => roll(input.sessionID, last, target),
fail,
@@ -1822,10 +1841,10 @@ export default function Page() {
const restoreMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const sessionID = controller.identity.params.id
const sessionID = params.id
if (!sessionID) return
const api = sdk().api.session
const session = sdk().api.session
const target = sync()
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return
@@ -1844,8 +1863,8 @@ export default function Page() {
},
request: () =>
!next
? halt(sessionID).then(() => api.revert.clear({ sessionID }))
: halt(sessionID).then(() => api.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
complete: () => undefined,
rollback: () => roll(sessionID, last, target),
fail,
@@ -1862,12 +1881,12 @@ export default function Page() {
}
const restore = (id: string) => {
if (!controller.identity.params.id || reverting()) return
if (!params.id || reverting()) return
return restoreMutation.mutateAsync(id)
}
const rolled = createMemo(() => {
const id = controller.data.revertMessageID()
const id = revertMessageID()
if (!id) return []
const index = userMessages().findIndex((item) => item.id === id)
if (index < 0) return []
@@ -1902,7 +1921,7 @@ export default function Page() {
const actions = { revert, openAttachment }
createEffect(() => {
const sessionID = controller.identity.params.id
const sessionID = params.id
if (!sessionID) return
const item = queuedFollowups()[0]
@@ -1910,9 +1929,9 @@ export default function Page() {
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (controller.data.isChild()) return
if (isChildSession()) return
if (composer.blocked()) return
if (controller.data.working()) return
if (busy(sessionID)) return
void sendFollowup(sessionID, item.id)
})
@@ -1940,8 +1959,8 @@ export default function Page() {
)
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
sessionKey,
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
historyMore,
@@ -1967,7 +1986,7 @@ export default function Page() {
createEffect(
on(
() => controller.identity.params.id,
() => params.id,
(id) => {
if (!id) requestAnimationFrame(() => inputRef?.focus())
},
@@ -2030,23 +2049,19 @@ export default function Page() {
)
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(controller.identity.sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={controller.identity.params.id} />
createEffect(on(sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={params.id} />
}
const sessionPanelContent = () => (
<>
{sessionSync() ?? ""}
<Show
when={
!isDesktop() && !!controller.identity.params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()
}
>
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={controller.identity.params.id && mobileChanges()}>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
@@ -2060,11 +2075,10 @@ export default function Page() {
})}
</div>
</Match>
<Match when={controller.identity.params.id}>
<Show when={messagesReady() ? controller.identity.params.id : undefined} keyed>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={controller}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -2109,25 +2123,25 @@ export default function Page() {
</Switch>
</div>
<Show when={(controller.identity.params.id || !newSessionDesign()) && !mobileChanges()}>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
{(_) => {
const region = createSessionComposerRegionController({
const controller = createSessionComposerRegionController({
state: composer,
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
sessionKey,
sessionID: () => params.id,
prompt,
ready: () => !store.deferRender && messagesReady(),
centered,
todo: {
collapsed: () => controller.layout.view().todoCollapsed.get(),
onToggle: () => controller.layout.view().todoCollapsed.set(!controller.layout.view().todoCollapsed.get()),
collapsed: () => view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
},
followup: () =>
controller.identity.params.id && !controller.data.isChild()
params.id && !isChildSession()
? {
items: followupDock(),
sending: sendingFollowup(),
onSend: (id) => void sendFollowup(controller.identity.params.id!, id, { manual: true }),
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
onEdit: editFollowup,
}
: undefined,
@@ -2142,11 +2156,11 @@ export default function Page() {
: undefined,
onResponseSubmit: resumeScroll,
openParent: () => {
const id = controller.data.parentID()
const id = info()?.parentID
if (!id) return
navigate(
controller.identity.params.serverKey
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
@@ -2159,7 +2173,7 @@ export default function Page() {
})
return (
<SessionComposerRegion
controller={region}
controller={controller}
promptInput={
<Show
when={newSessionDesign()}
@@ -2180,7 +2194,7 @@ export default function Page() {
shouldQueue={queueEnabled}
onQueue={queueFollowup}
onAbort={() => {
const id = controller.identity.params.id
const id = params.id
if (!id) return
setFollowup("paused", id, true)
}}
@@ -2188,7 +2202,7 @@ export default function Page() {
}
>
{(_) => {
const promptInputController = usePromptInputV2Controller({
const controller = usePromptInputV2Controller({
get controls() {
return inputController()
},
@@ -2210,12 +2224,12 @@ export default function Page() {
shouldQueue: queueEnabled,
onQueue: queueFollowup,
onAbort: () => {
const id = controller.identity.params.id
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
})
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
return <PromptInputV2Composer controller={controller} borderUnderlay />
}}
</Show>
}
@@ -2223,7 +2237,7 @@ export default function Page() {
)
}}
</Show>
<Show when={!!controller.identity.params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</>
)
@@ -2237,9 +2251,7 @@ export default function Page() {
"gap-2 p-2": settings.general.newLayoutDesigns(),
}}
>
<Show when={!isDesktop() && !!controller.identity.params.id && !settings.general.newLayoutDesigns()}>
{mobileTabs()}
</Show>
<Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
<div
classList={{
@@ -2254,13 +2266,13 @@ export default function Page() {
{settings.general.newLayoutDesigns() ? (
<Show when={sessionPanelKey()} keyed>
{(_) => (
<SessionPanelFrame newLayout raised={!!controller.identity.params.id}>
<SessionPanelFrame newLayout raised={!!params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
</Show>
) : (
<SessionPanelFrame newLayout={false} raised={!!controller.identity.params.id}>
<SessionPanelFrame newLayout={false} raised={!!params.id}>
{sessionPanelContent()}
</SessionPanelFrame>
)}
@@ -2347,7 +2359,7 @@ export default function Page() {
size.touch()
layout.terminal.resize(height)
}}
onCollapse={() => controller.layout.view().terminal.close()}
onCollapse={() => view().terminal.close()}
/>
</div>
</Show>
@@ -1,71 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
id,
sessionID: "session",
role: "user",
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
id: "msg_2",
sessionID: "session",
role: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
describe("session controller invariants", () => {
test("normalizes file tabs once while preserving non-file tabs and order", () => {
const normalize = (tab: string) => normalizeSessionTab(tab, (value) => value.toLowerCase())
expect(normalizeSessionTabs(["review", "file://SRC/A.TS", "file://src/a.ts", "context"], normalize)).toEqual([
"review",
"file://src/a.ts",
"context",
])
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant, user("msg_b"), user("msg_c")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
test("rejects work captured by a previous session", () => {
createRoot((dispose) => {
const [key, setKey] = createSignal("session-a")
const ownership = createSessionOwnership(key)
const captured = ownership.capture()
let ran = false
setKey("session-b")
expect(captured.current()).toBe(false)
expect(captured.run(() => (ran = true))).toBeUndefined()
expect(ran).toBe(false)
dispose()
})
})
})
@@ -1,101 +0,0 @@
import type { Message, UserMessage } from "@/types"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
normalizeSessionTabs,
selectSessionUserMessages,
selectVisibleSessionUserMessages,
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: (sessionID: string | undefined) => boolean
}) {
const file = useFile()
const sync = useSync()
const layout = useSessionLayout()
const sessionID = createMemo(() => layout.params.id)
const info = createMemo(() => {
const id = sessionID()
return id ? sync().session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
return id ? sync().session.get(id) : undefined
})
const status = createMemo(() => {
const id = sessionID()
return id ? (sync().data.session_status[id] ?? idle) : idle
})
const messages = createMemo(() => {
const id = sessionID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
() => selectVisibleSessionUserMessages(userMessages(), revertMessageID()),
emptyUserMessages,
{ equals: same },
)
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
tabs: layout.tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: input.review,
hasReview: input.hasReview,
fileBrowser: input.fileBrowser ? () => input.fileBrowser?.(sessionID()) ?? false : undefined,
})
return {
identity: {
params: layout.params,
sessionID,
sessionKey: layout.sessionKey,
workspaceKey: layout.workspaceKey,
},
data: {
info,
parent,
parentID,
isChild: createMemo(() => !!parentID()),
status,
working: createMemo(() => {
const id = sessionID()
return id ? sync().data.session_working(id) : false
}),
revertMessageID,
},
history: {
messages,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
},
layout: {
tabs: layout.tabs,
view: layout.view,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
...tabs,
normalize: normalizeTab,
normalizeAll: (values: string[]) => normalizeSessionTabs(values, normalizeTab),
},
}
}
export type SessionController = ReturnType<typeof createSessionController>
@@ -1,20 +0,0 @@
import type { Message, UserMessage } from "@/types"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
return normalizeFileTab(tab)
}
export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) => string) {
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
@@ -1,26 +0,0 @@
export function timelineChildTitle(input: {
parentID?: string
taskDescription?: string
title?: string
fallback: string
}) {
if (!input.parentID) return input.title ?? ""
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}
@@ -1,319 +0,0 @@
import type { Message, Part, UserMessage } from "@/types"
import { Button } from "@opencode-ai/ui/button"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return undefined
const value = part.state.input?.description
if (typeof value === "string" && value) return value
return undefined
}
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = input.session.history
.messages()
.find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = sync().data.session_message[id] ?? []
if (!boundary) return projected
const index = projected.findIndex((message) => message.id === boundary)
return index < 0 ? projected : projected.slice(0, index)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = (): string | undefined => undefined
const shareEnabled = () => false
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parts(message.id))
.map((item) => taskDescription(item, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
return timelineChildTitle({
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
const projection = createTimelineProjection({
messages: input.session.history.messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
const errorMessage = (error: unknown) => {
if (error && typeof error === "object" && "data" in error) {
const data = error.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
}
if (error instanceof Error) return error.message
return language.t("common.requestFailed")
}
const rename = async (title: string) => {
const id = input.session.identity.sessionID()
if (!id || pending.rename) return false
const next = title.trim()
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await sdk()
.api.session.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
return false
})
setPending("rename", false)
if (!success) return false
sync().set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
return true
}
const share = async () => {
const id = input.session.identity.sessionID()
if (!id || pending.share || !shareEnabled()) return
}
const unshare = async () => {
const id = input.session.identity.sessionID()
if (!id || pending.unshare || !shareEnabled()) return
}
const href = (id: string) =>
input.session.identity.params.serverKey
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
if (input.session.identity.params.serverKey)
return tabs.newDraft({
server: requireServerKey(input.session.identity.params.serverKey),
directory: sdk().directory,
})
navigate(`/${input.session.identity.params.dir}/session`)
}
const exportSession = async (id: string) => {
try {
const data = await fetchSessionExport({ sessionID: id, api: sdk().api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (error) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: error instanceof Error ? error.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (id: string) => {
const session = sync().session.get(id)
if (!session) return false
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await sdk()
.api.session.remove({ sessionID: id })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = timelineRemovedSessionIDs(sync().data.session, id)
void navigateAfterRemoval(id, session.parentID, next?.id)
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
removed.forEach((sessionID) => sync().session.evict(sessionID))
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
createEffect(
on(
() => [input.session.data.parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description || sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
return {
data: {
sessionKey: input.session.identity.sessionKey,
sessionID: input.session.identity.sessionID,
status: input.session.data.status,
titleValue,
titleLabel,
shareUrl,
shareEnabled,
parentID: input.session.data.parentID,
parentTitle,
childTitle,
showHeader,
parts,
part,
projection,
newLayoutDesigns: settings.general.newLayoutDesigns,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
editToolPartsExpanded: settings.general.editToolPartsExpanded,
},
pending: {
rename: () => pending.rename,
share: () => pending.share,
unshare: () => pending.unshare,
},
action: {
rename,
share,
unshare,
export: exportSession,
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
navigateParent: () => {
const id = input.session.data.parentID()
if (id) navigate(href(id))
},
viewShare: () => {
const url = shareUrl()
if (url) platform.openExternal(url)
},
copyShareUrl: async () => {
const url = shareUrl()
if (!url) return
await navigator.clipboard.writeText(url).then(
() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
(error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
)
},
},
}
}
export type TimelineController = ReturnType<typeof createTimelineController>
@@ -11,8 +11,10 @@ import {
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
@@ -33,6 +35,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
@@ -41,22 +45,38 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
import type { AssistantMessage, Message as MessageType, Part as PartType, ToolPart, UserMessage } from "@/types"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -64,6 +84,14 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
const timelineFallbackItemSize = 60
const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
const value = part.state.input?.description
if (typeof value === "string" && value) return value
}
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
@@ -201,8 +229,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
)
}
type MessageTimelineProps = {
session: TimelineSessionSource
export function MessageTimeline(props: {
actions?: UserActions
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
onResumeScroll: () => void
@@ -222,42 +249,92 @@ type MessageTimelineProps = {
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
}
function MessageTimelineView(
props: MessageTimelineProps & {
data: TimelineController["data"]
action: TimelineController["action"]
pending: TimelineController["pending"]
},
) {
}) {
let touchGesture: number | undefined
const navigate = useNavigate()
const serverSDK = useServerSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const ownerSessionKey = props.data.sessionKey()
const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
const platform = usePlatform()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = props.data.sessionID
const sessionStatus = props.data.status
const titleLabel = props.data.titleLabel
const shareUrl = props.data.shareUrl
const shareEnabled = props.data.shareEnabled
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const showHeader = props.data.showHeader
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionID = createMemo(() => params.id)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
if (!boundary) return messages
const index = messages.findIndex((message) => message.id === boundary)
return index < 0 ? messages : messages.slice(0, index)
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = (): string | undefined => undefined
// TODO: Restore these actions when the V2 client exposes session sharing.
// const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const shareEnabled = () => false
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
if (!id) return
return sync().session.get(id)
})
const parentMessages = createMemo(() => {
const id = parentID()
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
return parentMessages()
.flatMap((message) => getMsgParts(message.id))
.map((part) => taskDescription(part, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
if (!parentID()) return titleLabel() ?? ""
if (childTaskDescription()) return childTaskDescription()
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
@@ -447,9 +524,9 @@ function MessageTimelineView(
virtualizer.scrollToEnd()
}
let measuredSessionKey = props.data.sessionKey()
let measuredSessionKey = sessionKey()
createEffect(() => {
const key = props.data.sessionKey()
const key = sessionKey()
timelineRows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
@@ -562,6 +639,90 @@ function MessageTimelineView(
props.setScrollRef(undefined)
})
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openExternal(url)
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
const shareMutation = useMutation(() => ({
// TODO: Restore sharing when the V2 client exposes a session sharing API.
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
mutationFn: async (_id: string) => Promise.reject(new Error("Session sharing is unavailable")),
onError: (err) => {
console.error("Failed to unshare session", err)
},
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === input.id)
if (index !== -1) draft.session[index].title = input.title
}),
)
setTitle("editing", false)
},
onError: (err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
},
}))
const shareSession = () => {
const id = sessionID()
if (!id || shareMutation.isPending) return
if (!shareEnabled()) return
shareMutation.mutate(id)
}
const unshareSession = () => {
const id = sessionID()
if (!id || unshareMutation.isPending) return
if (!shareEnabled()) return
unshareMutation.mutate(id)
}
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
@@ -573,7 +734,7 @@ function MessageTimelineView(
createEffect(
on(
props.data.sessionKey,
sessionKey,
() =>
setTitle({
draft: "",
@@ -586,6 +747,18 @@ function MessageTimelineView(
),
)
createEffect(
on(
() => [parentID(), childTaskDescription()] as const,
([id, description]) => {
if (!id || description) return
if (sync().data.message[id] !== undefined) return
void sync().session.sync(id)
},
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
@@ -597,12 +770,186 @@ function MessageTimelineView(
}
const closeTitleEditor = () => {
if (props.pending.rename()) return
if (titleMutation.isPending) return
setTitle("editing", false)
}
const saveTitleEditor = async () => {
if (await props.action.rename(title.draft)) setTitle("editing", false)
const saveTitleEditor = () => {
const id = sessionID()
if (!id) return
if (titleMutation.isPending) return
const next = title.draft.trim()
if (!next || next === (titleLabel() ?? "")) {
setTitle("editing", false)
return
}
titleMutation.mutate({ id, title: next })
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(href(parentID))
return
}
if (nextSessionID) {
navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
}
const exportSession = async (sessionID: string) => {
try {
const data = await fetchSessionExport({
sessionID,
api: sdk().api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
const deleteSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return false
const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of sync().data.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
sync().set(
produce((draft) => {
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
for (const id of removed) {
sync().session.evict(id)
}
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
@@ -681,7 +1028,7 @@ function MessageTimelineView(
const defaultOpen = createMemo(() => {
const item = part()
if (!item) return
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
})
return (
@@ -694,7 +1041,7 @@ function MessageTimelineView(
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={props.data.newLayoutDesigns()}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -757,8 +1104,8 @@ function MessageTimelineView(
<div
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": props.data.newLayoutDesigns(),
border: !props.data.newLayoutDesigns(),
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
@@ -793,7 +1140,7 @@ function MessageTimelineView(
if (m?.role === "user") return m
})
const messageComments = createMemo(() => {
if (!props.data.newLayoutDesigns()) return []
if (!settings.general.newLayoutDesigns()) return []
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
})
return (
@@ -806,7 +1153,7 @@ function MessageTimelineView(
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
useV2Actions={props.data.newLayoutDesigns()}
useV2Actions={settings.general.newLayoutDesigns()}
comments={messageComments()}
/>
</div>
@@ -854,7 +1201,7 @@ function MessageTimelineView(
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={props.data.showReasoningSummaries()}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
</TimelineRowFrame>
@@ -970,16 +1317,16 @@ function MessageTimelineView(
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"bottom-8": props.data.newLayoutDesigns(),
"bottom-6": !props.data.newLayoutDesigns(),
"bottom-8": settings.general.newLayoutDesigns(),
"bottom-6": !settings.general.newLayoutDesigns(),
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
"opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
"scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
"scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
}}
>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<button
type="button"
@@ -1042,22 +1389,22 @@ function MessageTimelineView(
classList={{
"sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!props.data.newLayoutDesigns(),
!settings.general.newLayoutDesigns(),
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2.5": props.data.newLayoutDesigns(),
"pl-2 md:pl-4": !props.data.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
"pl-2.5": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div
classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !props.data.newLayoutDesigns(),
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
@@ -1066,7 +1413,7 @@ function MessageTimelineView(
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
onClick={navigateParent}
>
{parentTitle()}
</button>
@@ -1087,8 +1434,8 @@ function MessageTimelineView(
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
props.data.newLayoutDesigns(),
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
>
@@ -1102,14 +1449,15 @@ function MessageTimelineView(
}}
data-slot="session-title-child"
value={title.draft}
disabled={props.pending.rename()}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": props.data.newLayoutDesigns()
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
@@ -1137,17 +1485,17 @@ function MessageTimelineView(
<div
classList={{
"shrink-0 flex items-center": true,
"gap-2": props.data.newLayoutDesigns(),
"gap-3": !props.data.newLayoutDesigns(),
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<DropdownMenu
gutter={4}
@@ -1210,12 +1558,14 @@ function MessageTimelineView(
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void props.action.export(id)}>
<DropdownMenu.Item onSelect={() => exportSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
{/* TODO: Need a V2 session archive API. */}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
@@ -1280,12 +1630,12 @@ function MessageTimelineView(
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void props.action.export(id)}>
<MenuV2.Item onSelect={() => exportSession(id)}>
{language.t("common.export")}...
</MenuV2.Item>
{/* TODO: Need a V2 session archive API. */}
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
@@ -1297,7 +1647,7 @@ function MessageTimelineView(
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={props.data.newLayoutDesigns() ? 6 : 4}
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
@@ -1309,7 +1659,7 @@ function MessageTimelineView(
data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
props.data.newLayoutDesigns(),
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
@@ -1329,7 +1679,7 @@ function MessageTimelineView(
}}
>
<Show
when={props.data.newLayoutDesigns()}
when={settings.general.newLayoutDesigns()}
fallback={
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
@@ -1350,10 +1700,10 @@ function MessageTimelineView(
size="large"
variant="primary"
class="w-full"
onClick={() => void props.action.share()}
disabled={props.pending.share()}
onClick={shareSession}
disabled={shareMutation.isPending}
>
{props.pending.share()
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@@ -1373,10 +1723,10 @@ function MessageTimelineView(
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={() => void props.action.unshare()}
disabled={props.pending.unshare()}
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{props.pending.unshare()
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@@ -1384,8 +1734,8 @@ function MessageTimelineView(
size="large"
variant="primary"
class="w-full"
onClick={props.action.viewShare}
disabled={props.pending.unshare()}
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
@@ -1413,10 +1763,10 @@ function MessageTimelineView(
<ButtonV2
variant="contrast"
class="w-full"
onClick={() => void props.action.share()}
disabled={props.pending.share()}
onClick={shareSession}
disabled={shareMutation.isPending}
>
{props.pending.share()
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</ButtonV2>
@@ -1442,7 +1792,7 @@ function MessageTimelineView(
variant="ghost-muted"
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={() => void props.action.copyShareUrl()}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
@@ -1450,18 +1800,18 @@ function MessageTimelineView(
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={props.action.viewShare}
disabled={props.pending.unshare()}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={() => void props.action.unshare()}
disabled={props.pending.unshare()}
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{props.pending.unshare()
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</ButtonV2>
@@ -1,24 +1,23 @@
import type { Message } from "@/types"
import type { Message, UserMessage } from "@/types"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import type { SessionController } from "../session-controller"
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
} from "../session-domain"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
const sessionFreshness = 15_000
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
export function createTimelineModel(input: {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
const serverSync = useServerSync()
const sync = useSync()
let refreshFrame: number | undefined
let refreshTimer: number | undefined
const [resource] = createResource(
() => input.session.identity.sessionID(),
() => input.sessionID(),
(id) => {
clearRefresh()
if (!id) return
@@ -30,7 +29,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (input.session.identity.sessionID() !== id) return
if (input.sessionID() !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
@@ -40,21 +39,33 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
return sync().session.sync(id)
},
)
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
})
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return id ? sync().session.history.more(id) : false
})
const loading = createMemo(() => {
const id = input.session.identity.sessionID()
const id = input.sessionID()
return id ? sync().session.history.loading(id) : false
})
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.session.identity.sessionID,
sessionID: input.sessionID,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@@ -67,12 +78,12 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
return {
history: { loadOlder, loading, more },
lastUserMessage: input.session.history.lastUserMessage,
messages: input.session.history.messages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
messages,
ready,
resource,
userMessages: input.session.history.userMessages,
visibleUserMessages: input.session.history.visibleUserMessages,
userMessages,
visibleUserMessages,
}
function clearRefresh() {
@@ -83,10 +94,20 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
}
}
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
}
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
@@ -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)
}
@@ -14,25 +14,19 @@ import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
identity: SessionController["identity"]
data: Pick<SessionController["data"], "info" | "revertMessageID">
history: Pick<SessionController["history"], "userMessages" | "visibleUserMessages">
layout: SessionController["layout"]
ownership: SessionController["ownership"]
tabs: Pick<SessionController["tabs"], "activeFileTab" | "closableTab">
}
export type SessionCommandContext = {
session: SessionCommandSource
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void
review?: () => boolean
fileBrowser?: () => boolean
}
const withCategory = (category: string) => {
@@ -56,13 +50,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = actions.session.ownership.capture()
const owner = sessionOwnership.capture()
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionController["ownership"]["capture"]>
owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
@@ -73,8 +69,41 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
input.owner.run(input.updateViewport)
}
const info = () => {
const id = params.id
if (!id) return
return sync().session.get(id)
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: actions.review,
hasReview,
fileBrowser: actions.fileBrowser,
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = settings.visibility.fileTree
const messages = () => {
const id = params.id
if (!id) return []
return sync().data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
@@ -92,7 +121,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const canAddSelectionContext = () => {
const tab = actions.session.tabs.activeFileTab()
const tab = activeFileTab()
if (!tab) return false
const path = file.pathFromTab(tab)
if (!path) return false
@@ -112,7 +141,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const isAutoAcceptActive = () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
return permission.isAutoAcceptingDirectory(sdk().directory)
}
@@ -157,7 +186,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const share = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
const existing = undefined
@@ -181,7 +210,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const unshare = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
// TODO: Restore unsharing when the V2 client exposes a session sharing API.
@@ -193,7 +222,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const exportSession = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
@@ -225,13 +254,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const closeTab = () => {
const tab = actions.session.tabs.closableTab()
const tab = closableTab()
if (!tab) return
actions.session.layout.tabs().close(tab)
tabs().close(tab)
}
const addSelection = () => {
const tab = actions.session.tabs.activeFileTab()
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
@@ -252,7 +281,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
actions.session.layout.view().terminal.open()
view().terminal.open()
}
const closeTerminal = () => {
@@ -260,7 +289,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
const last = terminal.all().length === 1
void terminal.close(id)
if (last) actions.session.layout.view().terminal.close()
if (last) view().terminal.close()
}
const chooseMcp = () => {
@@ -271,7 +300,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const toggleAutoAccept = () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
@@ -289,14 +318,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const undo = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const owner = sessionOwnership.capture()
const session = sdk().api.session
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = actions.session.data.revertMessageID()
const messages = actions.session.history.userMessages()
const revert = info()?.revert?.messageID
const messages = userMessages()
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
@@ -319,14 +348,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const redo = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const owner = sessionOwnership.capture()
const session = sdk().api.session
const messages = actions.session.history.userMessages()
const messages = userMessages()
const promptSession = prompt.capture()
const revertMessageID = actions.session.data.revertMessageID()
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const boundary = messages.findIndex((message) => message.id === revertMessageID)
@@ -353,7 +382,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const compact = async () => {
const sessionID = actions.session.identity.params.id
const sessionID = params.id
if (!sessionID) return
await sdk().api.session.compact({ sessionID })
@@ -374,14 +403,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return [
sessionCommand({
id: "session.share",
title: actions.session.data.info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: actions.session.data.info()?.share?.url
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !actions.session.identity.params.id,
disabled: !params.id,
onSelect: share,
}),
sessionCommand({
@@ -389,7 +416,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.share?.url,
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
]
@@ -407,7 +434,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.trigger("tab.new", source)
return
}
navigate(`/${actions.session.identity.params.dir}/session`)
navigate(`/${params.dir}/session`)
},
}),
sessionCommand({
@@ -415,7 +442,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
@@ -423,7 +450,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
@@ -431,7 +458,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
@@ -439,7 +466,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
sessionCommand({
@@ -447,13 +474,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.export"),
description: language.t("command.session.export.description"),
slash: "export",
disabled: !actions.session.identity.params.id,
disabled: !params.id,
onSelect: exportSession,
}),
]
const fileCmds = () => {
const tab = actions.session.tabs.closableTab()
const tab = closableTab()
return [
fileCommand({
id: "file.open",
@@ -491,20 +518,20 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => {
if (actions.session.layout.view().terminal.opened()) {
if (view().terminal.opened()) {
terminal.cancelFocus()
actions.session.layout.view().terminal.close()
view().terminal.close()
return
}
terminal.requestFocus(terminal.active())
actions.session.layout.view().terminal.open()
view().terminal.open()
},
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => actions.session.layout.view().reviewPanel.toggle(),
onSelect: () => view().reviewPanel.toggle(),
}),
...(shown()
? [
@@ -548,7 +575,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !actions.session.identity.params.id,
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
@@ -556,7 +583,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !actions.session.identity.params.id,
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
+12 -10
View File
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js"
import type { ServerCollectionController } from "@/components/server/server-management-controller"
import type { useServerManagementController } from "@/components/dialog-select-server"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -17,6 +17,8 @@ import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
@@ -26,7 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog()
const language = useLanguage()
const openAddWsl = () => {
void dialog.push(() => <DialogAddWslServer />)
dialog.push(() => <DialogAddWslServer />)
}
return (
<Show
@@ -65,7 +67,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
}
export function WslServerSettings(props: {
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
controller: Controller
servers: ReturnType<typeof useFilteredWslServers>
}) {
const platform = usePlatform()
@@ -84,7 +86,7 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.domain.connection.remove(key))
request.mutate(() => props.controller.handleRemove(key))
}
return (
@@ -98,7 +100,7 @@ export function WslServerSettings(props: {
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
<ServerHealthIndicator health={props.controller.status()[key]} />
<div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span>
@@ -112,7 +114,7 @@ export function WslServerSettings(props: {
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<Show when={opencodeAction()}>
@@ -143,13 +145,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")}
</MenuV2.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
@@ -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 () => {
+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
}),
}
}
+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" })),
@@ -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))
+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": {
+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:",
+8 -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)
@@ -654,6 +642,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
const selection = local.model.selection()
route.navigate({
type: "home",
location:
@@ -661,6 +650,10 @@ function App(props: { pair?: DialogPairCredentials }) {
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
})
if (selection) {
local.model.set(selection)
local.model.variant.set(selection.variant)
}
dialog.clear()
},
},
+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>
+45 -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",
@@ -601,7 +561,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 +614,6 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1304,39 +1262,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 +1307,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 +1344,7 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1536,74 +1468,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 +1496,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 +1506,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 +1519,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)
}

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