Compare commits

..

19 Commits

Author SHA1 Message Date
Aiden Cline 5c6356f5a6 fix(tui): keep configured model and agent when navigating sessions
Navigating between sessions adopted the session's last-used model and agent, which overrode an explicitly configured model/default_agent (or --model/--agent). Skip the auto-adoption when an explicit selection exists so the user's configured choice sticks.
2026-06-01 16:06:16 -05:00
Aiden Cline 10252bab83 wip 2026-06-01 15:39:58 -05:00
Aiden Cline 7d55ba4581 wip 2026-06-01 14:36:06 -05:00
opencode-agent[bot] 32d5058f8c chore: generate 2026-06-01 19:10:46 +00:00
Aarav Sareen 363d6d1a26 feat(app): v2 desktop UI improvements (#29689)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-01 19:09:09 +00:00
Adam 2bf85b8479 fix(stats): align big-pickle provider resolution (#30274) 2026-06-01 14:09:07 -05:00
Adam 1813256d8e fix(stats): stabilize top models hover 2026-06-01 12:52:11 -05:00
Adam d7ba8b1125 fix(stats): center top models dot grid 2026-06-01 12:45:58 -05:00
Adam 44350bced9 fix(stats): restore leaderboard spacing 2026-06-01 11:48:59 -05:00
opencode-agent[bot] 913c4ef0b1 chore: update nix node_modules hashes 2026-06-01 13:34:43 +00:00
opencode-agent[bot] 10095bfad4 chore: generate 2026-06-01 13:13:19 +00:00
Simon Klee 6a3b2f339a add run --replay mode (#30239) 2026-06-01 15:11:45 +02:00
Shoubhit Dash d85f8cd4d8 fix(core): contain lsp warmup defects (#30226) 2026-06-01 16:08:23 +05:30
opencode-agent[bot] 68676f2c5c chore: generate 2026-06-01 08:47:26 +00:00
Shoubhit Dash 2d2f587bfa fix(opencode): avoid nullable webfetch format schema (#30215) 2026-06-01 14:15:57 +05:30
Orca丶 bba76009a8 fix(tui): prevent prompt corruption when pasting near wide characters (#29710)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-06-01 08:42:14 +00:00
smagnuso 50b4ad89b3 fix(acp): honor session/cancel by aborting the running turn (#30145)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-01 14:08:28 +05:30
opencode-agent[bot] fd2278eefc chore: generate 2026-06-01 07:27:27 +00:00
Simon Klee 7ccb7889af opencode(run): add queued prompt management (#30103)
Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.
2026-06-01 09:26:09 +02:00
120 changed files with 5850 additions and 959 deletions
+19 -39
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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>
+176 -71
View File
@@ -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,
}}
>
&quot;{store.filter}&quot;
</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">&quot;{list.filter()}&quot;</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"
+1 -1
View File
@@ -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"
+64 -34
View File
@@ -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 -1
View File
@@ -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"
+13
View File
@@ -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 -1
View File
@@ -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"
+2
View File
@@ -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",
+2
View File
@@ -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 -1
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -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 }
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+34
View File
@@ -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} />
}
+3 -3
View File
@@ -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",
+20 -13
View File
@@ -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.
+1 -1
View File
@@ -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,
}
}
+69 -12
View File
@@ -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(() => {})
}
+77 -4
View File
@@ -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",
+2 -1
View File
@@ -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* (
+1 -1
View File
@@ -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)",
+16 -2
View File
@@ -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",
})
})
})
+3 -3
View File
@@ -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": {
+46 -76
View File
@@ -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;
+84 -27
View File
@@ -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", () => {
+22 -19
View File
@@ -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
}
+31 -12
View File
@@ -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) {
+3 -2
View File
@@ -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",
+87
View File
@@ -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
+171
View File
@@ -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;
+4 -5
View File
@@ -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"] {
+23 -1
View File
@@ -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 (
+2 -2
View File
@@ -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;
+6 -8
View File
@@ -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;
+16 -8
View File
@@ -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