mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0605bd9c5 | |||
| 1719482a17 | |||
| 43901eae6d |
@@ -612,9 +612,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -902,7 +902,6 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
@@ -1088,9 +1087,9 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -1108,9 +1107,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2168,27 +2167,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -3538,7 +3537,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3622,8 +3621,6 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -4040,7 +4037,7 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -4322,7 +4319,7 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
@@ -4466,8 +4463,6 @@
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -4806,7 +4801,7 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -4932,7 +4927,7 @@
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
@@ -4958,7 +4953,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
@@ -5560,7 +5555,7 @@
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
@@ -5592,8 +5587,6 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -6532,8 +6525,6 @@
|
||||
|
||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
|
||||
|
||||
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
@@ -6718,9 +6709,11 @@
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
@@ -6796,8 +6789,6 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -7564,20 +7555,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -8638,8 +8615,6 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
+6
-6
@@ -46,9 +46,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -150,9 +150,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
+142
-130
@@ -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)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ 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 { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
@@ -24,6 +24,8 @@ import { createTimelineProjection } from "./projection"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyParts: Part[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
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
|
||||
@@ -33,16 +35,7 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => {
|
||||
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[]>
|
||||
}) {
|
||||
export function createTimelineController(input: { userMessages: Accessor<UserMessage[]> }) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -51,33 +44,46 @@ export function createTimelineController(input: {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const status = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync().data.session_status[id] ?? idle
|
||||
})
|
||||
const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
const id = 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 boundary = 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 info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
const shareEnabled = () => false
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
})
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = input.session.data.parentID()
|
||||
const id = parentID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(
|
||||
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const parentTitle = createMemo(() => sessionTitle(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()
|
||||
const id = sessionID()
|
||||
if (!id) return undefined
|
||||
return parentMessages()
|
||||
.flatMap((message) => parts(message.id))
|
||||
@@ -86,19 +92,19 @@ export function createTimelineController(input: {
|
||||
})
|
||||
const childTitle = createMemo(() => {
|
||||
return timelineChildTitle({
|
||||
parentID: input.session.data.parentID(),
|
||||
parentID: parentID(),
|
||||
taskDescription: childTaskDescription(),
|
||||
title: titleLabel(),
|
||||
fallback: language.t("command.session.new"),
|
||||
})
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: input.session.history.messages,
|
||||
messages,
|
||||
userMessages: input.userMessages,
|
||||
sessionMessages: projectedMessages,
|
||||
parts,
|
||||
status: input.session.data.status,
|
||||
status,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
})
|
||||
@@ -113,7 +119,7 @@ export function createTimelineController(input: {
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const rename = async (title: string) => {
|
||||
const id = input.session.identity.sessionID()
|
||||
const id = sessionID()
|
||||
if (!id || pending.rename) return false
|
||||
const next = title.trim()
|
||||
if (!next || next === (titleLabel() ?? "")) return true
|
||||
@@ -136,27 +142,22 @@ export function createTimelineController(input: {
|
||||
return true
|
||||
}
|
||||
const share = async () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
const id = sessionID()
|
||||
if (!id || pending.share || !shareEnabled()) return
|
||||
}
|
||||
const unshare = async () => {
|
||||
const id = input.session.identity.sessionID()
|
||||
const id = 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)
|
||||
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (input.session.identity.params.id !== id) return
|
||||
if (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`)
|
||||
if (params.serverKey)
|
||||
return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
const exportSession = async (id: string) => {
|
||||
try {
|
||||
@@ -249,7 +250,7 @@ export function createTimelineController(input: {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
||||
() => [parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description || sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
@@ -260,14 +261,14 @@ export function createTimelineController(input: {
|
||||
|
||||
return {
|
||||
data: {
|
||||
sessionKey: input.session.identity.sessionKey,
|
||||
sessionID: input.session.identity.sessionID,
|
||||
status: input.session.data.status,
|
||||
sessionKey,
|
||||
sessionID,
|
||||
status,
|
||||
titleValue,
|
||||
titleLabel,
|
||||
shareUrl,
|
||||
shareEnabled,
|
||||
parentID: input.session.data.parentID,
|
||||
parentID,
|
||||
parentTitle,
|
||||
childTitle,
|
||||
showHeader,
|
||||
@@ -291,7 +292,7 @@ export function createTimelineController(input: {
|
||||
export: exportSession,
|
||||
showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
|
||||
navigateParent: () => {
|
||||
const id = input.session.data.parentID()
|
||||
const id = parentID()
|
||||
if (id) navigate(href(id))
|
||||
},
|
||||
viewShare: () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineController, type TimelineController } from "./controller"
|
||||
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
@@ -202,7 +202,6 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
session: TimelineSessionSource
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
@@ -225,7 +224,7 @@ type MessageTimelineProps = {
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
|
||||
const controller = createTimelineController({ userMessages: () => props.userMessages })
|
||||
return (
|
||||
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -110,7 +110,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
<form
|
||||
data-component="prompt-input-v2"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
|
||||
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-base"
|
||||
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
@@ -691,7 +691,7 @@ export function PromptInputV2SubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-7 rounded-md p-[6px] [&_[data-slot=icon-svg]]:!text-v2-icon-icon-contrast shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
|
||||
@@ -97,6 +97,7 @@ import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider } from "./context/storage"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -251,6 +252,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
)
|
||||
})
|
||||
const clipboard = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createTuiClipboard(renderer)),
|
||||
(clipboard) =>
|
||||
Effect.tryPromise(() => clipboard.dispose()).pipe(
|
||||
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const finalizers = new Set<() => Promise<void>>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -287,7 +295,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<TuiAppProvider value={input.app}>
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
fallback={(error, reset) => (
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorComponent error={error} reset={reset} mode={mode} />
|
||||
</ClipboardProvider>
|
||||
)}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
@@ -336,7 +348,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
@@ -504,7 +516,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
await clipboard
|
||||
.write?.(text)
|
||||
.write(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
|
||||
+59
-118
@@ -1,125 +1,66 @@
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { platform, release, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
import {
|
||||
createClipboard,
|
||||
createHostClipboard,
|
||||
createRendererClipboardAdapter,
|
||||
decodePasteBytes,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
|
||||
|
||||
function command(command: string, args: string[] = [], input?: string) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
||||
const output: Buffer[] = []
|
||||
child.on("error", reject)
|
||||
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve(Buffer.concat(output))
|
||||
reject(new Error(`${command} exited with code ${code}`))
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
|
||||
return createClipboardAdapter(
|
||||
createClipboard({
|
||||
host: createHostClipboard(),
|
||||
terminal: createRendererClipboardAdapter(renderer),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function writeOsc52(text: string) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||
const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\`
|
||||
process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence)
|
||||
}
|
||||
|
||||
export async function read() {
|
||||
if (platform() === "darwin") {
|
||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await exec("osascript", [
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${file}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
])
|
||||
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
// Fall through to text clipboard.
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (platform() === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
|
||||
}
|
||||
|
||||
if (platform() === "linux") {
|
||||
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
|
||||
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
|
||||
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
const text = await clipboardy.read().catch(() => undefined)
|
||||
if (text) return { data: text, mime: "text/plain" }
|
||||
}
|
||||
|
||||
export function copyCommand(
|
||||
os: NodeJS.Platform,
|
||||
wayland: boolean,
|
||||
has: (name: string) => boolean,
|
||||
): string[] | undefined {
|
||||
if (os === "darwin" && has("osascript")) return ["osascript"]
|
||||
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
|
||||
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
|
||||
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
|
||||
if (os === "win32" && has("powershell.exe")) {
|
||||
return [
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
|
||||
|
||||
function getCopyMethod() {
|
||||
return (copyMethod ??= (async () => {
|
||||
const { which } = await import("@opencode-ai/core/util/which")
|
||||
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
|
||||
if (native?.[0] === "osascript") {
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
|
||||
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
|
||||
return {
|
||||
async read(): Promise<ClipboardContent | undefined> {
|
||||
const result = await clipboard.read({
|
||||
preferredTypes: ["image/png", "text/plain"],
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.status !== "read") {
|
||||
if (result.status === "failed") throw result.error
|
||||
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
|
||||
if (result.status === "limit-exceeded") {
|
||||
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
return async (text: string) => {
|
||||
await command(native[0], native.slice(1), text).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return async (text: string) => {
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
await clipboardy.write(text).catch(() => undefined)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
export async function write(text: string) {
|
||||
writeOsc52(text)
|
||||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
if (result.representation.mimeType === "image/png") {
|
||||
return {
|
||||
data: Buffer.from(result.representation.bytes).toString("base64"),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
if (result.representation.mimeType === "text/plain") {
|
||||
if (result.representation.bytes.length === 0) return undefined
|
||||
return {
|
||||
data: decodePasteBytes(result.representation.bytes),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
|
||||
},
|
||||
async write(text) {
|
||||
const result = await clipboard.writeText(text, {
|
||||
destination: "all-available",
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.host.status === "written" || result.terminal.status === "attempted") return
|
||||
if (result.host.status === "failed") throw result.host.error
|
||||
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`)
|
||||
},
|
||||
dispose() {
|
||||
return clipboard.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Transcript images",
|
||||
category: "Session",
|
||||
path: ["session", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
@@ -188,6 +197,15 @@ export const settings: Setting[] = [
|
||||
values: ["compact", "full"],
|
||||
keywords: ["paste summary", "clipboard", "pasted content"],
|
||||
},
|
||||
{
|
||||
title: "Image previews",
|
||||
category: "Input",
|
||||
path: ["prompt", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "clipboard", "images", "prompt"],
|
||||
},
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DialogDebug() {
|
||||
.map((entry) => `${entry.label}: ${entry.value}`)
|
||||
.join("\n")
|
||||
void clipboard
|
||||
.write?.(text)
|
||||
.write(text)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
type ImagePreviewItem = Readonly<{
|
||||
uri: string
|
||||
mention?: Readonly<{ text: string }>
|
||||
}>
|
||||
|
||||
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
const current = createMemo(() => props.images[index()])
|
||||
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
|
||||
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
|
||||
function move(direction: number) {
|
||||
if (props.images.length < 2) return
|
||||
setFailed(false)
|
||||
setIndex((value) => (value + direction + props.images.length) % props.images.length)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
|
||||
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Image {index() + 1} of {props.images.length}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<image
|
||||
id="prompt-image-viewer-image"
|
||||
source={current().uri}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height={imageHeight()}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
|
||||
{props.images.length > 1 ? "← previous" : ""}
|
||||
</text>
|
||||
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
|
||||
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
|
||||
{props.images.length > 1 ? "next →" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -492,7 +492,7 @@ function OAuthAuto(props: {
|
||||
run: () => {
|
||||
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
|
||||
clipboard
|
||||
.write?.(value)
|
||||
.write(value)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
|
||||
@@ -172,7 +172,6 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const copy = () => {
|
||||
if (!clipboard.write) return
|
||||
void clipboard
|
||||
.write(error())
|
||||
.then(() => setCopied(true))
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
const app = useTuiApp()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
|
||||
|
||||
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
|
||||
// theme context may be the thing that crashed.
|
||||
@@ -46,11 +46,19 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
const issueURL = buildIssueURL(message, stack, app.version)
|
||||
|
||||
const copyReport = () => {
|
||||
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
|
||||
void clipboard
|
||||
.write(issueURL.toString())
|
||||
.then(() => setCopyState("copied"))
|
||||
.catch(() => setCopyState("failed"))
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
|
||||
{
|
||||
key: "c",
|
||||
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
|
||||
copy: true,
|
||||
onUse: copyReport,
|
||||
},
|
||||
{ key: "r", label: () => "Restart", onUse: props.reset },
|
||||
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
||||
]
|
||||
@@ -135,13 +143,20 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<For each={actions}>
|
||||
{(action, index) => {
|
||||
const isSelected = () => selected() === index()
|
||||
const isCopied = () => action.copy && copied()
|
||||
const copyColor = () =>
|
||||
action.copy
|
||||
? copyState() === "copied"
|
||||
? colors.success
|
||||
: copyState() === "failed"
|
||||
? colors.error
|
||||
: undefined
|
||||
: undefined
|
||||
return (
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
onMouseDown={() => setSelected(index())}
|
||||
onMouseUp={() => action.onUse()}
|
||||
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
|
||||
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
|
||||
minWidth={15}
|
||||
alignItems="center"
|
||||
paddingLeft={2}
|
||||
@@ -149,7 +164,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
|
||||
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
|
||||
>
|
||||
{action.label()}
|
||||
</text>
|
||||
@@ -189,9 +204,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<Show when={showFooter()}>
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<text fg={colors.muted}>
|
||||
{copied()
|
||||
{copyState() === "copied"
|
||||
? "Report copied — paste it into a new GitHub issue."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
: copyState() === "failed"
|
||||
? "Clipboard write failed. Try again or report the crash manually."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
</text>
|
||||
<text fg={colors.muted}>OpenCode {app.version}</text>
|
||||
</box>
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
decodePasteBytes,
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
@@ -48,13 +47,20 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import {
|
||||
normalizePastedFilepath,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachment,
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -72,17 +78,6 @@ export type PromptProps = {
|
||||
}
|
||||
}
|
||||
|
||||
function pastedFilepath(value: string, platform: string) {
|
||||
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
}
|
||||
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
current: PromptInfo
|
||||
@@ -312,6 +307,41 @@ export function Prompt(props: PromptProps) {
|
||||
extmarkToPart: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
let disposed = false
|
||||
let pasteQueue = Promise.resolve()
|
||||
|
||||
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || input.isDestroyed) return
|
||||
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||
await run(
|
||||
() =>
|
||||
disposed ||
|
||||
input.isDestroyed ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) toast.error(error)
|
||||
})
|
||||
return pasteQueue
|
||||
}
|
||||
|
||||
const imageAttachments = createMemo(() =>
|
||||
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||
)
|
||||
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
|
||||
|
||||
function openImagePreview(initial: number) {
|
||||
const images = imageAttachments()
|
||||
if (images.length === 0) return
|
||||
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -391,22 +421,32 @@ export function Prompt(props: PromptProps) {
|
||||
name: "prompt.paste",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
const content = await clipboard.read?.()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
return enqueuePaste(async (changed) => {
|
||||
const content = await clipboard.read()
|
||||
if (changed()) return
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data, changed)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View image attachments",
|
||||
name: "prompt.images.view",
|
||||
category: "Prompt",
|
||||
enabled: imageAttachments().length > 0,
|
||||
run: () => openImagePreview(0),
|
||||
},
|
||||
{
|
||||
title: "Interrupt session",
|
||||
name: "session.interrupt",
|
||||
@@ -561,6 +601,7 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
"prompt.images.view",
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
@@ -614,6 +655,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
@@ -1262,27 +1304,39 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string) {
|
||||
async function pasteInputText(text: string, changed: () => boolean) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment?.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
||||
if (attachment) {
|
||||
if (changed()) return
|
||||
pasteLocalAttachment(filepath, attachment)
|
||||
return
|
||||
}
|
||||
if (attachment?.type === "binary") {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
return
|
||||
|
||||
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||
if (filepaths.length > 1) {
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) break
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
if (attachments.length === filepaths.length) {
|
||||
if (changed()) return
|
||||
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed()) return
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
@@ -1307,12 +1361,27 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
}
|
||||
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
@@ -1344,7 +1413,6 @@ export function Prompt(props: PromptProps) {
|
||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
@@ -1468,6 +1536,74 @@ export function Prompt(props: PromptProps) {
|
||||
flexGrow={1}
|
||||
width="100%"
|
||||
>
|
||||
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
|
||||
<box
|
||||
width="100%"
|
||||
height={imagePreviewHeight() + 1}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
justifyContent="flex-start"
|
||||
gap={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<For each={visibleImageAttachments()}>
|
||||
{(file, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={imagePreviewWidth()}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={imagePreviewWidth()}
|
||||
flexShrink={1}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
openImagePreview(index())
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!failed()}
|
||||
fallback={
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||
<text fg={theme.text.subdued}>No preview</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<image
|
||||
id={`prompt-image-preview-${index()}`}
|
||||
source={file.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={imageAttachments().length > visibleImageAttachments().length}>
|
||||
<box
|
||||
width={8}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={8}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
openImagePreview(visibleImageAttachments().length)
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||
+{imageAttachments().length - visibleImageAttachments().length} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
@@ -1496,7 +1632,7 @@ export function Prompt(props: PromptProps) {
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
onPaste={(event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1506,11 +1642,10 @@ export function Prompt(props: PromptProps) {
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (!pastedContent) {
|
||||
if (event.bytes.byteLength === 0) {
|
||||
keymap.dispatch("prompt.paste")
|
||||
return
|
||||
}
|
||||
@@ -1519,7 +1654,7 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
await pasteInputText(normalizedText)
|
||||
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
|
||||
const MAX_PASTED_FILEPATHS = 32
|
||||
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export type LocalFiles = Readonly<{
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
readText(path: string, maxBytes: number): Promise<string>
|
||||
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
@@ -11,14 +14,15 @@ export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export function readLocalAttachment(file: string) {
|
||||
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||
readBytes: readFileBounded,
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,16 +37,108 @@ const mimeTypes: Record<string, string> = {
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
async function readFileBounded(file: string, maxBytes: number) {
|
||||
const source = Bun.file(file)
|
||||
if (!(await source.exists())) throw new Error("Attachment does not exist")
|
||||
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
|
||||
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||
return content
|
||||
}
|
||||
|
||||
export function normalizePastedFilepath(value: string, platform: string) {
|
||||
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||
const url = decodeFileURL(raw, platform)
|
||||
if (url) return url
|
||||
if (platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
}
|
||||
|
||||
function decodeFileURL(value: string, platform: string): string | undefined {
|
||||
if (!value.startsWith("file://")) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (/%2f|%5c/i.test(url.pathname)) return undefined
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
if (platform !== "win32") {
|
||||
if (url.hostname && url.hostname !== "localhost") return undefined
|
||||
return pathname
|
||||
}
|
||||
const local = pathname.replace(/^\/([A-Za-z]:)/, "$1").replaceAll("/", "\\")
|
||||
if (url.hostname && url.hostname !== "localhost") return `\\\\${url.hostname}${local}`
|
||||
return local
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePastedFilepaths(value: string, platform: string) {
|
||||
const result: string[] = []
|
||||
let current = ""
|
||||
let quote = ""
|
||||
|
||||
function push() {
|
||||
if (!current) return
|
||||
result.push(decodeFileURL(current, platform) ?? current)
|
||||
current = ""
|
||||
}
|
||||
|
||||
const input = value.includes("file://")
|
||||
? value
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trimStart().startsWith("#"))
|
||||
.join("\n")
|
||||
: value
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const character = input[index]
|
||||
if (quote) {
|
||||
if (character === quote) {
|
||||
quote = ""
|
||||
continue
|
||||
}
|
||||
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
|
||||
current += input[++index]
|
||||
continue
|
||||
}
|
||||
current += character
|
||||
continue
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
|
||||
current += input[++index]
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(character)) {
|
||||
push()
|
||||
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||
continue
|
||||
}
|
||||
current += character
|
||||
}
|
||||
|
||||
if (quote) return []
|
||||
push()
|
||||
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||
return result
|
||||
}
|
||||
|
||||
export async function readLocalAttachmentWith(
|
||||
files: LocalFiles,
|
||||
path: string,
|
||||
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
): Promise<LocalAttachment | undefined> {
|
||||
const mime = await files.mime(path).catch(() => undefined)
|
||||
if (!mime) return
|
||||
if (!mime) return undefined
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await files.readText(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||
return { type: "text", mime, content }
|
||||
}
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
||||
const content = await files.readBytes(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||
if (!content || content.byteLength > maxBytes) return undefined
|
||||
return { type: "binary", mime, content }
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ export const Info = Schema.Struct({
|
||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||
description: "Display large pastes as compact placeholders or full text",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show image attachment previews above the prompt input",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Prompt input behavior" }),
|
||||
session: Schema.optional(
|
||||
@@ -128,6 +131,9 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
|
||||
@@ -165,6 +165,7 @@ export const Definitions = {
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||
@@ -366,6 +367,7 @@ export const CommandMap = {
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_queue: "prompt.queue",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_images_view: "prompt.images.view",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
prompt_stash_pop: "prompt.stash.pop",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardService = Readonly<{
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
read(): Promise<ClipboardContent | undefined>
|
||||
write(text: string): Promise<void>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
const ClipboardContext = createContext<ClipboardService>()
|
||||
|
||||
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
return useContext(ClipboardContext)
|
||||
const value = useContext(ClipboardContext)
|
||||
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -66,8 +66,12 @@ export function DialogMessage(props: {
|
||||
: "text" in value
|
||||
? value.text
|
||||
: ""
|
||||
await clipboard.write?.(text)
|
||||
dialog.clear()
|
||||
try {
|
||||
await clipboard.write(text)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -366,7 +366,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
|
||||
function copyExternal() {
|
||||
const current = externalField()
|
||||
if (!current || !clipboard.write) return
|
||||
if (!current) return
|
||||
void clipboard
|
||||
.write(current.url)
|
||||
.then(() => {
|
||||
@@ -996,7 +996,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
>
|
||||
enter <span style={{ fg: theme.text.subdued }}>{actionLabel()}</span>
|
||||
</text>
|
||||
<Show when={externalField() && clipboard.write}>
|
||||
<Show when={externalField()}>
|
||||
<text fg={theme.text.default} onMouseUp={copyExternal}>
|
||||
c <span style={{ fg: theme.text.subdued }}>copy</span>
|
||||
</text>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -54,6 +54,7 @@ import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
@@ -847,7 +848,7 @@ export function Session() {
|
||||
}
|
||||
|
||||
clipboard
|
||||
.write?.(text)
|
||||
.write(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
dialog.clear()
|
||||
@@ -865,7 +866,7 @@ export function Session() {
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
||||
await clipboard.write?.(transcript)
|
||||
await clipboard.write(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch {
|
||||
toast.show({ message: "Failed to copy session transcript", variant: "error" })
|
||||
@@ -899,7 +900,7 @@ export function Session() {
|
||||
) + EOL
|
||||
|
||||
if (options.action === "copy") {
|
||||
await clipboard.write?.(content)
|
||||
await clipboard.write(content)
|
||||
dialog.clear()
|
||||
toast.show({ message: "Copied to clipboard", variant: "success" })
|
||||
return
|
||||
@@ -1587,8 +1588,9 @@ function SessionGroupView(props: {
|
||||
</InlineToolRow>
|
||||
</Show>
|
||||
<Show when={expanded() && grouped().length > 0}>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
|
||||
</Show>
|
||||
<ToolImages parts={grouped()} />
|
||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1893,6 +1895,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const skills = createMemo(() => props.message.skills ?? [])
|
||||
const images = createMemo(() =>
|
||||
files().flatMap((file) =>
|
||||
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
|
||||
),
|
||||
)
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -1914,6 +1921,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
@@ -2206,7 +2214,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
@@ -2230,7 +2238,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Switch>
|
||||
<Match when={display() === "shell"}>
|
||||
<Shell {...toolprops} />
|
||||
@@ -2276,6 +2284,87 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
|
||||
return <SessionImages images={images()} />
|
||||
}
|
||||
|
||||
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
|
||||
const ctx = use()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
|
||||
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const visible = createMemo(() => images().slice(0, 3))
|
||||
|
||||
return (
|
||||
<Show when={visible().length > 0}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingLeft={props.paddingLeft ?? 3}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={height() * 2}
|
||||
height={height()}
|
||||
flexBasis={height() * 2}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
|
||||
}}
|
||||
>
|
||||
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||
<image
|
||||
source={image.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={images().length > visible().length}>
|
||||
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
|
||||
<text wrapMode="none" truncate>
|
||||
+{images().length - visible().length} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function inlineToolImages(part: SessionMessageAssistantTool) {
|
||||
return toolDisplayContent(part.state).flatMap((content) =>
|
||||
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||
? [{ uri: content.uri }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
|
||||
@@ -204,7 +204,7 @@ export function DialogProvider(props: ParentProps) {
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text || !clipboard.write) return false
|
||||
if (!text) return false
|
||||
void clipboard.write(text).then(
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
|
||||
@@ -35,7 +35,7 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
||||
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
|
||||
|
||||
clipboard
|
||||
?.write?.(clipboardText)
|
||||
.write(clipboardText)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
|
||||
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
|
||||
app.mockInput.pressArrow("down")
|
||||
for (const key of "sounds") app.mockInput.pressKey(key)
|
||||
for (const key of "image preview") app.mockInput.pressKey(key)
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
|
||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
|
||||
app.mockInput.pressEnter()
|
||||
await app.waitFor(() => current.attention?.sound === false)
|
||||
await app.waitFor(() => current.prompt?.image_preview === true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ async function mountForm(root: string, width = 80) {
|
||||
>
|
||||
<ClipboardProvider
|
||||
value={{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
write(text) {
|
||||
copied.push(text)
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -1,19 +1,129 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { copyCommand } from "../src/clipboard"
|
||||
import {
|
||||
createClipboard,
|
||||
type ClipboardReadOptions,
|
||||
type ClipboardReadResult,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type ClipboardWriteOptions,
|
||||
type ClipboardWriteResult,
|
||||
type HostClipboardService,
|
||||
} from "@opentui/core"
|
||||
import { createClipboardAdapter } from "../src/clipboard"
|
||||
|
||||
test("prefers Wayland clipboard when available", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"])
|
||||
function coreClipboard(options: {
|
||||
read?: ClipboardReadResult
|
||||
write?: ClipboardWriteResult
|
||||
onRead?: (input: ClipboardReadOptions) => void
|
||||
onWrite?: (text: string, input: ClipboardWriteOptions) => void
|
||||
}): CoreClipboardService {
|
||||
return {
|
||||
async read(input) {
|
||||
options.onRead?.(input)
|
||||
return options.read ?? { status: "empty" }
|
||||
},
|
||||
async writeText(text, input) {
|
||||
options.onWrite?.(text, input)
|
||||
return (
|
||||
options.write ?? {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
}
|
||||
)
|
||||
},
|
||||
async clear() {
|
||||
return {
|
||||
host: { status: "cleared" },
|
||||
terminal: { status: "not-attempted", capability: "unknown" },
|
||||
}
|
||||
},
|
||||
async dispose() {},
|
||||
}
|
||||
}
|
||||
|
||||
test("adapts OpenTUI image and text reads", async () => {
|
||||
const requests: ClipboardReadOptions[] = []
|
||||
const image = createClipboardAdapter(
|
||||
coreClipboard({
|
||||
read: {
|
||||
status: "read",
|
||||
representation: { mimeType: "image/png", bytes: new Uint8Array([0, 1, 2, 255]) },
|
||||
},
|
||||
onRead: (input) => requests.push(input),
|
||||
}),
|
||||
)
|
||||
const text = "line 1\r\n\t世界"
|
||||
const plain = createClipboardAdapter(
|
||||
coreClipboard({
|
||||
read: {
|
||||
status: "read",
|
||||
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode(text) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await image.read()).toEqual({ data: "AAEC/w==", mime: "image/png" })
|
||||
expect(await plain.read()).toEqual({ data: text, mime: "text/plain" })
|
||||
expect(requests).toEqual([{ preferredTypes: ["image/png", "text/plain"], selection: "clipboard" }])
|
||||
})
|
||||
|
||||
test("uses osascript on macOS", () => {
|
||||
expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"])
|
||||
test("uses all available routes but skips the process host remotely", async () => {
|
||||
const writes = { host: 0, terminal: 0 }
|
||||
const host: HostClipboardService = {
|
||||
maxWriteBytes: 8 * 1024 * 1024,
|
||||
async read() {
|
||||
return { status: "empty" }
|
||||
},
|
||||
async writeText() {
|
||||
writes.host++
|
||||
return { status: "written" }
|
||||
},
|
||||
async clear() {
|
||||
return { status: "cleared" }
|
||||
},
|
||||
async dispose() {},
|
||||
}
|
||||
const clipboard = createClipboardAdapter(
|
||||
createClipboard({
|
||||
host,
|
||||
terminal: {
|
||||
remote: true,
|
||||
writeText() {
|
||||
writes.terminal++
|
||||
return { status: "attempted", capability: "supported" }
|
||||
},
|
||||
clear() {
|
||||
return { status: "attempted", capability: "supported" }
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await clipboard.write("hello")).toBeUndefined()
|
||||
expect(writes).toEqual({ host: 0, terminal: 1 })
|
||||
})
|
||||
|
||||
test("falls back through X11 clipboard commands", () => {
|
||||
expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"])
|
||||
expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"])
|
||||
})
|
||||
test("rejects only when no clipboard route accepted the write", async () => {
|
||||
const writes: [string, ClipboardWriteOptions][] = []
|
||||
const failure = new Error("native clipboard failed")
|
||||
const fallback = createClipboardAdapter(
|
||||
coreClipboard({
|
||||
write: {
|
||||
host: { status: "written" },
|
||||
terminal: { status: "local-failure", capability: "supported" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const clipboard = createClipboardAdapter(
|
||||
coreClipboard({
|
||||
write: {
|
||||
host: { status: "failed", error: failure },
|
||||
terminal: { status: "local-failure", capability: "supported" },
|
||||
},
|
||||
onWrite: (text, input) => writes.push([text, input]),
|
||||
}),
|
||||
)
|
||||
|
||||
test("returns undefined when native clipboard is unavailable", () => {
|
||||
expect(copyCommand("linux", false, () => false)).toBeUndefined()
|
||||
expect(await fallback.write("hello")).toBeUndefined()
|
||||
expect(await clipboard.write("hello").then(undefined, (error) => error)).toBe(failure)
|
||||
expect(writes).toEqual([["hello", { destination: "all-available", selection: "clipboard" }]])
|
||||
})
|
||||
|
||||
@@ -23,6 +23,8 @@ test("validates the session tabs setting", () => {
|
||||
})
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
} from "../../src/context/runtime"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { LogProvider, type LogSink } from "../../src/context/log"
|
||||
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
|
||||
|
||||
const clipboard: ClipboardService = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {},
|
||||
}
|
||||
|
||||
export function TestTuiContexts(
|
||||
props: ParentProps<{
|
||||
@@ -14,6 +22,7 @@ export function TestTuiContexts(
|
||||
directory?: string
|
||||
paths?: Partial<TuiPaths>
|
||||
log?: LogSink
|
||||
clipboard?: ClipboardService
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
@@ -28,7 +37,9 @@ export function TestTuiContexts(
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>{props.children}</TuiStartupProvider>
|
||||
<TuiStartupProvider value={{ skipInitialLoading: false }}>
|
||||
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
||||
|
||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||
@@ -11,6 +11,44 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
|
||||
}
|
||||
|
||||
describe("prompt local attachments", () => {
|
||||
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
|
||||
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
|
||||
"/tmp/one image.png",
|
||||
"/tmp/two image.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||
"/tmp/one image.png",
|
||||
"/tmp/two.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||
"/tmp/one.png",
|
||||
"/tmp/two.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
|
||||
"/tmp/one\\image.png",
|
||||
"/tmp/two.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
|
||||
"C:\\one image.png",
|
||||
"C:\\two.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths("file:///C:/one%20image.png\r\nfile://server/share/two.webp", "win32")).toEqual([
|
||||
"C:\\one image.png",
|
||||
"\\\\server\\share\\two.webp",
|
||||
])
|
||||
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
|
||||
"/tmp/O'Brien.png",
|
||||
"/tmp/two.webp",
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects unbounded and malformed multi-file drops", () => {
|
||||
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
|
||||
expect(
|
||||
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reads SVG attachments as text", async () => {
|
||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||
type: "text",
|
||||
@@ -39,5 +77,8 @@ describe("prompt local attachments", () => {
|
||||
"/tmp/missing.png",
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user