mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 03:37:02 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6356f5a6 | |||
| 10252bab83 | |||
| 7d55ba4581 | |||
| 32d5058f8c | |||
| 363d6d1a26 | |||
| 2bf85b8479 | |||
| 1813256d8e | |||
| d7ba8b1125 | |||
| 44350bced9 | |||
| 913c4ef0b1 | |||
| 10095bfad4 | |||
| 6a3b2f339a | |||
| d85f8cd4d8 | |||
| 68676f2c5c | |||
| 2d2f587bfa | |||
| bba76009a8 | |||
| 50b4ad89b3 | |||
| fd2278eefc | |||
| 7ccb7889af |
@@ -468,7 +468,7 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.54",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -477,7 +477,7 @@
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
@@ -498,7 +498,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
@@ -611,9 +611,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.0",
|
||||
"@opentui/keymap": ">=0.3.0",
|
||||
"@opentui/solid": ">=0.3.0",
|
||||
"@opentui/core": ">=0.3.1",
|
||||
"@opentui/keymap": ">=0.3.1",
|
||||
"@opentui/solid": ">=0.3.1",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -862,9 +862,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.3.0",
|
||||
"@opentui/keymap": "0.3.0",
|
||||
"@opentui/solid": "0.3.0",
|
||||
"@opentui/core": "0.3.1",
|
||||
"@opentui/keymap": "0.3.1",
|
||||
"@opentui/solid": "0.3.1",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1758,23 +1758,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
|
||||
"@opentui/core": ["@opentui/core@0.3.1", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.1", "@opentui/core-darwin-x64": "0.3.1", "@opentui/core-linux-arm64": "0.3.1", "@opentui/core-linux-x64": "0.3.1", "@opentui/core-win32-arm64": "0.3.1", "@opentui/core-win32-x64": "0.3.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-kQFSsSCgtlasSqTigCgKmM67xaquGvTg+vwimDnFSZtcBEt4E3dz7qLrbeh5FVvTA+RMbwe+Bozq03PW+SgjXw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krvVfiBpeBY+727R8yogdqIcxkK3RUVcI97bqjl8jTeDMcWOkFFfHezssRMPmbR5x++1tX669Fz3fuxoe7XUIg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-D/6ec5H8SPpSBMr01/sqgSddIl1Qc1QMKsDl/wV5MpbxYc7Qvie9qlNvvoSsWNfAXAbafLRb1jQBzouk41cp1w=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/FFBoAsWJyS/EO/cF7h7DuEENYa9nAdSv1W/TIyKXpBisN6K3U1Xgbk528TkfWjrwJjhGs+9OMYdXuAHd5LTw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Btb7Q4BOC55Aj2qCs0VoxGuj87DNfUEaSx0z89oeU4npTN+6SpJApyGZTCNNeSe2sdmOGeh/8eAR4X96ORjcKg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+lt24u3KwEPG69oXDOLz9N484wPcAHvrPbDNU77OT6DvWew+StAjh40eY+Zeu0TkTNDWfj7qnQKV0GKWtFA3cw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-eVkKMYirYgpn92lI0YT/GKru4J+UiXjzwyzNRFX+P59OHXvL3GFdqJMcJmX4/zvyjg4c8HDnU79YLnyG+TlXLw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.1", "", { "dependencies": { "@opentui/core": "0.3.1" }, "peerDependencies": { "@opentui/react": "0.3.1", "@opentui/solid": "0.3.1", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BTj+ggsarO2uyvd6CWzvgfsekA8c4aEclbAPKPZGVjBI3Fo5+KAHUrXvteFO5qpGMANfEJTtVHoRu5cic1Nlaw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "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-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.3.1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.1", "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-2R6wEijfMub9COTBCm8IKVj2y7+Sc4fZZjJawxk8sE6+++mzeUaokKNJTlYhZXpMju4LKMv6j9CjWkG8JYfbcg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -6032,13 +6032,9 @@
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.54", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m+oLYlZUA0j5aeE8BrjDd2DSCx6HujqPB2C0YjQF7iGQ4sTCCmhHfH2o0uY7vlSPIJpA2ak6YyF7R14a5dq8fw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="],
|
||||
|
||||
"opencode/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.9.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ=="],
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
|
||||
@@ -6844,14 +6840,6 @@
|
||||
|
||||
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
@@ -7194,14 +7182,6 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-3OI8L9IT1XFN44CO3YEgHy/zKqVNhzkJjW1P2+9X4lU=",
|
||||
"aarch64-linux": "sha256-YZSeZIUBQCII3+dLwKhvKv4RKHwAkLSnvGXXEHbmjeQ=",
|
||||
"aarch64-darwin": "sha256-8CsowndXBpFDTd2RNSMh7xaULwLoz3Lu6kfqkacOk2s=",
|
||||
"x86_64-darwin": "sha256-pWyjVHZVBjcGvVU9AoSR4nhuMNKIHKJK5faihD8UzhQ="
|
||||
"x86_64-linux": "sha256-51jxaHLvv2Staz9NN9N4EYoNmr2fZeDvfKZ5enf/Wx0=",
|
||||
"aarch64-linux": "sha256-oGMMlgSJx7Yw5qN6LOquCD/K8GPLy4kDo34AOJGmcso=",
|
||||
"aarch64-darwin": "sha256-j7IvnyY8Cj4a509D85i+FgfsyQiBstxagvVWMp6y5hI=",
|
||||
"x86_64-darwin": "sha256-Dd36AN6LopLNV79d7i9lGz5kKOuv6mk1YyBlmdcIhZY="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -38,9 +38,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.0",
|
||||
"@opentui/keymap": "0.3.0",
|
||||
"@opentui/solid": "0.3.0",
|
||||
"@opentui/core": "0.3.1",
|
||||
"@opentui/keymap": "0.3.1",
|
||||
"@opentui/solid": "0.3.1",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
@@ -25,8 +25,8 @@ import { messageAgentColor } from "@/utils/agent"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
|
||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
|
||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class="w-full max-w-[720px]">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -86,6 +87,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -407,7 +409,13 @@ export const SettingsGeneral: Component = () => {
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (!checked) return
|
||||
void import("@/components/settings-v2").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
|
||||
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
||||
const IconButtonV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
|
||||
)
|
||||
const TextInputV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
|
||||
)
|
||||
const SettingsListV2 = lazy(() =>
|
||||
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
|
||||
)
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
@@ -257,7 +269,7 @@ function useKeyCapture(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component = () => {
|
||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -371,85 +383,178 @@ export const SettingsKeybinds: Component = () => {
|
||||
if (store.active) command.keybinds(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
const emptyResults = (
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
|
||||
"settings-v2-shortcuts-status": props.v2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-weak": !props.v2,
|
||||
}}
|
||||
>
|
||||
{language.t("settings.shortcuts.search.empty")}
|
||||
</span>
|
||||
<Show when={store.filter}>
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-strong mt-1": !props.v2,
|
||||
"settings-v2-shortcuts-status-filter": props.v2,
|
||||
}}
|
||||
>
|
||||
"{store.filter}"
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||
|
||||
const groups = (
|
||||
<div
|
||||
classList={{
|
||||
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
|
||||
"flex flex-col gap-8 max-w-[720px]": !props.v2,
|
||||
}}
|
||||
>
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div
|
||||
classList={{
|
||||
"settings-v2-section": props.v2,
|
||||
"flex flex-col gap-1": !props.v2,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
classList={{
|
||||
"settings-v2-section-title": props.v2,
|
||||
"text-14-medium text-text-strong pb-2": !props.v2,
|
||||
}}
|
||||
>
|
||||
{language.t(groupKey[group])}
|
||||
</h3>
|
||||
<List>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-strong": !props.v2,
|
||||
}}
|
||||
>
|
||||
{title(id)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": props.v2,
|
||||
"settings-v2-keybind-button--active": props.v2 && store.active === id,
|
||||
"h-8 px-3 rounded-md text-12-regular": !props.v2,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
!props.v2 && store.active !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak":
|
||||
!props.v2 && store.active === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show
|
||||
when={store.active === id}
|
||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
{emptyResults}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.v2}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
|
||||
<SettingsList>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
store.active !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show
|
||||
when={store.active === id}
|
||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
|
||||
<Show when={store.filter}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">{groups}</div>
|
||||
</>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
defaultValue="general"
|
||||
class="settings-v2"
|
||||
data-component="settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SelectV2 } from "@opencode-ai/ui/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneralV2: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value) return false
|
||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||
return permission.isAutoAccepting(params.id, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value) return
|
||||
|
||||
if (!params.id) {
|
||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||
permission.toggleAutoAcceptDirectory(value)
|
||||
return
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(params.id, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const globalSync = useServerSync()
|
||||
const globalSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
globalSdk.client.pty
|
||||
.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||
{ initialValue: null as DisplayBackend | null },
|
||||
)
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
{ initialValue: false },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = globalSync.data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onDisplayBackendChange = (checked: boolean) => {
|
||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||
if (!update) return
|
||||
void update.finally(() => {
|
||||
void refetchDisplayBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const onPinchZoomChange = (checked: boolean) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
if (!update) return
|
||||
void update.catch(() => setPinchZoom(!checked))
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
label: language.label(locale),
|
||||
})),
|
||||
)
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && language.setLocale(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
globalSync.updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSessionProgressBar.title")}
|
||||
description={language.t("settings.general.row.showSessionProgressBar.description")}
|
||||
>
|
||||
<div data-action="settings-show-session-progress-bar">
|
||||
<Switch
|
||||
checked={settings.general.showSessionProgressBar()}
|
||||
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showNavigation.title")}
|
||||
description={language.t("settings.general.row.showNavigation.description")}
|
||||
>
|
||||
<div data-action="settings-show-navigation">
|
||||
<Switch
|
||||
checked={settings.general.showNavigation()}
|
||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
>
|
||||
<div data-action="settings-show-custom-agents">
|
||||
<Switch
|
||||
checked={settings.general.showCustomAgents()}
|
||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewColorScheme(option.value)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewTheme(option.id)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-ui-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={sans()}
|
||||
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.uiFont.title")}
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-code-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={mono()}
|
||||
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.font.title")}
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.terminalFont.title")}
|
||||
description={language.t("settings.general.row.terminalFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-terminal-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={terminal()}
|
||||
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
||||
placeholder={terminalDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.terminalFont.title")}
|
||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DisplaySection = () => (
|
||||
<Show when={desktop()}>
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.pinchZoom.title")}
|
||||
description={language.t("settings.general.row.pinchZoom.description")}
|
||||
>
|
||||
<div data-action="settings-pinch-zoom">
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={linux()}>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection />
|
||||
|
||||
<UpdatesSection />
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DialogSettings } from "./dialog-settings-v2"
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsModelsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
|
||||
const aName = a.items[0].provider.name
|
||||
const bName = b.items[0].provider.name
|
||||
return aName.localeCompare(bName)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-models">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header">
|
||||
<ProviderIcon
|
||||
id={group.category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-models-provider-icon shrink-0"
|
||||
/>
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-v2-list">{props.children}</div>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export interface SettingsRowV2Props {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-v2-row">
|
||||
<div data-slot="settings-v2-row-copy">
|
||||
<div data-slot="settings-v2-row-title">{props.title}</div>
|
||||
<div data-slot="settings-v2-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-v2-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider } from "../dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "../dialog-select-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||
] as const
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useServerSDK()
|
||||
const globalSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||
const items = providers
|
||||
.popular()
|
||||
.filter((p) => !connectedIDs.has(p.id))
|
||||
.slice()
|
||||
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
||||
return items
|
||||
})
|
||||
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
return
|
||||
}
|
||||
|
||||
const type = (item: ProviderItem) => {
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = globalSync.data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = globalSync.data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
globalSync.set("config", "disabled_providers", next)
|
||||
|
||||
await globalSync
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
globalSync.set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await globalSDK.client.auth
|
||||
.remove({ providerID })
|
||||
.then(async () => {
|
||||
await globalSDK.client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-providers">
|
||||
<div class="settings-v2-section" data-component="connected-providers-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsListV2>
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-provider-empty">{language.t("settings.providers.connected.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={connected()}>
|
||||
{(item) => (
|
||||
<div class="settings-v2-provider-row group">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name truncate">{item.name}</span>
|
||||
<Tag>{type(item)}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-v2-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
{language.t("common.disconnect")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.popular")}</h3>
|
||||
<SettingsListV2>
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="settings-v2-provider-row">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{item.name}</span>
|
||||
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note(item.id)}>
|
||||
{(key) => <p class="settings-v2-provider-description">{language.t(key())}</p>}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogConnectProvider provider={item.id} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider back="close" />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</SettingsListV2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-providers-view-all"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
@import "@opencode-ai/ui/v2/text-input-v2.css";
|
||||
@import "@opencode-ai/ui/v2/button-v2.css";
|
||||
|
||||
[data-component="settings-v2"] {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.settings-v2-panel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 40px 40px 32px;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-v2-tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] a.settings-v2-link {
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] a.settings-v2-link:hover {
|
||||
color: var(--v2-text-text-accent-hover);
|
||||
}
|
||||
|
||||
.settings-v2-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-section-title {
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-list"] {
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
padding-inline: 20px;
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"]:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
[data-component="settings-v2-row"] {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-copy"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-title"] {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]),
|
||||
[data-slot="settings-v2-row-control"] > [data-component="switch"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
[data-slot="settings-v2-row-control"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"] [data-component="select"][data-trigger-style="settings-v2"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"]
|
||||
[data-component="select"][data-trigger-style="settings-v2"]
|
||||
[data-component="button-v2"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer > span {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel [data-component="dialog"] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.settings-v2-provider-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.settings-v2-provider-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-v2-providers [data-component="provider-icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-lead {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-lead:not(:has(.settings-v2-provider-copy)) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-v2-provider-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-provider-empty {
|
||||
padding-block: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-provider-env-hint {
|
||||
padding-right: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.group:hover .settings-v2-provider-env-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-v2-providers-view-all {
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-v2-providers-view-all:hover {
|
||||
color: var(--v2-text-text-accent-hover);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-providers {
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header:has(+ .settings-v2-tab-body.settings-v2-providers) {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-providers .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-v2-providers .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-tab-header--stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header--stacked > .settings-v2-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-models {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-models-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-models .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-component="provider-icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-description"]:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-copy"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-title"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-models-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-models-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts .settings-v2-section {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div > span {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 8px;
|
||||
margin: -6px -8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings:
|
||||
"tnum" on,
|
||||
"lnum" on;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button:hover {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button--active {
|
||||
color: var(--v2-text-text-faint);
|
||||
border-radius: 2px;
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -20,8 +20,9 @@ import { useServerSync } from "@/context/server-sync"
|
||||
import { decodeDirectory } from "@/pages/directory-layout"
|
||||
import { iife } from "@opencode-ai/core/util/iife"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
@@ -370,22 +371,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return true
|
||||
}
|
||||
|
||||
makeEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "w") return
|
||||
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
|
||||
const openNewTab = () => navigate(newSessionHref())
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
|
||||
|
||||
command.register(() => {
|
||||
const commands = [
|
||||
{
|
||||
id: "tab.new",
|
||||
category: "tab",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+t",
|
||||
hidden: true,
|
||||
onSelect: openNewTab,
|
||||
},
|
||||
{
|
||||
id: "tab.close",
|
||||
category: "tab",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
hidden: true,
|
||||
onSelect: closeActiveTab,
|
||||
},
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
@@ -489,6 +496,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
title={tab.info.title}
|
||||
project={projectForSession(tab.info, projects(), projectByID())}
|
||||
directory={tab.dir}
|
||||
sessionId={tab.info.id}
|
||||
onClose={() => tabsStoreActions.removeTab(tab.href)}
|
||||
/>
|
||||
</>
|
||||
@@ -736,26 +744,39 @@ function TabNavItem(props: {
|
||||
title: string
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
hideClose?: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const match = useMatch(() => props.href)
|
||||
const isActive = () => !!match()
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
|
||||
data-active={isActive()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
|
||||
>
|
||||
<ProjectTabAvatar project={props.project} directory={props.directory} />
|
||||
<span class="text-clip leading-5">{props.title}</span>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<ProjectTabAvatar project={props.project} directory={props.directory} sessionId={props.sessionId} />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{props.title}</span>
|
||||
</a>
|
||||
|
||||
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
|
||||
<div class="absolute right-0 inset-y-0 flex flex-row items-center overflow-hidden rounded-r-[6px] pr-1 py-1 w-8 pl-2">
|
||||
<div
|
||||
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
class="absolute inset-0 rounded-r-[6px] bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
|
||||
style={{
|
||||
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
|
||||
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
|
||||
@@ -765,7 +786,7 @@ function TabNavItem(props: {
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
|
||||
onClick={props.onClose}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -773,22 +794,35 @@ function TabNavItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
|
||||
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId)
|
||||
return (
|
||||
<AvatarV2
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
kind="org"
|
||||
size="small"
|
||||
{...getAvatarColors(props.project?.icon?.color)}
|
||||
class="size-4 rounded"
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}
|
||||
return (
|
||||
<div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
|
||||
<div
|
||||
class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={props.href}
|
||||
aria-current="page"
|
||||
@@ -807,11 +841,7 @@ function NewSessionTabItem(props: { href: string; title: string; onClose: () =>
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Show, type JSX } from "solid-js"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Session,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
|
||||
@@ -12,6 +12,9 @@ import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
|
||||
export type { ProjectAvatarVariant }
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_SIDEBAR_WIDTH = 344
|
||||
@@ -33,6 +36,16 @@ export function getAvatarColors(key?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||
if (key === "orange") return "orange"
|
||||
if (key === "pink") return "pink"
|
||||
if (key === "cyan") return "cyan"
|
||||
if (key === "purple") return "purple"
|
||||
if (key === "mint") return "cyan"
|
||||
if (key === "lime") return "green"
|
||||
return "gray"
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -531,6 +531,8 @@ export const dict = {
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.group.today": "Today",
|
||||
|
||||
@@ -502,6 +502,8 @@ export const dict = {
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.search.sessions": "会话",
|
||||
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
|
||||
+530
-285
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { toaster } from "@opencode-ai/ui/toast"
|
||||
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
@@ -128,6 +129,7 @@ export default function Layout(props: ParentProps) {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
createEffect(() => setV2Toast(newDesign()))
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -1227,7 +1229,10 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function openSettings() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
const module = settings.general.newLayoutDesigns()
|
||||
? import("@/components/settings-v2")
|
||||
: import("@/components/dialog-settings")
|
||||
void module.then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
@@ -2380,7 +2385,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Show>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
<Toast.Region />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -2533,7 +2538,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
<Toast.Region />
|
||||
<ToastRegion v2={newDesign()} />
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
|
||||
export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>) {
|
||||
const globalSync = useServerSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const hasPermissions = createMemo(() => {
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0)
|
||||
const loading = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
const [store] = globalSync.child(directory(), { bootstrap: false })
|
||||
return store.session_working(sessionId())
|
||||
})
|
||||
return { unread, loading }
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { SessionComposerState } from "@/pages/session/composer/session-comp
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
@@ -150,8 +151,9 @@ export function SessionComposerRegion(props: {
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"max-w-[720px] px-0": props.placement === "inline",
|
||||
"w-full pointer-events-auto": true,
|
||||
"px-3": props.placement !== "inline",
|
||||
[NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -48,7 +48,7 @@ import type {
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
|
||||
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
|
||||
|
||||
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
|
||||
return input.newLayoutDesigns && !input.sessionID
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
|
||||
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
|
||||
|
||||
let v2 = false
|
||||
|
||||
export function setV2Toast(value: boolean) {
|
||||
v2 = value
|
||||
}
|
||||
|
||||
export function ToastRegion(props: { v2: boolean }) {
|
||||
if (props.v2) return <ToastV2.Region />
|
||||
return <Toast.Region />
|
||||
}
|
||||
|
||||
export function showToast(options: ToastOptions | string) {
|
||||
if (!v2) return showLegacyToast(options)
|
||||
if (typeof options === "string") return showToastV2(options)
|
||||
|
||||
return showToastV2({
|
||||
...options,
|
||||
icon: resolveIcon(options.icon, options.variant),
|
||||
actions: options.actions?.map((action) => ({
|
||||
...action,
|
||||
variant: action.onClick === "dismiss" ? "secondary" : "primary",
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
|
||||
const name = icon ?? (variant === "success" ? "check" : undefined)
|
||||
if (!name) return
|
||||
return <Icon name={name} />
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.54",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@ai-sdk/openai-compatible": "2.0.48",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
@@ -105,7 +105,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
|
||||
@@ -330,25 +330,34 @@ export function make(input: {
|
||||
}
|
||||
})
|
||||
|
||||
const abortBackingSession = Effect.fn("ACP.abortBackingSession")(function* (current: ACPSession.Info) {
|
||||
yield* request(
|
||||
() => input.sdk.session.abort({ directory: current.cwd, sessionID: current.id }, { throwOnError: true }),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to abort ACP backing session", { error, sessionID: current.id })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
sessionSnapshots.delete(params.sessionId)
|
||||
if (!removed) return {}
|
||||
|
||||
yield* request(
|
||||
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* abortBackingSession(removed)
|
||||
return {}
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("ACP.cancel")(function* (params: CancelNotification) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
yield* abortBackingSession(current)
|
||||
})
|
||||
|
||||
const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const forked = yield* request(
|
||||
@@ -563,9 +572,7 @@ export function make(input: {
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
}),
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
function promptOffsetWidth(value: string) {
|
||||
export function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
|
||||
@@ -221,7 +221,7 @@ export const RunCommand = effectCmd({
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "replay visible session history on interactive resume",
|
||||
describe: "replay interactive session history on resume and after resize",
|
||||
})
|
||||
.option("replay-limit", {
|
||||
type: "number",
|
||||
|
||||
@@ -5,7 +5,7 @@ import fuzzysort from "fuzzysort"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||
import type { FooterQueuedPrompt, FooterSubagentTab, RunCommand, RunInput, RunProvider } from "./types"
|
||||
|
||||
type PanelEntry = RunFooterMenuItem & {
|
||||
category: string
|
||||
@@ -14,6 +14,7 @@ type PanelEntry = RunFooterMenuItem & {
|
||||
|
||||
type CommandEntry =
|
||||
| (PanelEntry & { action: "model" })
|
||||
| (PanelEntry & { action: "queued" })
|
||||
| (PanelEntry & { action: "subagent" })
|
||||
| (PanelEntry & { action: "variant.cycle" })
|
||||
| (PanelEntry & { action: "variant.list" })
|
||||
@@ -37,6 +38,10 @@ type SubagentEntry = PanelEntry & {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type QueuedEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
||||
|
||||
const PANEL_PAD = 2
|
||||
@@ -294,11 +299,13 @@ export function RunCommandMenuBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
subagents: Accessor<FooterSubagentTab[]>
|
||||
queued: Accessor<FooterQueuedPrompt[]>
|
||||
variants: Accessor<string[]>
|
||||
variantCycle: string
|
||||
onClose: () => void
|
||||
onModel: () => void
|
||||
onSubagent: () => void
|
||||
onQueued: () => void
|
||||
onVariant: () => void
|
||||
onVariantCycle: () => void
|
||||
onCommand: (name: string) => void
|
||||
@@ -315,6 +322,20 @@ export function RunCommandMenuBody(props: {
|
||||
category: "Suggested",
|
||||
display: "Switch model",
|
||||
},
|
||||
...(props.queued().length > 0
|
||||
? [
|
||||
{
|
||||
action: "queued" as const,
|
||||
category: "Suggested",
|
||||
display: "Manage queued prompts",
|
||||
footer: `${props.queued().length} queued`,
|
||||
keywords: props
|
||||
.queued()
|
||||
.map((item) => item.prompt.text)
|
||||
.join(" "),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.subagents().length > 0
|
||||
? [
|
||||
{
|
||||
@@ -387,6 +408,11 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "queued") {
|
||||
props.onQueued()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "variant.cycle") {
|
||||
props.onVariantCycle()
|
||||
return
|
||||
@@ -559,6 +585,102 @@ export function RunSubagentSelectBody(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function RunQueuedPromptSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onEdit: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: "queued · ctrl+e edit · ctrl+d remove",
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = selected()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
|
||||
event.preventDefault()
|
||||
props.onDelete(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
if (item && ctrl && event.name === "e") {
|
||||
event.preventDefault()
|
||||
props.onEdit(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({
|
||||
event,
|
||||
menu,
|
||||
field: () => field,
|
||||
setQuery,
|
||||
select: () => {
|
||||
const item = selected()
|
||||
if (item) props.onEdit(item.prompt)
|
||||
},
|
||||
close: props.onClose,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
id="run-direct-footer-queued-panel"
|
||||
title="Queued prompts"
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
>
|
||||
<RunFooterMenu
|
||||
id="run-direct-footer-queued-list"
|
||||
theme={props.theme}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={false}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunVariantSelectBody(props: {
|
||||
theme: Accessor<RunFooterTheme>
|
||||
variants: Accessor<string[]>
|
||||
|
||||
@@ -63,7 +63,6 @@ type PromptInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
subagents: Accessor<number>
|
||||
resources: Accessor<RunResource[]>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
tuiConfig: RunTuiConfig
|
||||
@@ -79,7 +78,6 @@ type PromptInput = {
|
||||
onInputClear: () => void
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onSubagentMenu?: () => void
|
||||
onRows: (rows: number) => void
|
||||
onStatus: (text: string) => void
|
||||
}
|
||||
@@ -98,6 +96,7 @@ export type PromptState = {
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
replaceDraft: (text: string) => void
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
@@ -791,12 +790,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
if (next.kind === "slash") {
|
||||
const text = `/${next.name} `
|
||||
const cursor = area.cursorOffset
|
||||
const head = slashHead(area.plainText)
|
||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
|
||||
const text = `/${next.name}${separator}`
|
||||
|
||||
area.cursorOffset = 0
|
||||
const start = area.logicalCursor
|
||||
area.cursorOffset = cursor
|
||||
area.cursorOffset =
|
||||
shell() || !head
|
||||
? cursor
|
||||
: local
|
||||
? Bun.stringWidth(area.plainText)
|
||||
: Bun.stringWidth(area.plainText.slice(0, head.end))
|
||||
const end = area.logicalCursor
|
||||
|
||||
area.deleteRange(start.row, start.col, end.row, end.col)
|
||||
@@ -804,6 +811,11 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
area.cursorOffset = Bun.stringWidth(text)
|
||||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
submitPrompt(clonePrompt(draft))
|
||||
return
|
||||
}
|
||||
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
return
|
||||
@@ -888,6 +900,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
if (current === "command") return false
|
||||
if (current === "model") return false
|
||||
if (current === "variant") return false
|
||||
if (current === "queued-menu") return false
|
||||
if (current === "subagent-menu") return false
|
||||
return true
|
||||
}
|
||||
@@ -957,17 +970,6 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: input.prompt() && !visible(),
|
||||
bindings: [
|
||||
{
|
||||
key: "down",
|
||||
desc: "View subagents",
|
||||
group: "Prompt",
|
||||
cmd() {
|
||||
if (!area || area.isDestroyed) return false
|
||||
if (area.plainText.length !== 0) return false
|
||||
if (input.subagents() === 0) return false
|
||||
input.onSubagentMenu?.()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "!",
|
||||
desc: "Shell mode",
|
||||
@@ -1199,6 +1201,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
scheduleRows()
|
||||
},
|
||||
replaceDraft,
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
FooterEvent,
|
||||
FooterPatch,
|
||||
FooterPromptRoute,
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
@@ -164,11 +165,13 @@ export class RunFooter implements FooterApi {
|
||||
private closed = false
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
|
||||
private closes = new Set<() => void>()
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
private pending = false
|
||||
private flushing: Promise<void> = Promise.resolve()
|
||||
private flushError: unknown
|
||||
// Fixed portion of footer height above the textarea.
|
||||
private base: number
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
@@ -192,6 +195,8 @@ export class RunFooter implements FooterApi {
|
||||
private setView: Setter<FooterView>
|
||||
private subagent: Accessor<FooterSubagentState>
|
||||
private setSubagent: (next: FooterSubagentState) => void
|
||||
private queuedPrompts: Accessor<FooterQueuedPrompt[]>
|
||||
private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
|
||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||
private subagentMenuRows = SUBAGENT_ROWS
|
||||
private autocomplete = false
|
||||
@@ -200,6 +205,15 @@ export class RunFooter implements FooterApi {
|
||||
private requestExitHandler: (() => boolean) | undefined
|
||||
private scrollback: RunScrollbackStream
|
||||
|
||||
private createScrollback(wrote: boolean): RunScrollbackStream {
|
||||
return new RunScrollbackStream(this.renderer, this.options.theme, {
|
||||
diffStyle: this.options.diffStyle,
|
||||
wrote,
|
||||
sessionID: this.options.sessionID,
|
||||
treeSitterClient: this.options.treeSitterClient,
|
||||
})
|
||||
}
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private options: RunFooterOptions,
|
||||
@@ -249,13 +263,11 @@ export class RunFooter implements FooterApi {
|
||||
setSubagent("permissions", reconcile(next.permissions, { key: "id" }))
|
||||
setSubagent("questions", reconcile(next.questions, { key: "id" }))
|
||||
}
|
||||
const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
|
||||
this.queuedPrompts = queuedPrompts
|
||||
this.setQueuedPrompts = setQueuedPrompts
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||
diffStyle: options.diffStyle,
|
||||
wrote: options.wrote,
|
||||
sessionID: options.sessionID,
|
||||
treeSitterClient: options.treeSitterClient,
|
||||
})
|
||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
@@ -270,6 +282,7 @@ export class RunFooter implements FooterApi {
|
||||
state: footer.state,
|
||||
view: footer.view,
|
||||
subagent: footer.subagent,
|
||||
queuedPrompts: footer.queuedPrompts,
|
||||
findFiles: options.findFiles,
|
||||
agents: footer.agents,
|
||||
resources: footer.resources,
|
||||
@@ -299,6 +312,7 @@ export class RunFooter implements FooterApi {
|
||||
onLayout: footer.syncLayout,
|
||||
onStatus: footer.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
onQueuedRemove: footer.handleQueuedRemove,
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -325,6 +339,13 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
}
|
||||
|
||||
public onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void {
|
||||
this.queuedRemoves.add(fn)
|
||||
return () => {
|
||||
this.queuedRemoves.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
public onClose(fn: () => void): () => void {
|
||||
if (this.isClosed) {
|
||||
fn()
|
||||
@@ -370,6 +391,15 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "queued.prompts") {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setQueuedPrompts(next.prompts)
|
||||
return
|
||||
}
|
||||
|
||||
const patch = eventPatch(next)
|
||||
if (patch) {
|
||||
this.patch(patch)
|
||||
@@ -440,7 +470,9 @@ export class RunFooter implements FooterApi {
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.catch((error) => {
|
||||
this.flushError = error
|
||||
})
|
||||
}
|
||||
|
||||
private present(view: FooterView): void {
|
||||
@@ -498,6 +530,12 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
return this.flushing.then(async () => {
|
||||
if (this.flushError !== undefined) {
|
||||
const error = this.flushError
|
||||
this.flushError = undefined
|
||||
throw error
|
||||
}
|
||||
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
@@ -510,6 +548,15 @@ export class RunFooter implements FooterApi {
|
||||
})
|
||||
}
|
||||
|
||||
public resetForReplay(wrote: boolean): void {
|
||||
if (this.isGone) {
|
||||
return
|
||||
}
|
||||
|
||||
this.scrollback.destroy()
|
||||
this.scrollback = this.createScrollback(wrote)
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
@@ -546,6 +593,11 @@ export class RunFooter implements FooterApi {
|
||||
this.requestExitHandler = fn
|
||||
}
|
||||
|
||||
private handleQueuedRemove = async (messageID: string): Promise<boolean> => {
|
||||
const fn = [...this.queuedRemoves][0]
|
||||
return fn ? await fn(messageID) : false
|
||||
}
|
||||
|
||||
private handleInputClear = (): void => {
|
||||
this.clearInterruptTimer()
|
||||
this.clearExitTimer()
|
||||
@@ -573,11 +625,13 @@ export class RunFooter implements FooterApi {
|
||||
? 1 + MODEL_ROWS
|
||||
: this.promptRoute.type === "variant"
|
||||
? 1 + VARIANT_ROWS
|
||||
: this.promptRoute.type === "subagent-menu"
|
||||
: this.promptRoute.type === "queued-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
||||
: this.promptRoute.type === "subagent-menu"
|
||||
? 1 + this.subagentMenuRows
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(base + TEXTAREA_MIN_ROWS, Math.min(base + PROMPT_MAX_ROWS, base + this.rows))
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
@@ -872,6 +926,7 @@ export class RunFooter implements FooterApi {
|
||||
this.clearExitTimer()
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.queuedRemoves.clear()
|
||||
this.closes.clear()
|
||||
this.scrollback.destroy()
|
||||
}
|
||||
@@ -903,6 +958,8 @@ export class RunFooter implements FooterApi {
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.catch((error) => {
|
||||
this.flushError = error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
} from "./footer.command"
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
FooterQueuedPrompt,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
@@ -79,6 +81,7 @@ type RunFooterViewProps = {
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
subagent?: () => FooterSubagentState
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
theme?: RunTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
tuiConfig: RunTuiConfig
|
||||
@@ -100,6 +103,7 @@ type RunFooterViewProps = {
|
||||
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
||||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
@@ -119,13 +123,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
|
||||
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
|
||||
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
|
||||
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
|
||||
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
|
||||
const panel = createMemo(() => selectingSubagent() || commanding() || modeling() || varianting())
|
||||
const panel = createMemo(() => selectingQueued() || selectingSubagent() || commanding() || modeling() || varianting())
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? current.sessionID : undefined
|
||||
@@ -151,6 +157,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
label: count === 1 ? "agent" : "agents",
|
||||
}
|
||||
})
|
||||
const queuedIndicator = createMemo(() => {
|
||||
const count = queuedPrompts().length
|
||||
if (count === 0) return
|
||||
return { count, label: count === 1 ? "prompt" : "prompts" }
|
||||
})
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
@@ -180,11 +191,30 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const queuedShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.queued_prompts"] })
|
||||
.get("session.queued_prompts"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const subagentShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeyBindings(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["session.child.first"] })
|
||||
.get("session.child.first"),
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const queue = createMemo(() => props.state().queue)
|
||||
const additionalQueue = createMemo(() => Math.max(0, queue() - queuedPrompts().length))
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
@@ -248,6 +278,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const openQueuedMenu = () => {
|
||||
if (queuedPrompts().length === 0) return
|
||||
setRoute({ type: "queued-menu" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
setRoute({ type: "composer" })
|
||||
}
|
||||
@@ -282,7 +318,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
agents: props.agents,
|
||||
subagents: () => tabs().length,
|
||||
resources: props.resources,
|
||||
commands: props.commands,
|
||||
tuiConfig: props.tuiConfig,
|
||||
@@ -298,7 +333,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onInputClear: props.onInputClear,
|
||||
onExitRequest: props.onExitRequest,
|
||||
onExit: props.onExit,
|
||||
onSubagentMenu: openSubagentMenu,
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
@@ -336,6 +370,34 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && tabs().length > 0,
|
||||
commands: [
|
||||
{
|
||||
name: "session.child.first",
|
||||
title: "View subagents",
|
||||
category: "Session",
|
||||
run: openSubagentMenu,
|
||||
},
|
||||
],
|
||||
bindings: props.tuiConfig.keybinds.get("session.child.first"),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
|
||||
commands: [
|
||||
{
|
||||
name: "session.queued_prompts",
|
||||
title: "Manage queued prompts",
|
||||
category: "Session",
|
||||
run: openQueuedMenu,
|
||||
},
|
||||
],
|
||||
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
if (current.type !== "subagent") {
|
||||
@@ -361,6 +423,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
closePanel()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
|
||||
closePanel()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (active().type === "prompt") {
|
||||
return
|
||||
@@ -371,6 +438,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
current.type !== "command" &&
|
||||
current.type !== "model" &&
|
||||
current.type !== "variant" &&
|
||||
current.type !== "queued-menu" &&
|
||||
current.type !== "subagent-menu"
|
||||
) {
|
||||
return
|
||||
@@ -449,16 +517,32 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={selectingQueued()}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={theme}
|
||||
prompts={queuedPrompts}
|
||||
onClose={closePanel}
|
||||
onDelete={(item) => void props.onQueuedRemove(item.messageID)}
|
||||
onEdit={async (item) => {
|
||||
if (!(await props.onQueuedRemove(item.messageID))) return
|
||||
closePanel()
|
||||
queueMicrotask(() => composer.replacePrompt(item.prompt))
|
||||
}}
|
||||
onRows={setSubagentMenuRows}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={commanding()}>
|
||||
<RunCommandMenuBody
|
||||
theme={theme}
|
||||
commands={props.commands}
|
||||
subagents={tabs}
|
||||
queued={queuedPrompts}
|
||||
variants={props.variants}
|
||||
variantCycle={variantCycle()}
|
||||
onClose={closePanel}
|
||||
onModel={openModel}
|
||||
onSubagent={openSubagentMenu}
|
||||
onQueued={openQueuedMenu}
|
||||
onVariant={openVariant}
|
||||
onVariantCycle={() => {
|
||||
props.onCycle()
|
||||
@@ -615,7 +699,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
|
||||
<Show
|
||||
when={busy() || exiting() || duration().length > 0 || queuedIndicator() || subagentIndicator()}
|
||||
>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0} marginLeft={1}>
|
||||
<Show when={exiting()}>
|
||||
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate>
|
||||
@@ -665,11 +751,24 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
</Show>
|
||||
{info().count} <span style={{ fg: theme().muted }}>{info().label}</span>
|
||||
<span style={{ fg: theme().muted }}> · </span>
|
||||
<span style={{ fg: theme().highlight }}>↓</span>
|
||||
<span style={{ fg: theme().highlight }}>{subagentShortcut() || "leader+down"}</span>
|
||||
<span style={{ fg: theme().muted }}> to view</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={queuedIndicator()}>
|
||||
{(info) => (
|
||||
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>
|
||||
<Show when={busy() || exiting() || duration().length > 0 || subagentIndicator()}>
|
||||
<span style={{ fg: theme().muted }}>· </span>
|
||||
</Show>
|
||||
{info().count} <span style={{ fg: theme().muted }}>queued {info().label}</span>
|
||||
<span style={{ fg: theme().muted }}> · </span>
|
||||
<span style={{ fg: theme().highlight }}>{queuedShortcut() || "leader+q"}</span>
|
||||
<span style={{ fg: theme().muted }}> to edit/remove</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -686,9 +785,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
when={shell()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={queue() > 0}>
|
||||
<Show when={additionalQueue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
{additionalQueue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||
@@ -75,6 +75,8 @@ export type LifecycleInput = {
|
||||
|
||||
export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
onResize(fn: () => void): () => void
|
||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||
}
|
||||
|
||||
@@ -307,6 +309,46 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
|
||||
return {
|
||||
footer,
|
||||
onResize(fn) {
|
||||
let width = renderer.terminalWidth
|
||||
let height = renderer.terminalHeight
|
||||
const resize = () => {
|
||||
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
width = renderer.terminalWidth
|
||||
height = renderer.terminalHeight
|
||||
fn()
|
||||
}
|
||||
renderer.on(CliRenderEvents.RESIZE, resize)
|
||||
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
||||
},
|
||||
async resetForReplay(next) {
|
||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
await footer.idle()
|
||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||
throw new Error("runtime closed")
|
||||
}
|
||||
|
||||
footer.resetForReplay(true)
|
||||
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
||||
renderer.writeToScrollback(
|
||||
entrySplash({
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||
}),
|
||||
theme: theme.splash,
|
||||
showSession: splash.showSession,
|
||||
}),
|
||||
)
|
||||
renderer.requestRender()
|
||||
},
|
||||
close,
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Serial prompt queue for direct interactive mode.
|
||||
//
|
||||
// Prompts arrive from the footer (user types and hits enter) and queue up
|
||||
// here. The queue drains one turn at a time: it appends the user row to
|
||||
// scrollback, calls input.run() to execute the turn through the stream
|
||||
// transport, and waits for completion before starting the next prompt.
|
||||
// here. The queue drains one turn at a time; ordinary prompts waiting behind
|
||||
// an active ordinary turn are exposed for edit/removal until they begin.
|
||||
//
|
||||
// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection,
|
||||
// and tracks per-turn wall-clock duration for the footer status line.
|
||||
//
|
||||
// Resolves when the footer closes and all in-flight work finishes.
|
||||
import * as Locale from "@/util/locale"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
|
||||
import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -34,6 +34,8 @@ export type QueueInput = {
|
||||
|
||||
type State = {
|
||||
queue: RunPrompt[]
|
||||
queued: FooterQueuedPrompt[]
|
||||
active?: RunPrompt
|
||||
ctrl?: AbortController
|
||||
closed: boolean
|
||||
}
|
||||
@@ -51,15 +53,15 @@ function defer<T = void>(): Deferred<T> {
|
||||
|
||||
// Runs the prompt queue until the footer closes.
|
||||
//
|
||||
// Subscribes to footer prompt events, queues them, and drains one at a
|
||||
// time through input.run(). If the user submits multiple prompts while
|
||||
// a turn is running, they queue up and execute in order. The footer shows
|
||||
// the queue depth so the user knows how many are pending.
|
||||
// Subscribes to footer prompt events and drains operations through input.run().
|
||||
// Ordinary prompts submitted during an ordinary active turn remain local and
|
||||
// are exposed by the footer for edit/removal until their turn begins.
|
||||
export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const stop = defer<{ type: "closed" }>()
|
||||
const done = defer()
|
||||
const state: State = {
|
||||
queue: [],
|
||||
queued: [],
|
||||
closed: input.footer.isClosed,
|
||||
}
|
||||
let draining: Promise<void> | undefined
|
||||
@@ -69,6 +71,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
input.footer.event(next)
|
||||
}
|
||||
|
||||
const syncQueue = () => {
|
||||
const queue = state.queue.length
|
||||
emit({ type: "queue", queue }, { queue })
|
||||
emit(
|
||||
{
|
||||
type: "queued.prompts",
|
||||
prompts: [...state.queued],
|
||||
},
|
||||
{ queued: state.queued.length },
|
||||
)
|
||||
}
|
||||
|
||||
const removeLocalQueued = (queued: FooterQueuedPrompt) => {
|
||||
if (!state.queued.includes(queued)) return
|
||||
state.queued = state.queued.filter((item) => item !== queued)
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (!state.closed || draining) {
|
||||
return
|
||||
@@ -84,6 +104,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
|
||||
state.closed = true
|
||||
state.queue.length = 0
|
||||
state.queued.length = 0
|
||||
state.ctrl?.abort()
|
||||
stop.resolve({ type: "closed" })
|
||||
finish()
|
||||
@@ -102,16 +123,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
continue
|
||||
}
|
||||
|
||||
const queued = state.queued.find((item) => item.prompt === prompt)
|
||||
if (queued) removeLocalQueued(queued)
|
||||
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
emit(
|
||||
{
|
||||
type: "queue",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
syncQueue()
|
||||
if (!input.onNewSession) {
|
||||
emit(
|
||||
{
|
||||
@@ -146,6 +162,15 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
continue
|
||||
}
|
||||
|
||||
const sent =
|
||||
prompt.mode === "shell"
|
||||
? prompt
|
||||
: {
|
||||
...prompt,
|
||||
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
|
||||
}
|
||||
state.active = sent
|
||||
|
||||
emit(
|
||||
{
|
||||
type: "turn.send",
|
||||
@@ -167,18 +192,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
break
|
||||
}
|
||||
|
||||
if (prompt.mode !== "shell") {
|
||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
||||
if (sent.mode !== "shell") {
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(prompt)
|
||||
input.onSend?.(sent)
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
}
|
||||
|
||||
const task = input.run(prompt, ctrl.signal).then(
|
||||
const task = input.run(sent, ctrl.signal).then(
|
||||
() => ({ type: "done" as const }),
|
||||
(error) => ({ type: "error" as const, error }),
|
||||
)
|
||||
@@ -207,6 +238,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
duration,
|
||||
},
|
||||
)
|
||||
state.active = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -241,16 +273,28 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const active = state.active
|
||||
if (
|
||||
active &&
|
||||
active.mode !== "shell" &&
|
||||
!active.command &&
|
||||
prompt.mode !== "shell" &&
|
||||
!prompt.command &&
|
||||
!isNewCommand(prompt.text)
|
||||
) {
|
||||
const queued: FooterQueuedPrompt = {
|
||||
messageID: MessageID.ascending(),
|
||||
partID: PartID.ascending(),
|
||||
prompt,
|
||||
}
|
||||
state.queued = [...state.queued, queued]
|
||||
state.queue.push(prompt)
|
||||
syncQueue()
|
||||
return
|
||||
}
|
||||
|
||||
state.queue.push(prompt)
|
||||
emit(
|
||||
{
|
||||
type: "queue",
|
||||
queue: state.queue.length,
|
||||
},
|
||||
{
|
||||
queue: state.queue.length,
|
||||
},
|
||||
)
|
||||
syncQueue()
|
||||
if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
|
||||
drain()
|
||||
return
|
||||
@@ -274,6 +318,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const offClose = input.footer.onClose(() => {
|
||||
close()
|
||||
})
|
||||
const offRemoveQueued = input.footer.onQueuedRemove((messageID) => {
|
||||
const queued = state.queued.find((item) => item.messageID === messageID)
|
||||
if (!queued) return false
|
||||
state.queue = state.queue.filter((prompt) => prompt !== queued.prompt)
|
||||
removeLocalQueued(queued)
|
||||
return true
|
||||
})
|
||||
|
||||
try {
|
||||
if (state.closed) {
|
||||
@@ -289,6 +340,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
} finally {
|
||||
offPrompt()
|
||||
offClose()
|
||||
offRemoveQueued()
|
||||
close()
|
||||
await draining?.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
// 4. runs the prompt queue until the footer closes.
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { createRunDemo } from "./demo"
|
||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||
import { trace } from "./trace"
|
||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||
import type { RunInput, RunPrompt, RunProvider } from "./types"
|
||||
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
||||
@@ -114,6 +115,7 @@ type RuntimeState = {
|
||||
activeVariant: string | undefined
|
||||
sessionID: string
|
||||
history: RunPrompt[]
|
||||
localRows: LocalReplayRow[]
|
||||
sessionTitle?: string
|
||||
agent: string | undefined
|
||||
switching?: Promise<void>
|
||||
@@ -139,6 +141,9 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||
}
|
||||
|
||||
const REPLAY_RESIZE_DELAY = 250
|
||||
const LOCAL_REPLAY_ROW_LIMIT = 100
|
||||
|
||||
async function resolveExitTitle(
|
||||
ctx: BootContext,
|
||||
input: RunRuntimeInput,
|
||||
@@ -196,6 +201,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||
sessionID: ctx.sessionID,
|
||||
history: [...session.history],
|
||||
localRows: [],
|
||||
sessionTitle: ctx.sessionTitle,
|
||||
agent: ctx.agent,
|
||||
}
|
||||
@@ -374,6 +380,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
||||
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
||||
}
|
||||
|
||||
const loadCatalog = async (): Promise<void> => {
|
||||
if (footer.isClosed) {
|
||||
@@ -510,6 +519,36 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
return next
|
||||
}
|
||||
|
||||
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const offResize = input.replay
|
||||
? shell.onResize(() => {
|
||||
if (replayResizeTimer) {
|
||||
clearTimeout(replayResizeTimer)
|
||||
}
|
||||
|
||||
replayResizeTimer = setTimeout(() => {
|
||||
replayResizeTimer = undefined
|
||||
if (footer.isClosed || !state.stream) {
|
||||
return
|
||||
}
|
||||
|
||||
void state.stream
|
||||
.then((item) =>
|
||||
item.handle.replayOnResize({
|
||||
localRows: () => state.localRows,
|
||||
reset: () =>
|
||||
shell.resetForReplay({
|
||||
sessionTitle: state.sessionTitle,
|
||||
sessionID: state.sessionID,
|
||||
history: state.history,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.catch(() => {})
|
||||
}, REPLAY_RESIZE_DELAY)
|
||||
})
|
||||
: () => {}
|
||||
|
||||
const runQueue = async () => {
|
||||
let includeFiles = true
|
||||
if (state.demo) {
|
||||
@@ -525,6 +564,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
onSend: (prompt) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
if (prompt.mode !== "shell") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
})
|
||||
}
|
||||
},
|
||||
onNewSession: createSession
|
||||
? async () => {
|
||||
@@ -545,6 +593,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
state.sessionTitle = created.sessionTitle
|
||||
state.agent = created.agent ?? state.agent
|
||||
state.history = []
|
||||
state.localRows = []
|
||||
includeFiles = true
|
||||
state.demo = input.demo
|
||||
? createRunDemo({
|
||||
@@ -598,12 +647,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
status: "failed to start new session",
|
||||
},
|
||||
})
|
||||
footer.append({
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
messageID: MessageID.ascending(),
|
||||
} as const
|
||||
rememberLocal(commit)
|
||||
footer.append(commit)
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
@@ -614,6 +666,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
|
||||
await state.switching?.catch(() => {})
|
||||
|
||||
let outputAnchor: LocalReplayAnchor | undefined
|
||||
return withRunSpan(
|
||||
"RunInteractive.turn",
|
||||
{
|
||||
@@ -644,8 +697,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles,
|
||||
onVisibleOutput: (anchor) => {
|
||||
outputAnchor = anchor
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
)
|
||||
}
|
||||
includeFiles = false
|
||||
} catch (error) {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
@@ -656,7 +717,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
const text =
|
||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||
(error instanceof Error ? error.message : String(error))
|
||||
footer.append({ kind: "error", text, phase: "start", source: "system" })
|
||||
const commit = {
|
||||
kind: "error",
|
||||
text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
} as const
|
||||
rememberLocal(commit, outputAnchor)
|
||||
footer.append(commit)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -683,6 +752,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
try {
|
||||
await runQueue()
|
||||
} finally {
|
||||
if (replayResizeTimer) {
|
||||
clearTimeout(replayResizeTimer)
|
||||
}
|
||||
offResize()
|
||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -60,6 +60,7 @@ type SessionCommit = StreamCommit
|
||||
// - part: part ID → "assistant" | "reasoning" (text parts only)
|
||||
// - text: part ID → full accumulated text so far
|
||||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||
// - visible: part ID → rendered text for an active part after display transforms
|
||||
// - end: part IDs whose time.end has arrived (part is finished)
|
||||
// - shell: shell call ID → chosen transcript source for direct shell calls
|
||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||
@@ -82,6 +83,7 @@ export type SessionData = {
|
||||
part: Map<string, PartKind>
|
||||
text: Map<string, string>
|
||||
sent: Map<string, number>
|
||||
visible: Map<string, string>
|
||||
end: Set<string>
|
||||
echo: Map<string, Set<string>>
|
||||
}
|
||||
@@ -119,6 +121,7 @@ export function createSessionData(
|
||||
part: new Map(),
|
||||
text: new Map(),
|
||||
sent: new Map(),
|
||||
visible: new Map(),
|
||||
end: new Set(),
|
||||
echo: new Map(),
|
||||
}
|
||||
@@ -538,6 +541,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
|
||||
|
||||
if (chunk) {
|
||||
data.sent.set(partID, text.length)
|
||||
data.visible.set(partID, (data.visible.get(partID) ?? "") + chunk)
|
||||
commits.push({
|
||||
kind,
|
||||
text: chunk,
|
||||
@@ -567,6 +571,7 @@ function drop(data: SessionData, partID: string) {
|
||||
data.part.delete(partID)
|
||||
data.text.delete(partID)
|
||||
data.sent.delete(partID)
|
||||
data.visible.delete(partID)
|
||||
data.msg.delete(partID)
|
||||
data.end.delete(partID)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { messagePrompt, type SessionMessages } from "./session.shared"
|
||||
import type { FooterPatch, StreamCommit } from "./types"
|
||||
import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
|
||||
|
||||
type ReplayInput = {
|
||||
messages: SessionMessages
|
||||
@@ -186,3 +186,116 @@ export function replaySession(input: ReplayInput): SessionReplay {
|
||||
patch: replayPatch(data, patch),
|
||||
}
|
||||
}
|
||||
|
||||
export function replayLocalRows(
|
||||
messages: SessionMessages,
|
||||
commits: StreamCommit[],
|
||||
rows: LocalReplayRow[],
|
||||
): StreamCommit[] {
|
||||
const persisted = new Set(messages.map((message) => message.info.id))
|
||||
return rows.reduce((out, local) => {
|
||||
const row = local.commit
|
||||
if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) {
|
||||
return out
|
||||
}
|
||||
|
||||
if (!row.messageID) {
|
||||
return [...out, row]
|
||||
}
|
||||
|
||||
const exact = local.after
|
||||
? out.findIndex(
|
||||
(commit) =>
|
||||
commit.kind === local.after?.kind &&
|
||||
commit.text === local.after.text &&
|
||||
commit.phase === local.after.phase &&
|
||||
commit.toolState === local.after.toolState &&
|
||||
(local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID),
|
||||
)
|
||||
: -1
|
||||
const anchored =
|
||||
exact !== -1
|
||||
? exact
|
||||
: local.after
|
||||
? out.findLastIndex((commit) =>
|
||||
local.after?.partID
|
||||
? commit.partID === local.after.partID
|
||||
: commit.kind === local.after?.kind && commit.messageID === local.after.messageID,
|
||||
)
|
||||
: -1
|
||||
if (anchored !== -1) {
|
||||
const commit = out[anchored]
|
||||
const visible = local.after?.visible
|
||||
if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) {
|
||||
return [
|
||||
...out.slice(0, anchored),
|
||||
{ ...commit, text: visible },
|
||||
row,
|
||||
{ ...commit, text: commit.text.slice(visible.length) },
|
||||
...out.slice(anchored + 1),
|
||||
]
|
||||
}
|
||||
|
||||
return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)]
|
||||
}
|
||||
|
||||
const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID)
|
||||
if (after !== -1) {
|
||||
return [...out.slice(0, after + 1), row, ...out.slice(after + 1)]
|
||||
}
|
||||
|
||||
const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID)
|
||||
if (before === -1) {
|
||||
return [...out, row]
|
||||
}
|
||||
|
||||
return [...out.slice(0, before), row, ...out.slice(before)]
|
||||
}, commits)
|
||||
}
|
||||
|
||||
export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] {
|
||||
return [...current.part.entries()].flatMap(([partID, kind]) => {
|
||||
if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const text = current.text.get(partID) ?? ""
|
||||
const existing = data.text.get(partID) ?? ""
|
||||
const sent = current.sent.get(partID) ?? 0
|
||||
const existingSent = data.sent.get(partID) ?? 0
|
||||
const visible = current.visible.get(partID) ?? ""
|
||||
const existingVisible = data.visible.get(partID) ?? ""
|
||||
if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) {
|
||||
return []
|
||||
}
|
||||
|
||||
data.part.set(partID, kind)
|
||||
data.text.set(partID, text)
|
||||
data.sent.set(partID, sent)
|
||||
data.visible.set(partID, visible)
|
||||
const messageID = current.msg.get(partID)
|
||||
if (messageID) {
|
||||
data.msg.set(partID, messageID)
|
||||
const role = current.role.get(messageID)
|
||||
if (role) {
|
||||
data.role.set(messageID, role)
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = visible.slice(existingVisible.length)
|
||||
if (!chunk) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind,
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: kind,
|
||||
...(messageID ? { messageID } : {}),
|
||||
partID,
|
||||
},
|
||||
] satisfies StreamCommit[]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// produce scrollback commits and footer patches, which get forwarded to the
|
||||
// footer through stream.ts.
|
||||
//
|
||||
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the
|
||||
// SDK, arms a deferred Wait, and resolves when the session becomes idle.
|
||||
// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt, arms a
|
||||
// deferred Wait, and resolves when the session becomes idle.
|
||||
// Prefer session.status idle events, but also poll session.status because some
|
||||
// transports can miss status events while still delivering message events. If
|
||||
// the turn is aborted (user interrupt), it flushes any in-progress parts as
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import { replaySession } from "./session-replay"
|
||||
import { replayActiveText, replayLocalRows, replaySession } from "./session-replay"
|
||||
import {
|
||||
bootstrapSubagentCalls,
|
||||
bootstrapSubagentData,
|
||||
@@ -51,6 +51,8 @@ import type {
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
FooterView,
|
||||
LocalReplayAnchor,
|
||||
LocalReplayRow,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
@@ -81,6 +83,7 @@ type Wait = {
|
||||
tick: number
|
||||
armed: boolean
|
||||
live: boolean
|
||||
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
|
||||
done: Deferred.Deferred<void, unknown>
|
||||
}
|
||||
|
||||
@@ -91,15 +94,22 @@ export type SessionTurnInput = {
|
||||
prompt: RunPrompt
|
||||
files: RunFilePart[]
|
||||
includeFiles: boolean
|
||||
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export type SessionResizeReplayInput = {
|
||||
localRows: () => LocalReplayRow[]
|
||||
reset: () => Promise<void>
|
||||
}
|
||||
|
||||
type State = {
|
||||
data: SessionData
|
||||
subagent: SubagentData
|
||||
@@ -115,6 +125,7 @@ type State = {
|
||||
type TransportService = {
|
||||
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
|
||||
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
|
||||
readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect<boolean>
|
||||
readonly close: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -440,6 +451,9 @@ function createLayer(input: StreamInput) {
|
||||
blockers: new Map(),
|
||||
}
|
||||
let booting = true
|
||||
let replaying = false
|
||||
let replayDisabled = false
|
||||
let replayPending: SessionResizeReplayInput | undefined
|
||||
const buffered: Event[] = []
|
||||
const replayedParts = new Set<string>()
|
||||
const recovering = new Set<string>()
|
||||
@@ -594,6 +608,38 @@ function createLayer(input: StreamInput) {
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
|
||||
const replayMessages = () =>
|
||||
Effect.promise(() =>
|
||||
input.sdk.session.messages({
|
||||
sessionID: input.sessionID,
|
||||
...(input.replayLimit === undefined
|
||||
? {}
|
||||
: { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }),
|
||||
}),
|
||||
).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))))
|
||||
|
||||
const replayRequests = () =>
|
||||
Effect.all(
|
||||
[
|
||||
Effect.promise(() => input.sdk.permission.list()).pipe(
|
||||
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
|
||||
),
|
||||
Effect.promise(() => input.sdk.question.list()).pipe(
|
||||
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const markReplayedParts = (data: SessionData) => {
|
||||
replayedParts.clear()
|
||||
for (const [partID] of data.text) {
|
||||
if (data.part.has(partID)) {
|
||||
replayedParts.add(partID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* (
|
||||
sessions: string[],
|
||||
) {
|
||||
@@ -681,7 +727,6 @@ function createLayer(input: StreamInput) {
|
||||
})
|
||||
: history
|
||||
|
||||
replayedParts.clear()
|
||||
if (history) {
|
||||
state.data = history.data
|
||||
}
|
||||
@@ -695,14 +740,8 @@ function createLayer(input: StreamInput) {
|
||||
})
|
||||
}
|
||||
|
||||
if (replay) {
|
||||
for (const [partID] of replay.data.text) {
|
||||
if (!replay.data.part.has(partID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
replayedParts.add(partID)
|
||||
}
|
||||
if (history) {
|
||||
markReplayedParts(history.data)
|
||||
}
|
||||
|
||||
bootstrapSubagentData({
|
||||
@@ -862,6 +901,20 @@ function createLayer(input: StreamInput) {
|
||||
limits: input.limits(),
|
||||
})
|
||||
state.data = next.data
|
||||
const visible = next.commits.at(-1)
|
||||
if (visible) {
|
||||
state.wait?.onVisibleOutput?.({
|
||||
kind: visible.kind,
|
||||
text: visible.text,
|
||||
phase: visible.phase,
|
||||
messageID: visible.messageID,
|
||||
partID: visible.partID,
|
||||
toolState: visible.toolState,
|
||||
...(visible.partID && state.data.visible.has(visible.partID)
|
||||
? { visible: state.data.visible.get(visible.partID) }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "message.part.updated" &&
|
||||
@@ -910,15 +963,163 @@ function createLayer(input: StreamInput) {
|
||||
yield* applyEvent(event)
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
const arrived = buffered.splice(0)
|
||||
if (!changed && arrived.length === 0) {
|
||||
buffered.push(...next)
|
||||
return
|
||||
}
|
||||
|
||||
pending = next
|
||||
pending = [...next, ...arrived]
|
||||
}
|
||||
})
|
||||
|
||||
const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect<boolean> = Effect.fn(
|
||||
"RunStreamTransport.replayOnResize",
|
||||
)(function* (next: SessionResizeReplayInput) {
|
||||
if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (replaying) {
|
||||
replayPending = next
|
||||
return false
|
||||
}
|
||||
|
||||
const finish: () => Effect.Effect<void> = Effect.fnUntraced(function* () {
|
||||
yield* drainBuffered()
|
||||
const pending = replayPending
|
||||
replayPending = undefined
|
||||
if (!pending || replayDisabled || closed || input.footer.isClosed) {
|
||||
replaying = false
|
||||
return
|
||||
}
|
||||
|
||||
replaying = false
|
||||
yield* replayOnResize(pending).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
replayedParts.clear()
|
||||
replaying = true
|
||||
input.trace?.write("replay.resize.start", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe(
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(source)) {
|
||||
input.trace?.write("replay.resize.abort", {
|
||||
sessionID: input.sessionID,
|
||||
phase: "snapshot",
|
||||
})
|
||||
yield* finish()
|
||||
return false
|
||||
}
|
||||
|
||||
const [messagesList, [permissions, questions]] = source.value
|
||||
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
|
||||
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
|
||||
const snapshot = yield* Effect.try({
|
||||
try: () => {
|
||||
const history = replaySession({
|
||||
messages: messagesList,
|
||||
permissions: sessionPermissions,
|
||||
questions: sessionQuestions,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
const activeCommits = replayActiveText(history.data, state.data)
|
||||
return {
|
||||
history,
|
||||
activeCommits,
|
||||
patch:
|
||||
history.data.part.size > 0 || history.data.tools.size > 0
|
||||
? { ...history.patch, phase: "running" as const }
|
||||
: history.patch,
|
||||
visible:
|
||||
input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
||||
? replaySession({
|
||||
messages: messagesList.slice(-input.replayLimit),
|
||||
permissions: sessionPermissions,
|
||||
questions: sessionQuestions,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
: history,
|
||||
}
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isFailure(snapshot)) {
|
||||
input.trace?.write("replay.resize.abort", {
|
||||
sessionID: input.sessionID,
|
||||
phase: "snapshot",
|
||||
})
|
||||
yield* finish()
|
||||
return false
|
||||
}
|
||||
|
||||
const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
|
||||
if (Exit.isFailure(idle) || closed || input.footer.isClosed) {
|
||||
yield* finish()
|
||||
return false
|
||||
}
|
||||
|
||||
const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit)
|
||||
if (Exit.isFailure(reset)) {
|
||||
replayDisabled = true
|
||||
input.trace?.write("replay.resize.disable", {
|
||||
sessionID: input.sessionID,
|
||||
phase: "reset",
|
||||
})
|
||||
input.footer.append({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
yield* finish()
|
||||
return false
|
||||
}
|
||||
|
||||
state.data = snapshot.value.history.data
|
||||
for (const request of [...state.data.permissions, ...state.data.questions]) {
|
||||
seedBlocker(request.id)
|
||||
}
|
||||
|
||||
for (const commit of replayLocalRows(
|
||||
messagesList,
|
||||
[...snapshot.value.visible.commits, ...snapshot.value.activeCommits],
|
||||
next.localRows(),
|
||||
)) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
syncFooter([], snapshot.value.patch, currentSubagentState())
|
||||
const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
|
||||
if (Exit.isFailure(rebuilt)) {
|
||||
replayDisabled = true
|
||||
input.trace?.write("replay.resize.disable", {
|
||||
sessionID: input.sessionID,
|
||||
phase: "rebuild",
|
||||
})
|
||||
input.footer.append({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
yield* finish()
|
||||
return false
|
||||
}
|
||||
|
||||
input.trace?.write("replay.resize.complete", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
yield* finish()
|
||||
return true
|
||||
})
|
||||
|
||||
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||
Stream.fromAsyncIterable(events.stream, (error) =>
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
@@ -943,7 +1144,7 @@ function createLayer(input: StreamInput) {
|
||||
}
|
||||
|
||||
const sessionID = sid(event)
|
||||
if (booting) {
|
||||
if (booting || replaying) {
|
||||
if (sessionID) {
|
||||
input.trace?.write("recv.event", event)
|
||||
buffered.push(event)
|
||||
@@ -1005,6 +1206,7 @@ function createLayer(input: StreamInput) {
|
||||
tick: state.tick,
|
||||
armed: false,
|
||||
live: false,
|
||||
onVisibleOutput: next.onVisibleOutput,
|
||||
done: yield* Deferred.make<void, unknown>(),
|
||||
}
|
||||
state.wait = item
|
||||
@@ -1020,6 +1222,7 @@ function createLayer(input: StreamInput) {
|
||||
|
||||
const req = {
|
||||
sessionID: input.sessionID,
|
||||
messageID: next.prompt.messageID,
|
||||
agent: next.agent,
|
||||
model: next.model,
|
||||
variant: next.variant,
|
||||
@@ -1081,6 +1284,7 @@ function createLayer(input: StreamInput) {
|
||||
input.sdk.session.command(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
messageID: next.prompt.messageID,
|
||||
agent: next.agent,
|
||||
model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined,
|
||||
variant: next.variant,
|
||||
@@ -1231,6 +1435,7 @@ function createLayer(input: StreamInput) {
|
||||
return Service.of({
|
||||
runPromptTurn,
|
||||
selectSubagent,
|
||||
replayOnResize,
|
||||
close,
|
||||
})
|
||||
}),
|
||||
@@ -1254,6 +1459,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return {
|
||||
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
|
||||
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
|
||||
replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)),
|
||||
close: () => runtime.runPromise((svc) => svc.close()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type RunCommand = NonNullable<Awaited<ReturnType<OpencodeClient["command"
|
||||
export type RunProvider = NonNullable<Awaited<ReturnType<OpencodeClient["provider"]["list"]>>["data"]>["all"][number]
|
||||
|
||||
export type RunPrompt = {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
mode?: "shell"
|
||||
@@ -40,6 +42,12 @@ export type RunPrompt = {
|
||||
}
|
||||
}
|
||||
|
||||
export type FooterQueuedPrompt = {
|
||||
messageID: string
|
||||
partID: string
|
||||
prompt: RunPrompt
|
||||
}
|
||||
|
||||
export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
|
||||
|
||||
type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
|
||||
@@ -162,6 +170,7 @@ export type FooterView =
|
||||
|
||||
export type FooterPromptRoute =
|
||||
| { type: "composer" }
|
||||
| { type: "queued-menu" }
|
||||
| { type: "subagent-menu" }
|
||||
| { type: "subagent"; sessionID: string }
|
||||
| { type: "command" }
|
||||
@@ -222,6 +231,10 @@ export type FooterEvent =
|
||||
type: "queue"
|
||||
queue: number
|
||||
}
|
||||
| {
|
||||
type: "queued.prompts"
|
||||
prompts: FooterQueuedPrompt[]
|
||||
}
|
||||
| {
|
||||
type: "first"
|
||||
first: boolean
|
||||
@@ -296,12 +309,28 @@ export type StreamCommit = {
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalReplayAnchor = {
|
||||
kind: EntryKind
|
||||
text: string
|
||||
phase: StreamPhase
|
||||
messageID?: string
|
||||
partID?: string
|
||||
toolState?: StreamToolState
|
||||
visible?: string
|
||||
}
|
||||
|
||||
export type LocalReplayRow = {
|
||||
commit: StreamCommit
|
||||
after?: LocalReplayAnchor
|
||||
}
|
||||
|
||||
// The public contract between the stream transport / prompt queue and
|
||||
// the footer. RunFooter implements this. The transport and queue never
|
||||
// touch the renderer directly -- they go through this interface.
|
||||
export type FooterApi = {
|
||||
readonly isClosed: boolean
|
||||
onPrompt(fn: (input: RunPrompt) => void): () => void
|
||||
onQueuedRemove(fn: (messageID: string) => boolean | Promise<boolean>): () => void
|
||||
onClose(fn: () => void): () => void
|
||||
event(next: FooterEvent): void
|
||||
append(commit: StreamCommit): void
|
||||
|
||||
@@ -25,10 +25,11 @@ import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { promptOffsetWidth } from "@/cli/cmd/prompt-display"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { computePromptTraits } from "./traits"
|
||||
import { assign, expandPastedTextPlaceholders } from "./part"
|
||||
import { assign, expandPastedTextPlaceholders, expandTrackedPastedText } from "./part"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
@@ -404,9 +405,12 @@ export function Prompt(props: PromptProps) {
|
||||
// Only set agent if it's a primary agent (not a subagent)
|
||||
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
|
||||
if (msg.agent && isPrimaryAgent) {
|
||||
// Keep command line --agent if specified.
|
||||
if (!args.agent) local.agent.set(msg.agent)
|
||||
if (msg.model) {
|
||||
// Keep an explicitly configured agent (--agent flag or config "default_agent") instead
|
||||
// of flipping to the session's last-used agent when navigating between sessions.
|
||||
if (!args.agent && !sync.data.config.default_agent) local.agent.set(msg.agent)
|
||||
// Keep an explicitly configured model (--model flag or config "model") instead of
|
||||
// flipping to the session's last-used model when navigating between sessions.
|
||||
if (msg.model && !args.model && !sync.data.config.model) {
|
||||
local.model.set(msg.model)
|
||||
local.model.variant.set(msg.model.variant)
|
||||
}
|
||||
@@ -1109,23 +1113,15 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
let inputText = store.prompt.input
|
||||
|
||||
// Expand pasted text inline before submitting
|
||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
|
||||
|
||||
for (const extmark of sortedExtmarks) {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = store.prompt.parts[partIndex]
|
||||
if (part?.type === "text" && part.text) {
|
||||
const before = inputText.slice(0, extmark.start)
|
||||
const after = inputText.slice(extmark.end)
|
||||
inputText = before + part.text + after
|
||||
}
|
||||
}
|
||||
}
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.input,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
||||
if (part?.type !== "text") return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
|
||||
// Filter out text parts (pasted content) since they're now expanded inline
|
||||
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
|
||||
@@ -1242,9 +1238,9 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
|
||||
function pasteText(text: string, virtualText: string) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
|
||||
|
||||
input.insertText(virtualText + " ")
|
||||
|
||||
@@ -1336,7 +1332,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const pdf = file.mime === "application/pdf"
|
||||
const count = store.prompt.parts.filter((x) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PartID } from "@/session/schema"
|
||||
import { displaySlice } from "@/cli/cmd/prompt-display"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
type Item = PromptInfo["parts"][number]
|
||||
@@ -21,3 +22,10 @@ export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["pa
|
||||
return result.replace(part.source.text.value, part.text)
|
||||
}, text)
|
||||
}
|
||||
|
||||
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
||||
return ranges
|
||||
.slice()
|
||||
.sort((a, b) => b.start - a.start)
|
||||
.reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const Definitions = {
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
|
||||
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
|
||||
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
|
||||
session_child_first: keybind("<leader>down", "Go to first child session"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
@@ -292,6 +293,7 @@ export const CommandMap = {
|
||||
session_compact: "session.compact",
|
||||
session_toggle_timestamps: "session.toggle.timestamps",
|
||||
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
session_child_first: "session.child.first",
|
||||
session_child_cycle: "session.child.next",
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
|
||||
@@ -87,7 +87,8 @@ export const ReadTool = Tool.define(
|
||||
})
|
||||
|
||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
||||
yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
|
||||
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
const readSample = Effect.fn("ReadTool.readSample")(function* (
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Parameters = Schema.Struct({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
default: "markdown",
|
||||
})
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
.pipe(Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,10 +12,9 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import * as ACPService from "@/acp/service"
|
||||
import * as ACPError from "@/acp/error"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
import { UsageService } from "@/acp/usage"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
@@ -142,7 +141,10 @@ const provider: Provider.Info = {
|
||||
}
|
||||
|
||||
describe("ACP service sessions", () => {
|
||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||
const makeService = (
|
||||
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
|
||||
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
|
||||
) => {
|
||||
const updates: SessionNotification[] = []
|
||||
const mcpAdds: string[] = []
|
||||
const aborts: string[] = []
|
||||
@@ -220,10 +222,12 @@ describe("ACP service sessions", () => {
|
||||
summarizes.push(input)
|
||||
return Promise.resolve({ data: true })
|
||||
},
|
||||
abort: (input: { sessionID: string }) => {
|
||||
aborts.push(input.sessionID)
|
||||
return Promise.resolve({ data: true })
|
||||
},
|
||||
abort:
|
||||
options?.abort ??
|
||||
((input: { sessionID: string }) => {
|
||||
aborts.push(input.sessionID)
|
||||
return Promise.resolve({ data: true })
|
||||
}),
|
||||
fork: (input: { sessionID: string }) => {
|
||||
forks.push(input.sessionID)
|
||||
return Promise.resolve({ data: { id: `fork_${input.sessionID}` } })
|
||||
@@ -381,34 +385,28 @@ describe("ACP service sessions", () => {
|
||||
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
|
||||
})
|
||||
|
||||
it("does not fail close when backing abort fails", async () => {
|
||||
const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync(
|
||||
ACPSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
const { service } = makeService()
|
||||
const sdk = {
|
||||
config: {
|
||||
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||
get: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
app: {
|
||||
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
|
||||
skills: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
command: {
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
abort: () => Promise.reject(new Error("nope")),
|
||||
},
|
||||
mcp: {
|
||||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const closing = ACPService.make({ sdk, session: sessionService })
|
||||
await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
|
||||
it("cancel aborts the backing session and keeps the ACP session", async () => {
|
||||
const { service, aborts } = makeService()
|
||||
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({})
|
||||
await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
|
||||
|
||||
// The running turn was aborted via the core session API.
|
||||
expect(aborts).toEqual([created.sessionId])
|
||||
// Unlike closeSession, the ACP session is still present afterwards so
|
||||
// the client can keep prompting.
|
||||
const stillUsable = await Effect.runPromise(
|
||||
service.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }),
|
||||
)
|
||||
expect(stillUsable).toBeDefined()
|
||||
})
|
||||
|
||||
it("does not fail cancel or close when the backing abort fails", async () => {
|
||||
const { service } = makeService([], { abort: () => Promise.reject(new Error("nope")) })
|
||||
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
|
||||
expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
|
||||
expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
|
||||
import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
|
||||
import { assign, expandTrackedPastedText, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
|
||||
|
||||
describe("prompt part", () => {
|
||||
test("strip removes persisted ids from reused file parts", () => {
|
||||
@@ -44,4 +44,34 @@ describe("prompt part", () => {
|
||||
url: "data:image/png;base64,abc",
|
||||
})
|
||||
})
|
||||
|
||||
test("expandTrackedPastedText preserves wide characters around pasted text", () => {
|
||||
const marker = "[Pasted ~3 lines]"
|
||||
const prefix = "你好你好\n"
|
||||
|
||||
expect(
|
||||
expandTrackedPastedText(prefix + marker + "\n阿斯顿法国红酒看来", [
|
||||
{
|
||||
start: Bun.stringWidth("你好你好") + 1,
|
||||
end: Bun.stringWidth("你好你好") + 1 + Bun.stringWidth(marker),
|
||||
text: "public:\n\tvoid ExecuteTask();\nprivate:",
|
||||
},
|
||||
]),
|
||||
).toBe("你好你好\npublic:\n\tvoid ExecuteTask();\nprivate:\n阿斯顿法国红酒看来")
|
||||
})
|
||||
|
||||
test("expandTrackedPastedText only expands the tracked placeholder occurrence", () => {
|
||||
const marker = "[Pasted ~3 lines]"
|
||||
const prefix = `keep ${marker} then `
|
||||
|
||||
expect(
|
||||
expandTrackedPastedText(prefix + marker + " tail", [
|
||||
{
|
||||
start: Bun.stringWidth(prefix),
|
||||
end: Bun.stringWidth(prefix + marker),
|
||||
text: "alpha\nbeta\ngamma",
|
||||
},
|
||||
]),
|
||||
).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +103,7 @@ Options:
|
||||
--variant model variant (provider-specific reasoning effort, e.g., high,
|
||||
max, minimal) [string]
|
||||
--thinking show thinking blocks [boolean]
|
||||
--replay replay visible session history on interactive resume
|
||||
--replay replay interactive session history on resume and after resize
|
||||
[boolean] [default: false]
|
||||
--replay-limit cap visible interactive replay to the newest N messages
|
||||
[number]
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunCommandMenuBody,
|
||||
RunModelSelectBody,
|
||||
RunQueuedPromptSelectBody,
|
||||
RunSubagentSelectBody,
|
||||
RunVariantSelectBody,
|
||||
} from "@/cli/cmd/run/footer.command"
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
FooterView,
|
||||
RunCommand,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunTuiConfig,
|
||||
StreamCommit,
|
||||
@@ -147,7 +149,14 @@ function footerState(input: Partial<FooterState> = {}) {
|
||||
})[0]
|
||||
}
|
||||
|
||||
async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => void } = {}) {
|
||||
async function renderFooter(
|
||||
input: {
|
||||
tuiConfig?: RunTuiConfig
|
||||
commands?: RunCommand[]
|
||||
onCycle?: () => void
|
||||
onSubmit?: (prompt: RunPrompt) => boolean
|
||||
} = {},
|
||||
) {
|
||||
const [view] = createSignal<FooterView>({ type: "prompt" })
|
||||
const [subagents] = createSignal<FooterSubagentState>({ tabs: [], details: {}, permissions: [], questions: [] })
|
||||
const state = footerState()
|
||||
@@ -166,7 +175,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
|
||||
findFiles={async () => []}
|
||||
agents={() => []}
|
||||
resources={() => []}
|
||||
commands={() => []}
|
||||
commands={() => input.commands ?? []}
|
||||
providers={() => undefined}
|
||||
currentModel={() => undefined}
|
||||
variants={() => []}
|
||||
@@ -177,7 +186,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
|
||||
theme={RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
agent="opencode"
|
||||
onSubmit={() => true}
|
||||
onSubmit={input.onSubmit ?? (() => true)}
|
||||
onPermissionReply={() => {}}
|
||||
onQuestionReply={() => {}}
|
||||
onQuestionReject={() => {}}
|
||||
@@ -190,6 +199,7 @@ async function renderFooter(input: { tuiConfig?: RunTuiConfig; onCycle?: () => v
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
@@ -276,11 +286,13 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
commands={commands}
|
||||
subagents={subagents}
|
||||
queued={() => []}
|
||||
variants={variants}
|
||||
variantCycle="ctrl+t"
|
||||
onClose={() => {}}
|
||||
onModel={() => {}}
|
||||
onSubagent={() => {}}
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onCommand={() => {}}
|
||||
@@ -334,11 +346,13 @@ test("direct command panel shows subagent entry when available", async () => {
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
commands={commands}
|
||||
subagents={subagents}
|
||||
queued={() => []}
|
||||
variants={variants}
|
||||
variantCycle="ctrl+t"
|
||||
onClose={() => {}}
|
||||
onModel={() => {}}
|
||||
onSubagent={() => {}}
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onCommand={() => {}}
|
||||
@@ -407,6 +421,36 @@ test("direct subagent panel renders active subagents", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||
const [prompts] = createSignal([
|
||||
{ messageID: "m-1", partID: "p-1", prompt: { text: "fix the auth test", parts: [] } },
|
||||
])
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_SUBAGENT_PANEL_ROWS}>
|
||||
<RunQueuedPromptSelectBody
|
||||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 100, height: RUN_SUBAGENT_PANEL_ROWS },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Queued prompts")
|
||||
expect(app.captureCharFrame()).toContain("fix the auth test")
|
||||
expect(app.captureCharFrame()).toContain("queued")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// OpenTUI currently segfaults when the full footer view suite creates several
|
||||
// keymap-backed test renderers in one process. Re-enable after the runtime fix.
|
||||
test.skip("direct footer opens command panel through keymap binding", async () => {
|
||||
@@ -462,11 +506,73 @@ test("direct footer keeps leader variant binding inactive when leader is disable
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer shows subagent indicator while prompt is running", async () => {
|
||||
test("direct footer submits slash autocomplete selections without dispatching shell completions", async () => {
|
||||
const submits: RunPrompt[] = []
|
||||
const app = await renderFooter({
|
||||
commands: [command({ name: "review", description: "Review code" })],
|
||||
onSubmit(prompt) {
|
||||
submits.push(prompt)
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressKey("TAB")
|
||||
await app.renderOnce()
|
||||
|
||||
"/re branch".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
Array.from({ length: 7 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
|
||||
app.mockInput.pressKey("v")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
"/nx".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
app.mockInput.pressKey("ARROW_LEFT")
|
||||
app.mockInput.pressKey("e")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
"/n scratch".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
Array.from({ length: 8 }).forEach(() => app.mockInput.pressKey("ARROW_LEFT"))
|
||||
app.mockInput.pressKey("e")
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
app.mockInput.pressKey("!")
|
||||
"/rev".split("").forEach((key) => app.mockInput.pressKey(key))
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/new ", parts: [] },
|
||||
])
|
||||
expect(app.captureCharFrame()).toContain("/review")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer shows editable prompts and additional queued work while running", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "",
|
||||
queue: 0,
|
||||
queue: 3,
|
||||
model: "gpt-5",
|
||||
duration: "",
|
||||
usage: "",
|
||||
@@ -502,6 +608,9 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
||||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
queuedPrompts={() => [
|
||||
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
|
||||
]}
|
||||
theme={RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
agent="opencode"
|
||||
@@ -518,6 +627,7 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
||||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
@@ -525,19 +635,21 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={8}>
|
||||
<box width={160} height={8}>
|
||||
<Harness />
|
||||
</box>
|
||||
),
|
||||
{
|
||||
width: 100,
|
||||
width: 160,
|
||||
height: 8,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ↓ to view")
|
||||
expect(app.captureCharFrame()).toContain("interrupt · 1 agent · ctrl+x down to view · 1 queued prompt · ctrl+x q")
|
||||
expect(app.captureCharFrame()).toContain("2 queued")
|
||||
expect(app.captureCharFrame()).not.toContain("agent · ·")
|
||||
} finally {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/
|
||||
|
||||
function footer() {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const queuedRemoves = new Set<(messageID: string) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
const events: FooterEvent[] = []
|
||||
const commits: StreamCommit[] = []
|
||||
@@ -19,6 +20,12 @@ function footer() {
|
||||
prompts.delete(fn)
|
||||
}
|
||||
},
|
||||
onQueuedRemove(fn) {
|
||||
queuedRemoves.add(fn)
|
||||
return () => {
|
||||
queuedRemoves.delete(fn)
|
||||
}
|
||||
},
|
||||
onClose(fn) {
|
||||
if (closed) {
|
||||
fn()
|
||||
@@ -66,6 +73,9 @@ function footer() {
|
||||
fn(next)
|
||||
}
|
||||
},
|
||||
removeQueued(messageID: string) {
|
||||
for (const fn of [...queuedRemoves]) fn(messageID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +143,7 @@ describe("run runtime queue", () => {
|
||||
text: "hello",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -215,6 +226,7 @@ describe("run runtime queue", () => {
|
||||
text: " hello ",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -250,6 +262,7 @@ describe("run runtime queue", () => {
|
||||
text: "/fmt bash",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
ui.api.close()
|
||||
@@ -289,6 +302,82 @@ describe("run runtime queue", () => {
|
||||
expect(seen).toEqual(["one", "two"])
|
||||
})
|
||||
|
||||
test("exposes ordinary in-flight prompts for removal before sending", async () => {
|
||||
const ui = footer()
|
||||
const turns: RunPrompt[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
turns.push(input)
|
||||
await gate
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||
expect(turns[0]?.messageID).toEqual(expect.any(String))
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
||||
const first = ui.events.find((item) => item.type === "queued.prompts")
|
||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
|
||||
expect(
|
||||
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
|
||||
).toBe(false)
|
||||
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
|
||||
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
|
||||
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
|
||||
await Promise.resolve()
|
||||
|
||||
wake?.()
|
||||
ui.api.close()
|
||||
await task
|
||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||
})
|
||||
|
||||
test("removing one managed queued prompt preserves the others", async () => {
|
||||
const ui = footer()
|
||||
const turns: string[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
turns.push(input.text)
|
||||
if (input.text === "active") await gate
|
||||
if (input.text === "queued three") ui.api.close()
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("active")
|
||||
ui.submit("queued one")
|
||||
ui.submit("queued two")
|
||||
ui.submit("queued three")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
if (event?.type === "queued.prompts") {
|
||||
const second = event.prompts.find((item) => item.prompt.text === "queued two")
|
||||
if (second) ui.removeQueued(second.messageID)
|
||||
}
|
||||
|
||||
wake?.()
|
||||
await task
|
||||
expect(turns).toEqual(["active", "queued one", "queued three"])
|
||||
})
|
||||
|
||||
test("drains a prompt queued during an in-flight turn", async () => {
|
||||
const ui = footer()
|
||||
const seen: string[] = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { replaySession } from "@/cli/cmd/run/session-replay"
|
||||
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
|
||||
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
||||
|
||||
function userMessage(id: string, text: string): SessionMessages[number] {
|
||||
@@ -156,4 +156,301 @@ describe("run session replay", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("merges failed local rows ahead of later persisted prompts", () => {
|
||||
const persisted = {
|
||||
kind: "user",
|
||||
text: "successful",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
const failed = {
|
||||
kind: "user",
|
||||
text: "failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "network unavailable",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
|
||||
).toEqual([failed, error, persisted])
|
||||
})
|
||||
|
||||
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
|
||||
const persisted = {
|
||||
kind: "user",
|
||||
text: "failed after persistence",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "connection closed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "failed after persistence")],
|
||||
[persisted],
|
||||
[{ commit: persisted }, { commit: error }],
|
||||
),
|
||||
).toEqual([persisted, error])
|
||||
})
|
||||
|
||||
test("keeps a local turn failure below assistant output already visible for that turn", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const answer = {
|
||||
kind: "assistant",
|
||||
text: "partial answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const second = {
|
||||
kind: "user",
|
||||
text: "retry",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
|
||||
[first, answer, second],
|
||||
[
|
||||
{
|
||||
commit: error,
|
||||
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([first, answer, error, second])
|
||||
})
|
||||
|
||||
test("keeps a local failure above assistant output received after the failure", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "request failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const late = {
|
||||
kind: "assistant",
|
||||
text: "late answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
} as const
|
||||
|
||||
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
|
||||
first,
|
||||
error,
|
||||
late,
|
||||
])
|
||||
})
|
||||
|
||||
test("inserts a local failure between persisted output chunks spanning that failure", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const complete = {
|
||||
kind: "assistant",
|
||||
text: "before after",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "start")],
|
||||
[first, complete],
|
||||
[
|
||||
{
|
||||
commit: error,
|
||||
after: {
|
||||
kind: "assistant",
|
||||
text: "before ",
|
||||
phase: "progress",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-1",
|
||||
visible: "before ",
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
|
||||
})
|
||||
|
||||
test("places an unpersisted failed prompt before live output from that turn", () => {
|
||||
const prompt = {
|
||||
kind: "user",
|
||||
text: "start",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-1",
|
||||
} as const
|
||||
const answer = {
|
||||
kind: "assistant",
|
||||
text: "partial answer",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-2",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "stream failed",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[],
|
||||
[answer],
|
||||
[
|
||||
{ commit: prompt },
|
||||
{
|
||||
commit: error,
|
||||
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([prompt, answer, error])
|
||||
})
|
||||
|
||||
test("anchors a failure after the visible start of a tool that later completes", () => {
|
||||
const prompt = {
|
||||
kind: "user",
|
||||
text: "run ls",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const running = {
|
||||
kind: "tool",
|
||||
text: "running bash",
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "running",
|
||||
} as const
|
||||
const completed = {
|
||||
kind: "tool",
|
||||
text: "file.txt",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "completed",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "connection lost",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "run ls")],
|
||||
[prompt, running, completed],
|
||||
[
|
||||
{
|
||||
commit: error,
|
||||
after: {
|
||||
kind: "tool",
|
||||
text: "running bash",
|
||||
phase: "start",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "part-tool-1",
|
||||
toolState: "running",
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([prompt, running, error, completed])
|
||||
})
|
||||
|
||||
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
|
||||
const first = {
|
||||
kind: "user",
|
||||
text: "before",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-1",
|
||||
} as const
|
||||
const error = {
|
||||
kind: "error",
|
||||
text: "failed to start new session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-2",
|
||||
} as const
|
||||
const second = {
|
||||
kind: "user",
|
||||
text: "after",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-user-3",
|
||||
} as const
|
||||
|
||||
expect(
|
||||
replayLocalRows(
|
||||
[userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
|
||||
[first, second],
|
||||
[{ commit: error }],
|
||||
),
|
||||
).toEqual([first, error, second])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ function footer() {
|
||||
const api: FooterApi = {
|
||||
isClosed: false,
|
||||
onPrompt: () => () => {},
|
||||
onQueuedRemove: () => () => {},
|
||||
onClose: () => () => {},
|
||||
event: (next) => {
|
||||
events.push(next)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
|
||||
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
||||
import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
||||
|
||||
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
|
||||
type GlobalEventStream = Awaited<ReturnType<OpencodeClient["global"]["event"]>>["stream"]
|
||||
@@ -11,6 +11,7 @@ type SessionChild = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["ch
|
||||
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
|
||||
type SessionStatusMap = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["status"]>>["data"]>
|
||||
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
|
||||
type ReasoningPart = Extract<SessionMessage["parts"][number], { type: "reasoning" }>
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
@@ -298,6 +299,29 @@ function textUpdated(part: TextPart): SdkEvent {
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningPart(id: string, messageID: string, text: string): ReasoningPart {
|
||||
return {
|
||||
id,
|
||||
sessionID: "session-1",
|
||||
messageID,
|
||||
type: "reasoning",
|
||||
text,
|
||||
time: { start: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningUpdated(part: ReasoningPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolUpdated(part: SessionToolPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
@@ -358,6 +382,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
||||
return closed
|
||||
},
|
||||
onPrompt: () => () => {},
|
||||
onQueuedRemove: () => () => {},
|
||||
onClose: () => () => {},
|
||||
event(next) {
|
||||
events.push(next)
|
||||
@@ -720,6 +745,444 @@ describe("run stream transport", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("rebuilds session output on resize and continues live deltas from replayed state", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-1",
|
||||
parts: [textPart("text-1", "msg-1", "Hello")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const localRows: LocalReplayRow[] = [
|
||||
{ commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } },
|
||||
]
|
||||
const reset = mock(() => {
|
||||
localRows.push({
|
||||
commit: {
|
||||
kind: "user",
|
||||
text: "sent during reset",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: "msg-during-reset",
|
||||
},
|
||||
})
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
expect(
|
||||
await transport.replayOnResize({
|
||||
localRows: () => localRows,
|
||||
reset,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "assistant", text: "Hello" }),
|
||||
expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }),
|
||||
]),
|
||||
)
|
||||
|
||||
src.push(textUpdated(textPart("text-1", "msg-1", "Hello world")))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world"))
|
||||
expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([
|
||||
"Hello",
|
||||
" world",
|
||||
])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("coalesces active resize requests into one trailing replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const firstReset = defer()
|
||||
const resetA = mock(() => firstReset.promise)
|
||||
const resetB = mock(() => Promise.resolve())
|
||||
const resetC = mock(() => Promise.resolve())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
|
||||
firstReset.resolve()
|
||||
expect(await active).toBe(true)
|
||||
expect(resetA).toHaveBeenCalledTimes(1)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
expect(resetC).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps coalescing resize requests while buffered events drain", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const firstReset = defer()
|
||||
const statusGate = defer()
|
||||
const statusStarted = defer()
|
||||
let blockStatus = false
|
||||
const trace = mock((_type: string, _data?: unknown) => {})
|
||||
const resetA = mock(() => firstReset.promise)
|
||||
const resetB = mock(() => Promise.resolve())
|
||||
const resetC = mock(() => Promise.resolve())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
status: async () => {
|
||||
if (blockStatus) {
|
||||
statusStarted.resolve()
|
||||
await statusGate.promise
|
||||
}
|
||||
return ok(statusMap(true))
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
trace: { write: trace },
|
||||
})
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { text: "active", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await waitFor(() => ui.events.find((event) => event.type === "turn.wait"))
|
||||
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||
blockStatus = true
|
||||
src.push(busy())
|
||||
src.push(idle())
|
||||
await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined))
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||
firstReset.resolve()
|
||||
await Promise.race([
|
||||
statusStarted.promise,
|
||||
Bun.sleep(1_000).then(() => {
|
||||
throw new Error("timed out waiting for buffered status drain")
|
||||
}),
|
||||
])
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||
expect(resetC).not.toHaveBeenCalled()
|
||||
blockStatus = false
|
||||
statusGate.resolve()
|
||||
|
||||
expect(
|
||||
await Promise.race([
|
||||
active,
|
||||
Bun.sleep(1_000).then(() => {
|
||||
throw new Error("timed out waiting for trailing resize replay")
|
||||
}),
|
||||
]),
|
||||
).toBe(true)
|
||||
expect(resetB).not.toHaveBeenCalled()
|
||||
expect(resetC).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
await turn
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-live",
|
||||
parts: [textPart("text-live", "msg-live", "")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-live"))
|
||||
src.push(textUpdated(textPart("text-live", "msg-live", "")))
|
||||
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||
src.push(
|
||||
textUpdated({
|
||||
...textPart("text-live", "msg-live", "HelloHello"),
|
||||
time: { start: 1, end: 2 },
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2
|
||||
? true
|
||||
: undefined,
|
||||
)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||
).toEqual(["Hello", "Hello"])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves the display prefix for active reasoning restored during replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-thinking",
|
||||
parts: [reasoningPart("thinking-1", "msg-thinking", "")],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-thinking"))
|
||||
src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", "")))
|
||||
src.push(textDelta("msg-thinking", "thinking-1", "plan"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([
|
||||
"Thinking: plan",
|
||||
])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not overlay stale active text when persistence completes during replay", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([
|
||||
assistantMessage({
|
||||
sessionID: "session-1",
|
||||
id: "msg-finished",
|
||||
parts: [
|
||||
{
|
||||
...textPart("text-finished", "msg-finished", "Hello"),
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
src.push(assistant("msg-finished"))
|
||||
src.push(textUpdated(textPart("text-finished", "msg-finished", "")))
|
||||
src.push(textDelta("msg-finished", "text-finished", "Hello"))
|
||||
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||
ui.commits.length = 0
|
||||
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||
expect(
|
||||
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||
).toEqual(["Hello"])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not clear the terminal when resize replay snapshot fetch fails", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let calls = 0
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({
|
||||
stream: src.stream,
|
||||
messages: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
throw new Error("snapshot failed")
|
||||
},
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => Promise.resolve())
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).not.toHaveBeenCalled()
|
||||
expect(ui.commits).toEqual([])
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("disables resize replay for the session after terminal reset fails", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => Promise.reject(new Error("clear failed")))
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toContainEqual({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
let cleared = false
|
||||
const idle = ui.api.idle
|
||||
ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle())
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ stream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const reset = mock(() => {
|
||||
cleared = true
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||
expect(reset).toHaveBeenCalledTimes(1)
|
||||
expect(ui.commits).toContainEqual({
|
||||
kind: "error",
|
||||
text: "resize replay failed; disabled for this session",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("drops completed historical subagent tabs during bootstrap", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
|
||||
@@ -396,21 +396,14 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"format": {
|
||||
"anyOf": [
|
||||
{
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
{
|
||||
"type": "null",
|
||||
},
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in seconds (max 120)",
|
||||
|
||||
@@ -82,6 +82,13 @@ describe("tool parameters", () => {
|
||||
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not expose defaulted optional keys as nullable", () => {
|
||||
expect(toJsonSchema(WebFetch)).toMatchObject({
|
||||
properties: { format: { type: "string", enum: ["text", "markdown", "html"], default: "markdown" } },
|
||||
})
|
||||
expect(toJsonSchema(WebFetch).properties?.format).not.toHaveProperty("anyOf")
|
||||
})
|
||||
})
|
||||
|
||||
describe("apply_patch", () => {
|
||||
@@ -257,8 +264,15 @@ describe("tool parameters", () => {
|
||||
})
|
||||
|
||||
describe("webfetch", () => {
|
||||
test("accepts url-only", () => {
|
||||
expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com")
|
||||
test("defaults omitted format to markdown", () => {
|
||||
expect(parse(WebFetch, { url: "https://example.com" })).toEqual({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
})
|
||||
expect(parse(WebFetch, { url: "https://example.com", format: undefined })).toEqual({
|
||||
url: "https://example.com",
|
||||
format: "markdown",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.0",
|
||||
"@opentui/keymap": ">=0.3.0",
|
||||
"@opentui/solid": ">=0.3.0"
|
||||
"@opentui/core": ">=0.3.1",
|
||||
"@opentui/keymap": ">=0.3.1",
|
||||
"@opentui/solid": ">=0.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
[data-page="stats"] [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="content"] {
|
||||
color: var(--stats-text);
|
||||
font-family:
|
||||
@@ -1028,6 +1032,8 @@
|
||||
|
||||
[data-page="stats"] [data-component="top-models-chart"] {
|
||||
--top-models-bar-gap: 12px;
|
||||
--top-models-dot-size: 6px;
|
||||
--top-models-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 34px minmax(0, 1fr);
|
||||
@@ -1161,17 +1167,27 @@
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
position: absolute;
|
||||
inset: 0 calc(var(--top-models-bar-gap) / 2);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--top-models-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: center top;
|
||||
mask-position: var(--top-models-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: 6px 6px;
|
||||
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: center top;
|
||||
-webkit-mask-position: var(--top-models-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: 6px 6px;
|
||||
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
}
|
||||
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-stack"] {
|
||||
@@ -1706,15 +1722,30 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="leaderboard"],
|
||||
[data-page="stats"] [data-slot="leaderboard-featured"],
|
||||
[data-page="stats"] [data-slot="leaderboard-compact"],
|
||||
[data-page="stats"] [data-slot="leaderboard-column"],
|
||||
[data-page="stats"] [data-slot="leaderboard-mobile"] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="leaderboard"] {
|
||||
display: block;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
margin-top: 36px;
|
||||
scroll-margin-top: 88px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-featured"],
|
||||
[data-page="stats"] [data-slot="leaderboard-compact"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-pattern"] {
|
||||
scroll-margin-top: 88px;
|
||||
height: 16px;
|
||||
margin: 32px 0 16px;
|
||||
margin: 16px 0;
|
||||
overflow: hidden;
|
||||
background: var(--stats-hero-pattern);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
@@ -1727,30 +1758,15 @@
|
||||
-webkit-mask-size: 6px 6px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
[data-page="stats"] [data-slot="leaderboard-column"] {
|
||||
gap: 8px;
|
||||
width: calc(100% + 80px);
|
||||
margin-inline: -40px;
|
||||
padding: 4px 40px 8px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-padding-inline: 40px;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"]::-webkit-scrollbar {
|
||||
[data-page="stats"] [data-slot="leaderboard-mobile"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] {
|
||||
flex: 0 0 clamp(240px, 31vw, 360px);
|
||||
width: clamp(240px, 31vw, 360px);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="leader-card"] {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@@ -2598,14 +2614,6 @@
|
||||
[data-page="stats"] [data-section="session-cost"] {
|
||||
padding: 64px 32px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] {
|
||||
width: calc(100% + 64px);
|
||||
margin-inline: -32px;
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
scroll-padding-inline: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 58rem) {
|
||||
@@ -2649,9 +2657,9 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] {
|
||||
flex-basis: 280px;
|
||||
width: 280px;
|
||||
[data-page="stats"] [data-slot="leaderboard-featured"],
|
||||
[data-page="stats"] [data-slot="leaderboard-compact"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="leader-card"][data-size="featured"],
|
||||
@@ -2760,49 +2768,11 @@
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-featured"],
|
||||
[data-page="stats"] [data-slot="leaderboard-pattern"],
|
||||
[data-page="stats"] [data-slot="leaderboard-compact"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-pattern"] {
|
||||
margin: 24px 0 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] {
|
||||
gap: 12px;
|
||||
width: calc(100% + 48px);
|
||||
margin-inline: -24px;
|
||||
padding-right: 24px;
|
||||
padding-left: 24px;
|
||||
scroll-padding-inline: 24px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-component="leader-card"] {
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
min-height: 156px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-body"] {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-avatar"] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-scroll"] [data-slot="leader-watermark"] {
|
||||
right: -68px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="leaderboard-mobile"] {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
@@ -368,7 +368,7 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) {
|
||||
}
|
||||
|
||||
function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) {
|
||||
const [product, setProduct] = createSignal<UsageProduct>("All Users")
|
||||
const [product, setProduct] = createSignal<UsageProduct>("Go")
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [sheet, setSheet] = createSignal<"product" | "range">()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
@@ -409,7 +409,6 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
||||
onActiveModelChange={setActiveModel}
|
||||
/>
|
||||
</Show>
|
||||
<div id="leaderboard" data-slot="leaderboard-pattern" aria-hidden="true" />
|
||||
<Show
|
||||
when={leaderboard().length > 0}
|
||||
fallback={
|
||||
@@ -418,7 +417,7 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
||||
>
|
||||
<Leaderboard data={leaderboard()} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
</Show>
|
||||
<div data-slot="chart-footer">
|
||||
<div data-slot="chart-footer" hidden>
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
<div data-slot="top-models-mobile-controls">
|
||||
<MobileFilterButton
|
||||
@@ -613,6 +612,11 @@ function TopModelsChart(props: {
|
||||
role="img"
|
||||
aria-label="Stacked top model usage chart"
|
||||
style={{ "--top-models-count": props.data.length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
props.onActiveModelChange(undefined)
|
||||
}}
|
||||
>
|
||||
<div data-slot="top-models-axis" aria-hidden="true">
|
||||
<For each={props.data}>
|
||||
@@ -633,7 +637,14 @@ function TopModelsChart(props: {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="top-models-bars">
|
||||
<div
|
||||
data-slot="top-models-bars"
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
props.onActiveModelChange(undefined)
|
||||
}}
|
||||
>
|
||||
<For each={props.data}>
|
||||
{(day, dayIndex) => (
|
||||
<div
|
||||
@@ -649,14 +660,14 @@ function TopModelsChart(props: {
|
||||
setActiveIndex(dayIndex())
|
||||
props.onActiveModelChange(undefined)
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
onPointerEnter={(event) => {
|
||||
setActiveIndex(dayIndex())
|
||||
props.onActiveModelChange(undefined)
|
||||
if (isTopModelsBlankHover(event.currentTarget, event.clientY)) props.onActiveModelChange(undefined)
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
onPointerMove={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
setActiveIndex(undefined)
|
||||
props.onActiveModelChange(undefined)
|
||||
setActiveIndex(dayIndex())
|
||||
if (isTopModelsBlankHover(event.currentTarget, event.clientY)) props.onActiveModelChange(undefined)
|
||||
}}
|
||||
onClick={() => {
|
||||
setActiveIndex(dayIndex())
|
||||
@@ -757,6 +768,12 @@ function TopModelsChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function isTopModelsBlankHover(bar: HTMLElement, clientY: number) {
|
||||
const stack = bar.querySelector<HTMLElement>('[data-slot="top-models-stack"]')
|
||||
if (!stack) return true
|
||||
return clientY < stack.getBoundingClientRect().top - 6
|
||||
}
|
||||
|
||||
function getTopModelsBarHeight(total: number, max: number) {
|
||||
if (total <= 0) return 0
|
||||
return Math.max(2, Math.min(100, (total / max) * 100))
|
||||
@@ -848,13 +865,50 @@ function Leaderboard(props: {
|
||||
activeModel: string | undefined
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
const featured = createMemo(() => props.data.slice(0, 3))
|
||||
const columns = createMemo(() =>
|
||||
[0, 1, 2].map((index) => props.data.slice(3 + index * 5, 8 + index * 5)).filter((column) => column.length > 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="leaderboard" role="list" aria-label="Model token leaderboard">
|
||||
<div data-slot="leaderboard-scroll" aria-label="Scrollable model token leaderboard">
|
||||
<div id="leaderboard" data-component="leaderboard" role="list" aria-label="Model token leaderboard">
|
||||
<div data-slot="leaderboard-featured">
|
||||
<For each={featured()}>
|
||||
{(entry) => (
|
||||
<LeaderboardCard
|
||||
entry={entry}
|
||||
size="featured"
|
||||
active={props.activeModel === entry.model}
|
||||
onActiveModelChange={props.onActiveModelChange}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="leaderboard-pattern" aria-hidden="true" />
|
||||
<div data-slot="leaderboard-compact">
|
||||
<For each={columns()}>
|
||||
{(column) => (
|
||||
<div data-slot="leaderboard-column">
|
||||
<For each={column}>
|
||||
{(entry) => (
|
||||
<LeaderboardCard
|
||||
entry={entry}
|
||||
size="compact"
|
||||
active={props.activeModel === entry.model}
|
||||
onActiveModelChange={props.onActiveModelChange}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="leaderboard-mobile" aria-label="Scrollable model token leaderboard">
|
||||
<For each={props.data}>
|
||||
{(entry) => (
|
||||
<LeaderboardCard
|
||||
entry={entry}
|
||||
size="featured"
|
||||
active={props.activeModel === entry.model}
|
||||
onActiveModelChange={props.onActiveModelChange}
|
||||
/>
|
||||
@@ -867,13 +921,14 @@ function Leaderboard(props: {
|
||||
|
||||
function LeaderboardCard(props: {
|
||||
entry: LeaderboardEntry
|
||||
size: "featured" | "compact"
|
||||
active: boolean
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
data-component="leader-card"
|
||||
data-size="featured"
|
||||
data-size={props.size}
|
||||
data-active={props.active ? "true" : undefined}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
@@ -986,17 +1041,19 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
<span>[*]</span>
|
||||
<strong>{inspecting() ? formatMarketDate(activeDay()) : formatMarketRange(data())}</strong>
|
||||
</p>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={range()}
|
||||
label="Date range"
|
||||
variant="range"
|
||||
onSelect={(item) => {
|
||||
setRange(item)
|
||||
setActiveAuthor(undefined)
|
||||
setInspecting(false)
|
||||
}}
|
||||
/>
|
||||
<div hidden>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={range()}
|
||||
label="Date range"
|
||||
variant="range"
|
||||
onSelect={(item) => {
|
||||
setRange(item)
|
||||
setActiveAuthor(undefined)
|
||||
setInspecting(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -1215,7 +1272,7 @@ function marketDateParts(label: string) {
|
||||
}
|
||||
|
||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Zen")
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 13))
|
||||
@@ -1233,7 +1290,7 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
|
||||
>
|
||||
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer">
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
@@ -1313,7 +1370,7 @@ function MetricBar(props: { value: number; max: number; active: boolean }) {
|
||||
}
|
||||
|
||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Zen")
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 16))
|
||||
@@ -1334,7 +1391,7 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
>
|
||||
<SessionCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer">
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toModelAggregate } from "./inference"
|
||||
import { modelAuthor, normalizeInferenceModel } from "./model-normalization"
|
||||
import { toModelAggregate, toProviderAggregate } from "./inference"
|
||||
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
|
||||
|
||||
describe("inference stat normalization", () => {
|
||||
test("normalizes model suffixes used by router/provider variants", () => {
|
||||
@@ -12,7 +12,7 @@ describe("inference stat normalization", () => {
|
||||
})
|
||||
|
||||
test("maps normalized model ids to public authors", () => {
|
||||
expect(modelAuthor("big-pickle")).toBe("opencode")
|
||||
expect(modelAuthor("big-pickle")).toBe("unknown")
|
||||
expect(modelAuthor("claude-sonnet-4-5")).toBe("anthropic")
|
||||
expect(modelAuthor("deepseek-v4-pro")).toBe("deepseek")
|
||||
expect(modelAuthor("gemini-3.5-flash")).toBe("google")
|
||||
@@ -28,7 +28,15 @@ describe("inference stat normalization", () => {
|
||||
expect(modelAuthor("alpha-gpt-next")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("model aggregates ignore datalake provider and use normalized author/model", () => {
|
||||
test("uses provider.model to resolve opencode route providers", () => {
|
||||
expect(statModel("big-pickle", "claude-sonnet-4-5")).toBe("claude-sonnet-4-5")
|
||||
expect(statModel("big-pickle", "gpt-5-free")).toBe("gpt-5")
|
||||
expect(statProvider("big-pickle", "claude-sonnet-4-5", "opencode")).toBe("anthropic")
|
||||
expect(statProvider("big-pickle", "gpt-5", "opencode")).toBe("openai")
|
||||
expect(statProvider("big-pickle", "", "opencode")).toBe("unknown")
|
||||
})
|
||||
|
||||
test("model aggregates prefer provider.model and use normalized model", () => {
|
||||
expect(toModelAggregate(aggregate("alpha-gpt-next", "openai"))).toEqual([])
|
||||
|
||||
expect(toModelAggregate(aggregate("deepseek-v4-flash-free", "not-public-provider"))).toMatchObject([
|
||||
@@ -38,6 +46,23 @@ describe("inference stat normalization", () => {
|
||||
model: "deepseek-v4-flash",
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
toModelAggregate({ ...aggregate("big-pickle", "opencode"), provider_model: "claude-sonnet-4-5" }),
|
||||
).toMatchObject([
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
provider_model: "claude-sonnet-4-5",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("provider aggregates never keep opencode as the provider", () => {
|
||||
expect(toProviderAggregate({ ...aggregate("big-pickle", "opencode"), provider_model: "gpt-5" })).toMatchObject([
|
||||
{ provider: "openai" },
|
||||
])
|
||||
expect(toProviderAggregate(aggregate("big-pickle", "opencode"))).toMatchObject([{ provider: "unknown" }])
|
||||
})
|
||||
|
||||
test("model aggregates use ISO week period keys", () => {
|
||||
|
||||
@@ -2,13 +2,7 @@ import { Resource } from "sst/resource"
|
||||
import type { AthenaData } from "../athena"
|
||||
import type { GeoStatAggregate } from "./geo"
|
||||
import type { ModelStatAggregate } from "./model"
|
||||
import {
|
||||
EXCLUDED_MODELS,
|
||||
MODEL_AUTHOR_OVERRIDES,
|
||||
MODEL_AUTHOR_RULES,
|
||||
modelAuthor,
|
||||
normalizeInferenceModel,
|
||||
} from "./model-normalization"
|
||||
import { EXCLUDED_MODELS, MODEL_AUTHOR_RULES, statModel, statProvider } from "./model-normalization"
|
||||
import type { ProviderStatAggregate } from "./provider"
|
||||
import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
|
||||
|
||||
@@ -64,7 +58,7 @@ WITH normalized AS (
|
||||
SELECT
|
||||
from_iso8601_timestamp(event_timestamp) AS event_time,
|
||||
model AS raw_model,
|
||||
COALESCE(NULLIF(regexp_replace(model, '(-free|:global)+$', ''), ''), 'unknown') AS model,
|
||||
${statModelSql("model", "provider_model")} AS model,
|
||||
COALESCE(NULLIF(provider_model, ''), '') AS provider_model,
|
||||
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
|
||||
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
|
||||
@@ -98,10 +92,10 @@ WITH normalized AS (
|
||||
event_time,
|
||||
CASE
|
||||
WHEN source = 'lite' THEN 'Go'
|
||||
WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free'
|
||||
WHEN raw_model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free'
|
||||
ELSE 'Paid'
|
||||
END AS tier,
|
||||
${modelAuthorSql("model")} AS provider,
|
||||
${statProviderSql("model", "provider_model")} AS provider,
|
||||
provider_model,
|
||||
model,
|
||||
country,
|
||||
@@ -157,25 +151,27 @@ ORDER BY grain, period_key, total_tokens DESC
|
||||
}
|
||||
|
||||
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
|
||||
const model = normalizeInferenceModel(data.model)
|
||||
const author = modelAuthor(model)
|
||||
if (!author) return []
|
||||
const model = statModel(data.model, data.provider_model)
|
||||
const provider = statProvider(model, data.provider_model, data.provider)
|
||||
if (!provider) return []
|
||||
|
||||
return toStatBaseAggregate(data).flatMap((base) => [
|
||||
{ ...base, provider: author, model, provider_model: data.provider_model || "" },
|
||||
{ ...base, provider, model, provider_model: data.provider_model || "" },
|
||||
])
|
||||
}
|
||||
|
||||
export function toProviderAggregate(data: AthenaData): ProviderStatAggregate[] {
|
||||
return toStatBaseAggregate(data).flatMap((base) => [{ ...base, provider: data.provider || "unknown" }])
|
||||
return toStatBaseAggregate(data).flatMap((base) => [
|
||||
{ ...base, provider: statProvider(data.model, data.provider_model, data.provider) || "unknown" },
|
||||
])
|
||||
}
|
||||
|
||||
export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
|
||||
return toStatBaseAggregate(data).flatMap((base) => [
|
||||
{
|
||||
...base,
|
||||
provider: data.provider || "all",
|
||||
model: normalizeInferenceModel(data.model || "all"),
|
||||
provider: statProvider(data.model, data.provider_model, data.provider) || "all",
|
||||
model: statModel(data.model || "all", data.provider_model),
|
||||
country: normalizeCountry(data.country),
|
||||
continent: data.continent || "",
|
||||
},
|
||||
@@ -243,9 +239,16 @@ function sqlString(value: string) {
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
function modelAuthorSql(model: string) {
|
||||
function statModelSql(model: string, providerModel: string) {
|
||||
return `COALESCE(NULLIF(regexp_replace(CASE
|
||||
WHEN ${model} = 'big-pickle' THEN COALESCE(NULLIF(${providerModel}, ''), ${model})
|
||||
ELSE ${model}
|
||||
END, '(-free|:global)+$', ''), ''), 'unknown')`
|
||||
}
|
||||
|
||||
function statProviderSql(model: string, providerModel: string) {
|
||||
return `CASE
|
||||
${MODEL_AUTHOR_OVERRIDES.map((item) => ` WHEN lower(${model}) = ${sqlString(item.model)} THEN ${sqlString(item.author)}`).join("\n")}
|
||||
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${providerModel}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
|
||||
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
|
||||
ELSE 'unknown'
|
||||
END`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const MODEL_AUTHOR_OVERRIDES = [{ model: "big-pickle", author: "opencode" }] as const
|
||||
export const MODEL_AUTHOR_RULES = [
|
||||
{ match: "claude", author: "anthropic" },
|
||||
{ match: "gemini", author: "google" },
|
||||
@@ -23,8 +22,26 @@ export function modelAuthor(value: string | undefined) {
|
||||
const model = normalizeInferenceModel(value).toLowerCase()
|
||||
if (EXCLUDED_MODELS.has(model)) return undefined
|
||||
|
||||
const override = MODEL_AUTHOR_OVERRIDES.find((item) => item.model === model)
|
||||
if (override) return override.author
|
||||
|
||||
return MODEL_AUTHOR_RULES.find((item) => model.includes(item.match))?.author ?? "unknown"
|
||||
}
|
||||
|
||||
export function statModel(model: string | undefined, providerModel: string | undefined) {
|
||||
const normalized = normalizeInferenceModel(model)
|
||||
if (normalized.toLowerCase() === "big-pickle") return normalizeInferenceModel(providerModel)
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function statProvider(
|
||||
model: string | undefined,
|
||||
providerModel: string | undefined,
|
||||
provider: string | undefined,
|
||||
) {
|
||||
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
|
||||
if (!modelAuthorValue) return undefined
|
||||
|
||||
const providerModelAuthor = modelAuthor(providerModel)
|
||||
if (providerModelAuthor && providerModelAuthor !== "unknown") return providerModelAuthor
|
||||
if (modelAuthorValue !== "unknown") return modelAuthorValue
|
||||
if (provider && provider.toLowerCase() !== "opencode") return provider
|
||||
return modelAuthorValue
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { geoStat, modelStat, providerStat } from "./database/schema"
|
||||
import { modelAuthor, normalizeInferenceModel } from "./domain/model-normalization"
|
||||
import { statModel, statProvider } from "./domain/model-normalization"
|
||||
import {
|
||||
chunks,
|
||||
collapseRows,
|
||||
@@ -190,28 +190,36 @@ function buildQueries(limit: number, tiers: string[]): QuerySpec[] {
|
||||
querySpec(
|
||||
"model-day",
|
||||
tier,
|
||||
metricQuery(["date", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)),
|
||||
metricQuery(["date", "tier", "stat_provider_2", "stat_model_2"], limit, tierFilters(tier)),
|
||||
),
|
||||
querySpec("provider-day", tier, metricQuery(["date", "tier", "stat_provider"], limit, tierFilters(tier))),
|
||||
querySpec("provider-day", tier, metricQuery(["date", "tier", "stat_provider_2"], limit, tierFilters(tier))),
|
||||
querySpec("geo-day", tier, metricQuery(["date", "tier", "country", "continent"], limit, tierFilters(tier))),
|
||||
querySpec(
|
||||
"geo-model-day",
|
||||
tier,
|
||||
metricQuery(["date", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)),
|
||||
metricQuery(
|
||||
["date", "tier", "stat_provider_2", "stat_model_2", "country", "continent"],
|
||||
limit,
|
||||
tierFilters(tier),
|
||||
),
|
||||
),
|
||||
])
|
||||
const weekly = tiers.flatMap((tier) => [
|
||||
querySpec(
|
||||
"model-week",
|
||||
tier,
|
||||
metricQuery(["week", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)),
|
||||
metricQuery(["week", "tier", "stat_provider_2", "stat_model_2"], limit, tierFilters(tier)),
|
||||
),
|
||||
querySpec("provider-week", tier, metricQuery(["week", "tier", "stat_provider"], limit, tierFilters(tier))),
|
||||
querySpec("provider-week", tier, metricQuery(["week", "tier", "stat_provider_2"], limit, tierFilters(tier))),
|
||||
querySpec("geo-week", tier, metricQuery(["week", "tier", "country", "continent"], limit, tierFilters(tier))),
|
||||
querySpec(
|
||||
"geo-model-week",
|
||||
tier,
|
||||
metricQuery(["week", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)),
|
||||
metricQuery(
|
||||
["week", "tier", "stat_provider_2", "stat_model_2", "country", "continent"],
|
||||
limit,
|
||||
tierFilters(tier),
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
@@ -345,12 +353,23 @@ function classifyRows(file: string, rows: RawRow[]): ImportKey {
|
||||
const headers = new Set(rows.flatMap((row) => Object.keys(row).map(normalizeHeader)))
|
||||
const grain: Grain = headers.has("date") ? "day" : "week"
|
||||
if (hasHeader(headers, ["country", "cf.country"])) {
|
||||
if (hasHeader(headers, ["model", "stat_model"]) && hasMetricHeaders(headers)) return `geo-model-${grain}`
|
||||
if (hasHeader(headers, ["model", "stat_model", "stat_model_2"]) && hasMetricHeaders(headers))
|
||||
return `geo-model-${grain}`
|
||||
return hasMetricHeaders(headers) ? `geo-${grain}` : `geo-continent-${grain}`
|
||||
}
|
||||
if (hasHeader(headers, ["model", "stat_model"]))
|
||||
if (hasHeader(headers, ["model", "stat_model", "stat_model_2"]))
|
||||
return hasMetricHeaders(headers) ? `model-${grain}` : `model-provider-model-${grain}`
|
||||
if (hasHeader(headers, ["provider", "provider.normalized", "stat_provider"])) return `provider-${grain}`
|
||||
if (
|
||||
hasHeader(headers, [
|
||||
"provider",
|
||||
"provider.normalized",
|
||||
"stat_provider",
|
||||
"stat_provider_2",
|
||||
"provider.model",
|
||||
"provider_model",
|
||||
])
|
||||
)
|
||||
return `provider-${grain}`
|
||||
fail(`Cannot classify export from columns in ${file}`)
|
||||
}
|
||||
|
||||
@@ -588,11 +607,11 @@ function deriveTier(row: RawRow) {
|
||||
}
|
||||
|
||||
function provider(row: RawRow) {
|
||||
return cell(row, ["stat_provider"]) || modelAuthor(model(row))
|
||||
return statProvider(model(row), providerModel(row), cell(row, ["stat_provider_2", "stat_provider"]))
|
||||
}
|
||||
|
||||
function model(row: RawRow) {
|
||||
return normalizeInferenceModel(cell(row, ["stat_model"]) || rawModel(row))
|
||||
return statModel(cell(row, ["stat_model_2", "stat_model"]) || rawModel(row), providerModel(row))
|
||||
}
|
||||
|
||||
function rawModel(row: RawRow) {
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"./icons/app": "./src/components/app-icons/types.ts",
|
||||
"./fonts/*": "./src/assets/fonts/*",
|
||||
"./audio/*": "./src/assets/audio/*",
|
||||
"./v2/*": "./src/v2/*"
|
||||
"./v2/*": "./src/v2/components/*.tsx",
|
||||
"./v2/styles/*": "./src/v2/styles/*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -47,8 +48,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
height: 24px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-value"] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2.5px;
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"]
|
||||
[data-slot="select-select-trigger"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"]
|
||||
[data-slot="select-select-trigger"]:is(:active, [data-state="pressed"]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-icon"] {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="select"][data-trigger-style="settings-v2"]
|
||||
[data-slot="select-select-trigger-icon"]
|
||||
[data-slot="icon-svg"] {
|
||||
margin-inline: -5px;
|
||||
color: #3a3a3a;
|
||||
}
|
||||
|
||||
[data-component="select-content"][data-trigger-style="settings-v2"] {
|
||||
min-width: 160px;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="select-select-content-list"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
[data-slot="select-select-item"] {
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// @ts-nocheck
|
||||
import * as mod from "./select-v2"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const story = create({
|
||||
title: "UI/SelectV2",
|
||||
mod,
|
||||
args: {
|
||||
options: ["One", "Two", "Three"],
|
||||
current: "One",
|
||||
placeholder: "Choose...",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/SelectV2",
|
||||
id: "components-select-v2",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Select as Kobalte } from "@kobalte/core/select"
|
||||
import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { Icon as IconV2 } from "../v2/components/icon"
|
||||
import { Icon } from "./icon"
|
||||
import "./select-v2.css"
|
||||
|
||||
export type SelectV2Props<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
|
||||
placeholder?: string
|
||||
options: T[]
|
||||
current?: T
|
||||
value?: (x: T) => string
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
valueClass?: ComponentProps<"div">["class"]
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onHighlight?: (value: T | undefined) => (() => void) | void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children?: (item: T | undefined) => JSX.Element
|
||||
triggerStyle?: JSX.CSSProperties
|
||||
triggerProps?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
export function SelectV2<T>(props: SelectV2Props<T> & { disabled?: boolean }) {
|
||||
const [local, others] = splitProps(props, [
|
||||
"class",
|
||||
"classList",
|
||||
"placeholder",
|
||||
"options",
|
||||
"current",
|
||||
"value",
|
||||
"label",
|
||||
"groupBy",
|
||||
"valueClass",
|
||||
"onSelect",
|
||||
"onHighlight",
|
||||
"onOpenChange",
|
||||
"children",
|
||||
"triggerStyle",
|
||||
"triggerProps",
|
||||
])
|
||||
|
||||
const state = {
|
||||
key: undefined as string | undefined,
|
||||
cleanup: undefined as (() => void) | void,
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
state.key = undefined
|
||||
}
|
||||
|
||||
const keyFor = (item: T) => (local.value ? local.value(item) : (item as string))
|
||||
|
||||
const move = (item: T | undefined) => {
|
||||
if (!local.onHighlight) return
|
||||
if (!item) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const key = keyFor(item)
|
||||
if (state.key === key) return
|
||||
state.cleanup?.()
|
||||
state.cleanup = local.onHighlight(item)
|
||||
state.key = key
|
||||
}
|
||||
|
||||
onCleanup(stop)
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
local.options,
|
||||
groupBy((x) => (local.groupBy ? local.groupBy(x) : "")),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, options: v })),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Kobalte<T, { category: string; options: T[] }>
|
||||
{...others}
|
||||
data-component="select"
|
||||
data-trigger-style="settings-v2"
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
value={local.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
|
||||
optionTextValue={(x) => (local.label ? local.label(x) : (x as string))}
|
||||
optionGroupChildren="options"
|
||||
placeholder={local.placeholder}
|
||||
sectionComponent={(local) => (
|
||||
<Kobalte.Section data-slot="select-section">{local.section.rawValue.category}</Kobalte.Section>
|
||||
)}
|
||||
itemComponent={(itemProps) => (
|
||||
<Kobalte.Item
|
||||
{...itemProps}
|
||||
data-slot="select-select-item"
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
onPointerEnter={() => move(itemProps.item.rawValue)}
|
||||
onPointerMove={() => move(itemProps.item.rawValue)}
|
||||
onFocus={() => move(itemProps.item.rawValue)}
|
||||
>
|
||||
<Kobalte.ItemLabel data-slot="select-select-item-label">
|
||||
{local.children
|
||||
? local.children(itemProps.item.rawValue)
|
||||
: local.label
|
||||
? local.label(itemProps.item.rawValue)
|
||||
: (itemProps.item.rawValue as string)}
|
||||
</Kobalte.ItemLabel>
|
||||
<Kobalte.ItemIndicator data-slot="select-select-item-indicator">
|
||||
<Icon name="check-small" size="small" />
|
||||
</Kobalte.ItemIndicator>
|
||||
</Kobalte.Item>
|
||||
)}
|
||||
onChange={(v) => {
|
||||
local.onSelect?.(v ?? undefined)
|
||||
stop()
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
local.onOpenChange?.(open)
|
||||
if (!open) stop()
|
||||
}}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
{...local.triggerProps}
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
data-slot="select-select-trigger"
|
||||
as="button"
|
||||
style={local.triggerStyle}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<Kobalte.Value<T> data-slot="select-select-trigger-value" class={local.valueClass}>
|
||||
{(state) => {
|
||||
const selected = state.selectedOption() ?? local.current
|
||||
if (!selected) return local.placeholder || ""
|
||||
if (local.label) return local.label(selected)
|
||||
return selected as string
|
||||
}}
|
||||
</Kobalte.Value>
|
||||
<Kobalte.Icon data-slot="select-select-trigger-icon">
|
||||
<IconV2 name="chevron-down" class="-mx-[5px]" />
|
||||
</Kobalte.Icon>
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
data-component="select-content"
|
||||
data-trigger-style="settings-v2"
|
||||
>
|
||||
<Kobalte.Listbox data-slot="select-select-content-list" />
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
box-shadow: 0 0 0 0.5px var(--accordion-v2-border);
|
||||
overflow: hidden;
|
||||
|
||||
font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif;
|
||||
color: var(--accordion-v2-fg);
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -59,7 +58,6 @@
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 100%;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
height: 28px;
|
||||
border-radius: var(--avatar-radius);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-weight: 530;
|
||||
font-size: var(--avatar-font-size);
|
||||
line-height: 1;
|
||||
|
||||
@@ -10,19 +10,18 @@
|
||||
user-select: none;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 0.5px solid var(--border-border-base);
|
||||
background: var(--background-bg-layer-02);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
color: var(--text-text-muted);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-component="tag"][data-high-contrast] {
|
||||
border-color: var(--border-border-strong);
|
||||
border-color: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
[data-slot="basic-tool-v2-trigger"] {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
@@ -145,3 +144,26 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Ghost muted */
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] {
|
||||
background-color: transparent;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] [data-slot="icon-svg"] {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const docs = `### Overview
|
||||
Button v2 with three visual variants and two sizes.
|
||||
|
||||
### API
|
||||
- \`variant\`: "neutral" | "contrast" | "ghost".
|
||||
- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted".
|
||||
- \`size\`: "normal" | "large".
|
||||
- \`icon\`: Optional icon name.
|
||||
- Inherits Kobalte Button props and native button attributes.
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["neutral", "contrast", "ghost"],
|
||||
options: ["neutral", "contrast", "ghost", "ghost-muted"],
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
@@ -63,6 +63,9 @@ export const Variants = {
|
||||
<ButtonV2 variant="neutral">Neutral</ButtonV2>
|
||||
<ButtonV2 variant="contrast">Contrast</ButtonV2>
|
||||
<ButtonV2 variant="ghost">Ghost</ButtonV2>
|
||||
<ButtonV2 variant="ghost-muted" icon="edit">
|
||||
Ghost muted
|
||||
</ButtonV2>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -112,7 +115,7 @@ export const Icon = {
|
||||
|
||||
export const AllStates = {
|
||||
render: () => {
|
||||
const variants = ["neutral", "contrast", "ghost"] as const
|
||||
const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const
|
||||
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
|
||||
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ButtonV2Props
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "neutral" | "contrast" | "ghost"
|
||||
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ButtonV2(props: ButtonV2Props) {
|
||||
}}
|
||||
>
|
||||
<Show when={resolvedIcon()}>
|
||||
<Icon name={resolvedIcon()!} size="small" />
|
||||
<Icon name={resolvedIcon()!} />
|
||||
</Show>
|
||||
{props.children}
|
||||
</Kobalte>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
[data-slot="checkbox-v2-error"] {
|
||||
color: var(--state-fg-danger);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-normal);
|
||||
@@ -90,7 +89,6 @@
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
color: inherit;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -109,7 +107,6 @@
|
||||
|
||||
[data-slot="checkbox-v2-description"] {
|
||||
color: var(--text-text-muted);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: var(--overlay-simple-overlay-scrim);
|
||||
background-color: var(--v2-overlay-simple-overlay-scrim);
|
||||
}
|
||||
|
||||
[data-component="dialog"] {
|
||||
@@ -22,8 +22,8 @@
|
||||
align-items: flex-start;
|
||||
width: 480px;
|
||||
height: 368px;
|
||||
background: var(--background-bg-layer-01);
|
||||
box-shadow: var(--elevation-overlay);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
border-radius: 6px;
|
||||
overflow: visible;
|
||||
pointer-events: auto;
|
||||
@@ -64,22 +64,20 @@
|
||||
|
||||
[data-slot="dialog-title"] {
|
||||
margin: 0;
|
||||
font-family: "Inter", var(--v2-font-family-sans);
|
||||
font-weight: 530;
|
||||
font-size: 15px;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.13px;
|
||||
color: var(--text-text-base);
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-slot="dialog-description"] {
|
||||
font-family: "Inter", var(--v2-font-family-sans);
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--text-text-muted);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
@@ -94,7 +92,7 @@
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--overlay-simple-overlay-hover);
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
[data-slot="diff-changes-additions"],
|
||||
[data-slot="diff-changes-deletions"] {
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
@@ -76,7 +75,6 @@
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
min-height: 11px;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -2,20 +2,20 @@ import { onMount, type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
const icons = {
|
||||
edit: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5555 8.21534V13.5556H2.44434L2.44434 2.4445H7.78462M6.88878 9.11119C6.88878 9.11119 8.96327 9.0367 9.69678 8.3032L14.0301 3.96986C14.5824 3.4176 14.5824 2.52213 14.0301 1.96986C13.4778 1.4176 12.5824 1.4176 12.0301 1.96986L7.69678 6.3032C7.00513 6.99484 6.88878 9.11119 6.88878 9.11119Z" stroke="currentColor"/>`,
|
||||
},
|
||||
"folder-add-left": {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M7.5 13.3333H1.5V2H6.83333L8.83333 4H14.8333V6M10.1667 11.3333H15.5M12.8333 8.66667V14" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
},
|
||||
"grid-plus": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
|
||||
},
|
||||
help: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M6.33345 6.33349V5.00015H9.66679V7.00015L8.00015 8.00015V9.66679M8.27485 11.6819H7.71897M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"sidebar-right": {
|
||||
viewBox: "0 0 20 20",
|
||||
@@ -31,7 +31,7 @@ const icons = {
|
||||
},
|
||||
"magnifying-glass": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
|
||||
body: `<path d="M14 14L10.3454 10.3454M6.88889 11.7778C9.58889 11.7778 11.7778 9.58889 11.7778 6.88889C11.7778 4.18889 9.58889 2 6.88889 2C4.18889 2 2 4.18889 2 6.88889C2 9.58889 4.18889 11.7778 6.88889 11.7778Z" stroke="currentColor"/>`,
|
||||
},
|
||||
menu: {
|
||||
viewBox: "0 0 16 16",
|
||||
@@ -42,8 +42,16 @@ const icons = {
|
||||
body: `<path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round"/><path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
},
|
||||
"settings-gear": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M7.99998 1.3335L14 4.66683V11.3335L7.99998 14.6668L2 11.3335V4.66683L7.99998 1.3335Z" stroke="currentColor"/><path d="M9.99998 8.00016C9.99998 9.10476 9.10458 10.0002 7.99998 10.0002C6.89538 10.0002 5.99998 9.10476 5.99998 8.00016C5.99998 6.89556 6.89538 6.00016 7.99998 6.00016C9.10458 6.00016 9.99998 6.89556 9.99998 8.00016Z" stroke="currentColor"/>`,
|
||||
},
|
||||
"chevron-down": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
|
||||
},
|
||||
close: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
|
||||
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
},
|
||||
"xmark-small": {
|
||||
viewBox: "0 0 16 16",
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
@@ -138,7 +137,6 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user