Compare commits

...

51 Commits

Author SHA1 Message Date
opencode-agent[bot] 8378856a90 refactor(console): simplify provider error parsing 2026-06-02 13:54:04 +00:00
opencode-agent[bot] 1e1dc56a09 fix(console): parse provider SSE error bodies 2026-06-02 12:48:31 +00:00
opencode-agent[bot] a78adb1b09 chore: update nix node_modules hashes 2026-06-02 11:44:02 +00:00
Simon Klee cd0fd9941d tui: revert OpenTUI upgrade to 0.2.16 (#30383) 2026-06-02 13:26:54 +02:00
Simon Klee 8936914e9d tui: show model context in run footer (#30380) 2026-06-02 13:09:03 +02:00
Brendan Allan d5a0ddb520 fix(app): tab title truncation and close button positioning (#30349) 2026-06-02 08:13:55 +00:00
Brendan Allan 787f170682 feat(app): inset new layout session panels (#30342) 2026-06-02 07:43:08 +00:00
Frank 8af7371c13 sync 2026-06-02 03:37:30 -04:00
Frank 90d85636e0 infra: stats 2026-06-02 02:41:33 -04:00
opencode-agent[bot] 6a5ef7f2ea chore: generate 2026-06-02 05:56:05 +00:00
Dax Raad 5937e606df feat(opencode): add filesystem read and list routes 2026-06-02 01:54:27 -04:00
opencode-agent[bot] 0136f03fa9 chore: generate 2026-06-02 05:40:53 +00:00
Dax Raad 4df3c98c6b feat(core): add dummy location filesystem layer 2026-06-02 01:39:04 -04:00
Dax Raad 98b7230c2a feat(core): add location filesystem contract 2026-06-02 01:39:04 -04:00
Dax Raad 8d03f6c5be refactor(core): simplify session pagination 2026-06-02 01:39:04 -04:00
Aiden Cline 1b85d55a0e fix(opencode): preserve websocket api errors (#30321) 2026-06-01 23:23:51 -05:00
opencode-agent[bot] 497ff36e91 chore: generate 2026-06-02 04:14:16 +00:00
Dax Raad c1c02f80dd feat(core): expose session location 2026-06-02 00:12:42 -04:00
opencode-agent[bot] bbec00fbd5 chore: generate 2026-06-02 03:28:04 +00:00
Aiden Cline 4668db8fa2 fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) 2026-06-01 22:20:44 -05:00
Luke Parker ab69f41067 fix(app): avoid suspending on pending child path (#30314) 2026-06-02 03:19:56 +00:00
opencode-agent[bot] 687c66248c chore: update nix node_modules hashes 2026-06-02 02:54:52 +00:00
Luke Parker 61b5046f88 fix: export v2 stylesheets and declare core node types (#30312) 2026-06-02 12:37:50 +10:00
Luke Parker 4e70eabbcc fix(app): restore deferred MCP status updates (#30220) 2026-06-02 12:11:11 +10:00
Kit Langton 1cae8f89c5 fix(tui): preserve live parts during session hydration (#30300) 2026-06-01 21:40:29 -04:00
opencode-agent[bot] 8dc2ffd48f chore: generate 2026-06-02 01:34:11 +00:00
Dax 9b815bcbd2 feat(core): add location-based permission service (#30287) 2026-06-02 01:32:50 +00:00
opencode-agent[bot] acd620f411 chore: generate 2026-06-02 00:56:03 +00:00
Luke Parker f0c7febb02 fix(opencode): enforce storage path invariants (#29666) 2026-06-02 00:54:41 +00:00
opencode-agent[bot] a821029258 chore: generate 2026-06-01 21:42:43 +00:00
Kit Langton 57d602d07a fix(tui): keep retry attempt before message (#30275) 2026-06-01 21:41:21 +00:00
Kit Langton 6072a68d6b fix(tui): keep background marker with subagent label (#30271) 2026-06-01 15:56:15 -04:00
Aiden Cline ae92f3158f feat(core): update Copilot for token-based billing (#30181) 2026-06-01 14:55:23 -05:00
James Long fa23fb5d38 fix(tui): handle events across workspaces (#30281) 2026-06-01 19:47:37 +00:00
Kit Langton 5f2b4eab28 fix(tui): clarify inline subagent rows (#30051) 2026-06-01 15:44:11 -04: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
232 changed files with 15603 additions and 2219 deletions
+2
View File
@@ -0,0 +1,2 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+16 -15
View File
@@ -290,6 +290,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
@@ -611,9 +612,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.3.0",
"@opentui/keymap": ">=0.3.0",
"@opentui/solid": ">=0.3.0",
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16",
},
"optionalPeers": [
"@opentui/core",
@@ -862,9 +863,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.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1758,23 +1759,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.2.16", "", { "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.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
"@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.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
"@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.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
"@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.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
"@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.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
"@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.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
"@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.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
"@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.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
"@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.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "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-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+37
View File
@@ -268,6 +268,7 @@ export const lakeIngest = new sst.Linkable("LakeIngest", {
secret: ingestSecret.result,
},
})
export const lakeIngestSecret = new sst.Secret("LakeIngestSecret", ingestSecret.result)
export const lakeQueryPermissions = [
{
@@ -320,3 +321,39 @@ export const lakeQueryPermissions = [
resources: ["*"],
},
]
////////////////
// S3 Tables
////////////////
const modelsNamespace = new aws.s3tables.Namespace("LakeModelsNamespace", {
namespace: "models",
tableBucketArn: tableBucket.arn,
})
new aws.s3tables.Table(
"LakeModelsEventTable",
{
name: "hit",
namespace: modelsNamespace.namespace,
tableBucketArn: modelsNamespace.tableBucketArn,
format: "ICEBERG",
metadata: {
iceberg: {
schema: {
fields: [
{ name: "event_timestamp", type: "string", required: false },
{ name: "event_date", type: "string", required: false },
{ name: "event_type", type: "string", required: false },
{ name: "country", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "ip", type: "string", required: false },
{ name: "ip_prefix", type: "string", required: false },
{ name: "path", type: "string", required: false },
],
},
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
)
+3 -8
View File
@@ -1,11 +1,6 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
import { EMAILOCTOPUS_API_KEY } from "./app"
const domain = (() => {
if ($app.stage === "production") return "stats.opencode.ai"
if ($app.stage === "dev") return "stats.dev.opencode.ai"
return `stats.${$app.stage}.dev.opencode.ai`
})()
import { domain } from "./stage"
////////////////
// LAKE
@@ -167,10 +162,10 @@ new sst.x.DevCommand("StatsStudio", {
export const app = new sst.cloudflare.x.SolidStart("Stats", {
path: "packages/stats/app",
buildCommand: "bun run build",
domain,
domain: `stats.${domain}`,
link: [database, EMAILOCTOPUS_API_KEY],
environment: {
PUBLIC_URL: `https://${domain}/stats`,
PUBLIC_URL: `https://stats.${domain}/stats`,
},
})
+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-prUkwnqgxh0JkMADW75EZ6lU6IPfv2WHfyuOaSXw1oQ=",
"aarch64-linux": "sha256-h5Y7AJwho5wsgu9AuAYdbxRgVWRibSHvpXSvVAHmkBc=",
"aarch64-darwin": "sha256-399Up0sg57GHlX5bVTviHsQSoijfVmXf2hL6Wu/glN0=",
"x86_64-darwin": "sha256-88PR1Pr+fmXlWOhWf4nLo1lx3HiVLvTZ4iRJgIy7rgk="
}
}
+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.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"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",
@@ -530,7 +530,7 @@ type SessionHeaderV2ActionsState = {
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-0">
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
@@ -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"
+66 -46
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,10 +20,10 @@ 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 {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
@@ -52,7 +52,7 @@ const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
@@ -133,8 +133,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
statusVisible: !params.dir && settings.general.showStatus(),
statusLabel: language.t("status.popover.trigger"),
}))
const back = () => {
@@ -222,7 +220,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-9 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
}}
style={{
@@ -370,22 +368,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",
@@ -456,7 +460,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<div
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 pt-2"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
@@ -489,6 +493,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)}
/>
</>
@@ -696,19 +701,12 @@ type TitlebarUpdatePillState = {
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
statusVisible: boolean
statusLabel: string
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
@@ -736,26 +734,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">{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 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 +776,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 +784,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 +831,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"
@@ -6,7 +6,7 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -49,14 +49,20 @@ beforeAll(async () => {
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
queryGroups.push(options)
return [
{ isLoading: true, data: undefined },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
]
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options)
return {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
},
}))
@@ -159,7 +165,7 @@ describe("createChildStoreManager", () => {
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = queryGroups.length
const offset = querySingles.length
const mcpLoads: string[] = []
const dispose = createOwner((owner) => {
@@ -180,18 +186,20 @@ describe("createChildStoreManager", () => {
try {
if (!manager) throw new Error("manager required")
const [, setStore] = manager.child("/project", { bootstrap: false })
const queries = queryGroups[offset]
if (!queries) throw new Error("queries required")
expect(queries().queries[1]?.enabled).toBe(false)
const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(4)
const query = querySingles[offset + 1]
if (!query) throw new Error("query required")
expect(query().enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(queries().queries[1]?.enabled).toBe(true)
expect(query().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project")
expect(queries().queries[1]?.enabled).toBe(false)
expect(query().enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false)
} finally {
dispose()
@@ -14,7 +14,7 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
@@ -180,14 +180,10 @@ export function createChildStoreManager(input: {
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
input.queryOptions.path(key),
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
input.queryOptions.lsp(key),
input.queryOptions.providers(key),
],
}))
const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const child = createStore<State>({
project: "",
@@ -204,8 +200,9 @@ export function createChildStoreManager(input: {
},
config: {},
get path() {
if (pathQuery.data) return pathQuery.data
return { state: "", config: "", worktree: "", directory, home: "" }
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
if (pathQuery.isLoading) return EMPTY
return pathQuery.data ?? EMPTY
},
status: "loading" as const,
agent: [],
+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
+10 -10
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 />)
})
@@ -2369,18 +2374,13 @@ export default function Layout(props: ParentProps) {
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
<ToastRegion v2={newDesign()} />
</div>
}
>
@@ -2533,7 +2533,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 }
}
+18 -4
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"
@@ -1707,10 +1707,15 @@ export default function Page() {
)
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<div class="relative size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<div
class="flex-1 min-h-0 flex flex-col md:flex-row "
classList={{
"gap-2 p-2": settings.general.newLayoutDesigns(),
}}
>
<Show when={!isDesktop() && !!params.id}>
<Tabs value={store.mobileTab} class="h-auto">
<Tabs.List>
@@ -1742,12 +1747,18 @@ export default function Page() {
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns(),
}}
style={{
width: sessionPanelWidth(),
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
<div
class="flex-1 min-h-0 overflow-hidden"
classList={{
"rounded-[10px]": settings.general.newLayoutDesigns(),
}}
>
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
@@ -1810,6 +1821,9 @@ export default function Page() {
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
<ResizeHandle
classList={{
"-right-1": settings.general.newLayoutDesigns(),
}}
direction="horizontal"
size={layout.session.width()}
min={450}
@@ -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
}
@@ -67,7 +67,7 @@ export function SessionSidePanel(props: {
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
if (reviewOpen()) return "auto"
return `${layout.fileTree.width()}px`
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
@@ -214,11 +214,18 @@ export function SessionSidePanel(props: {
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active() && !props.reviewSnap,
"rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": settings.general.newLayoutDesigns(),
"flex-1": reviewOpen(),
}}
style={{ width: panelWidth() }}
>
<Show when={open()}>
<div class="size-full flex border-l border-border-weaker-base">
<div
class="size-full flex"
classList={{
"border-l border-border-weaker-base": !settings.general.newLayoutDesigns(),
}}
>
<div
aria-hidden={!reviewOpen()}
inert={!reviewOpen()}
@@ -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} />
}
@@ -48,6 +48,7 @@ import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter"
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
import { parseProviderErrorBody } from "./providerError"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -248,7 +249,15 @@ export async function handler(
// Handle non-streaming response
if (!isStream || [400, 404, 429].includes(res.status)) {
const json = await res.json()
const json = await (async () => {
if (res.status === 200) return res.json()
const body = await res.text()
try {
const parsed = JSON.parse(body)
if (parsed && typeof parsed === "object") return parsed as Record<string, any>
} catch {}
return parseProviderErrorBody(body, res.statusText)
})()
await rateLimiter?.track()
const usage = providerInfo.extractUsage(json)
if (usage) {
@@ -0,0 +1,25 @@
export function parseProviderErrorBody(body: string, statusText: string) {
const text = body.trim()
const sseData = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.filter((line) => line && line !== "[DONE]")
const parsed = (() => {
for (const data of sseData) {
try {
const json = JSON.parse(data)
if (json && typeof json === "object") return json as Record<string, any>
} catch {}
}
})()
if (parsed) return parsed as Record<string, any>
return {
error: {
message: sseData[0] || text || statusText,
},
}
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { parseProviderErrorBody } from "../src/routes/zen/util/providerError"
describe("provider error parsing", () => {
test("parses SSE error bodies from upstream rate limits", () => {
expect(
parseProviderErrorBody(
'event:error\ndata: {"error":{"type":"rate_limit_error","message":"Too many requests"}}\n\n',
"Too Many Requests",
),
).toEqual({
error: {
type: "rate_limit_error",
message: "Too many requests",
},
})
})
test("wraps plain text errors in provider error shape", () => {
expect(parseProviderErrorBody("overloaded", "Too Many Requests")).toEqual({
error: {
message: "overloaded",
},
})
})
})
@@ -0,0 +1,7 @@
UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');
--> statement-breakpoint
UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
DROP TABLE `permission`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
CREATE TABLE `permission` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`action` text NOT NULL,
`resource` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `permission_project_action_resource_idx` ON `permission` (`project_id`,`action`,`resource`);
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
+2 -2
View File
@@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
@@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionV2.Ruleset,
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({
+3
View File
@@ -23,5 +23,8 @@ export const migrations = (
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,22 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(
`UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';`,
)
yield* tx.run(
`UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');`,
)
yield* tx.run(
`UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';`,
)
yield* tx.run(
`UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');`,
)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,24 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`permission\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`action\` text NOT NULL,
\`resource\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
})
},
} satisfies DatabaseMigration.Migration
+91
View File
@@ -0,0 +1,91 @@
import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core"
import { AbsolutePath } from "../schema"
function storagePath(input: string) {
if (process.platform !== "win32") return input
return input.replaceAll("\\", "/")
}
function isWindowsStoragePath(input: string) {
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
}
function absolute(input: string) {
const result = storagePath(input)
if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) {
throw new Error(`Path is not absolute: ${input}`)
}
return result
}
function toPlatform(input: string) {
if (process.platform !== "win32" || !isWindowsStoragePath(input)) return input
return input.replaceAll("/", "\\")
}
export const absoluteColumn = customType<{
data: AbsolutePath
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return absolute(input)
},
fromDriver(input) {
return AbsolutePath.make(toPlatform(absolute(input)))
},
})
// Legacy sessions may persist an empty directory. Keep that existing value
// readable while normalizing and validating every real directory.
export const directoryColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return input ? absolute(input) : input
},
fromDriver(input) {
return input ? toPlatform(absolute(input)) : input
},
})
export const pathColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return storagePath(input)
},
fromDriver(input) {
return storagePath(input)
},
})
export const absoluteArrayColumn = customType<{
data: AbsolutePath[]
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return JSON.stringify(input.map(absolute))
},
fromDriver(input) {
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
},
})
+179
View File
@@ -0,0 +1,179 @@
export * as LocationFileSystem from "./location-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
export const ReadInput = Schema.Struct({
path: RelativePath,
})
export type ReadInput = typeof ReadInput.Type
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type FindInput = typeof FindInput.Type
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}) {}
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const location = yield* Location.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const absolute = path.resolve(location.directory, input ?? ".")
if (!AppFileSystem.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!AppFileSystem.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real }
})
const entry = Effect.fnUntraced(function* (absolute: string) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!AppFileSystem.contains(root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(location.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
})
})
const entries = [
new Entry({
path: RelativePath.make("README.md"),
uri: pathToFileURL(path.join(location.directory, "README.md")).href,
type: "file",
mime: "text/markdown",
}),
new Entry({
path: RelativePath.make("src"),
uri: pathToFileURL(path.join(location.directory, "src")).href,
type: "directory",
mime: "application/x-directory",
}),
]
return Service.of({
read: Effect.fn("LocationFileSystem.read")(function* (input) {
const file = yield* resolve(input.path)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
const mime = AppFileSystem.mimeType(file.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
}),
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
const directory = yield* resolve(input.path)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name)), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
find: Effect.fn("LocationFileSystem.find")(function* (input) {
return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit)
}),
grep: Effect.fn("LocationFileSystem.grep")(function* (input) {
return [
new GrepMatch({
path: RelativePath.make("README.md"),
lines: "# opencode",
line: 1,
offset: 0,
submatches: [{ text: input.pattern, start: 0, end: input.pattern.length }],
}),
].slice(0, input.limit)
}),
})
}),
)
+10
View File
@@ -13,6 +13,11 @@ import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { SessionV2 } from "./session"
import { LocationFileSystem } from "./location-filesystem"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
@@ -25,6 +30,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Catalog.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
PermissionV2.locationLayer,
LocationFileSystem.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
@@ -36,5 +43,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
ModelsDev.defaultLayer,
AppFileSystem.defaultLayer,
Global.defaultLayer,
Database.defaultLayer,
SessionV2.defaultLayer,
PermissionSaved.defaultLayer,
],
}) {}
+285 -34
View File
@@ -1,42 +1,110 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
import { Identifier } from "./id/id"
import { Newtype } from "./schema"
import { PermissionSchema } from "./permission/schema"
import { PermissionSaved } from "./permission/saved"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}
export { Effect, Rule, Ruleset } from "./permission/schema"
type Effect = PermissionSchema.Effect
type Rule = PermissionSchema.Rule
type Ruleset = PermissionSchema.Ruleset
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionV2.ID"),
withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
callID: Schema.String,
}),
]).annotate({ identifier: "PermissionV2.Source" })
export type Source = typeof Source.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.Request" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.AssertInput" })
export type AssertInput = typeof AssertInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.ReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export const AskResult = Schema.Struct({
id: ID,
effect: PermissionSchema.Effect,
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
export const Event = {
Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "permission.v2.replied",
schema: {
sessionID: SessionV2.ID,
requestID: ID,
reply: Reply,
},
}),
}
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" })
export type Action = typeof Action.Type
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "Permission.Rule" })
export type Rule = typeof Rule.Type
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" })
export type Ruleset = typeof Ruleset.Type
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: PermissionSchema.Ruleset,
}) {}
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
}) {}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
export type Error = DeniedError | RejectedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}
@@ -45,12 +113,195 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
export interface Interface {
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
interface Pending {
readonly request: Request
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
const sessions = yield* SessionV2.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
yield* EffectRuntime.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = EffectRuntime.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
const session = yield* sessions.get(sessionID)
if (!session.agent) return []
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
})
function denied(input: AssertInput, rules: Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
function request(input: AssertInput): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
}
}
const create = EffectRuntime.fnUntraced(function* (request: Request) {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, deferred }
pending.set(request.id, item)
yield* events.publish(Event.Asked, request)
return item
})
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
if (result.effect === "ask") yield* create(value)
return { id: value.id, effect: result.effect }
})
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input))
return yield* Deferred.await(item.deferred).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.delete(item.request.id)
}),
),
)
})
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
})
const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) {
return pending.get(id)?.request
})
const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
+96
View File
@@ -0,0 +1,96 @@
export * as PermissionLegacy from "./legacy"
import { Schema } from "effect"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { SessionSchema } from "../session/schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = typeof Ruleset.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).pipe(Schema.optional),
}).annotate({ identifier: "PermissionRequest" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = typeof Approval.Type
export const AskInput = Schema.Struct({
...Request.fields,
id: ID.pipe(Schema.optional),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = typeof AskInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
...ReplyBody.fields,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
+87
View File
@@ -0,0 +1,87 @@
export * as PermissionSaved from "./saved"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { PermissionTable } from "./sql"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectV2.ID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export type Info = typeof Info.Type
export const ListInput = Schema.Struct({
projectID: ProjectV2.ID.pipe(Schema.optional),
}).annotate({ identifier: "PermissionSaved.ListInput" })
export type ListInput = typeof ListInput.Type
export const AddInput = Schema.Struct({
projectID: ProjectV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionSaved.AddInput" })
export type AddInput = typeof AddInput.Type
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly add: (input: AddInput) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PermissionSaved") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
.where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined)
.all()
.pipe(Effect.orDie)
return rows.map(
(row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }),
)
})
const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) {
if (!input.resources.length) return
yield* db
.insert(PermissionTable)
.values(
input.resources.map((resource) => ({
id: ID.create(),
project_id: input.projectID,
action: input.action,
resource,
})),
)
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) {
yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie)
})
return Service.of({ list, add, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+16
View File
@@ -0,0 +1,16 @@
export * as PermissionSchema from "./schema"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
export type Effect = typeof Effect.Type
export const Rule = Schema.Struct({
action: Schema.String,
resource: Schema.String,
effect: Effect,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
+20
View File
@@ -0,0 +1,20 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
import { ProjectTable } from "../project/sql"
import type { PermissionSaved } from "./saved"
export const PermissionTable = sqliteTable(
"permission",
{
id: text().$type<PermissionSaved.ID>().primaryKey(),
project_id: text()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
action: text().notNull(),
resource: text().notNull(),
...Timestamps,
},
(table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)],
)
+34 -36
View File
@@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ permission: "external_directory", pattern: "*", action: "ask" },
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }),
(resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: PermissionV2.Ruleset = [
{ permission: "*", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ permission: "question", pattern: "*", action: "deny" },
{ permission: "plan_enter", pattern: "*", action: "deny" },
{ permission: "plan_exit", pattern: "*", action: "deny" },
{ permission: "repo_clone", pattern: "*", action: "deny" },
{ permission: "repo_overview", pattern: "*", action: "deny" },
{ permission: "read", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*.env", action: "ask" },
{ permission: "read", pattern: "*.env.*", action: "ask" },
{ permission: "read", pattern: "*.env.example", action: "allow" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "repo_clone", resource: "*", effect: "deny" },
{ action: "repo_overview", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* agent.update((editor) => {
@@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_enter", pattern: "*", action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
})
@@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_exit", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
{ permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
permission: "edit",
pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
action: "allow",
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
)
@@ -158,9 +158,7 @@ export const Plugin = PluginV2.define({
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
item.permissions.push(
...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]),
)
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("explore"), (item) => {
@@ -172,14 +170,14 @@ export const Plugin = PluginV2.define({
...PermissionV2.merge(
defaults,
[
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "grep", pattern: "*", action: "allow" },
{ permission: "glob", pattern: "*", action: "allow" },
{ permission: "list", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "allow" },
{ permission: "websearch", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "list", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
],
readonlyExternalDirectory,
),
@@ -190,21 +188,21 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
})
}),
+3 -2
View File
@@ -1,10 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectV2.ID>().primaryKey(),
worktree: text().notNull(),
worktree: DatabasePath.absoluteColumn().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
@@ -12,6 +13,6 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
+34 -30
View File
@@ -15,7 +15,8 @@ import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
import { SessionMessageTable, SessionTable } from "./session/sql"
import { SessionSchema } from "./session/schema"
import { AbsolutePath, RelativePath } from "./schema"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
import { AgentV2 } from "./agent"
// get project -> project.locations
//
@@ -26,35 +27,35 @@ import { AbsolutePath, RelativePath } from "./schema"
// - by subpath
// - by workspace (home is special)
export const ListCursor = Schema.Struct({
export const ListAnchor = Schema.Struct({
id: SessionSchema.ID,
time: Schema.Finite,
direction: Schema.Literals(["previous", "next"]),
})
export type ListCursor = typeof ListCursor.Type
export type ListAnchor = typeof ListAnchor.Type
const ListInputBase = {
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: Schema.Int.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
cursor: ListCursor.pipe(Schema.optional),
anchor: ListAnchor.pipe(Schema.optional),
}
export const ListInput = Schema.Union([
Schema.Struct({
...ListInputBase,
}),
Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
}),
Schema.Struct({
...ListInputBase,
project: ProjectV2.ID,
subpath: RelativePath.pipe(Schema.optional),
}),
])
const ListDirectoryInput = Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
})
const ListProjectInput = Schema.Struct({
...ListInputBase,
project: ProjectV2.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const ListAllInput = Schema.Struct(ListInputBase)
export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
export type ListInput = typeof ListInput.Type
type CreateInput = {
@@ -141,14 +142,12 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return new SessionSchema.Info({
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
path: row.path ?? "",
agent: row.agent ?? undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
@@ -166,6 +165,11 @@ function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
write: row.tokens_cache_write,
},
},
location: Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
@@ -201,25 +205,25 @@ export const layer = Layer.effect(
return fromRow(row)
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.cursor?.direction ?? "next"
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.cursor) {
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
+2 -2
View File
@@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema"
@@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV2.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
+8 -16
View File
@@ -5,9 +5,9 @@ import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { Identifier } from "../util/identifier"
import { V2Schema } from "../v2-schema"
import { AgentV2 } from "../agent"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
@@ -24,22 +24,12 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
)
export type ID = typeof ID.Type
export const LegacyInfo = Schema.Struct({
export class Info extends Schema.Class<Info>("SessionV2.Info")({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: ProjectV2.ID, // derived from location
})
export type LegacyInfo = typeof LegacyInfo.Type
export class Info extends Schema.Class<Info>("Session.Info")({
id: ID,
parentID: optionalOmitUndefined(ID),
parentID: ID.pipe(optionalOmitUndefined),
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: ModelV2.Ref.pipe(optionalOmitUndefined),
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
@@ -53,7 +43,9 @@ export class Info extends Schema.Class<Info>("Session.Info")({
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
}) {}
+5 -12
View File
@@ -1,8 +1,9 @@
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
@@ -24,8 +25,8 @@ export const SessionTable = sqliteTable(
workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
directory: DatabasePath.directoryColumn().notNull(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
@@ -41,7 +42,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionV2.Ruleset>(),
permission: text({ mode: "json" }).$type<PermissionLegacy.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
@@ -128,11 +129,3 @@ export const SessionMessageTable = sqliteTable(
index("session_message_time_created_idx").on(table.time_created),
],
)
export const PermissionTable = sqliteTable("permission", {
project_id: text()
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PermissionV2.Ruleset>(),
})
+11 -11
View File
@@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* defaults((editor) =>
editor.update(build, (agent) => {
agent.mode = "primary"
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
}),
)
@@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => {
new Config.Loaded({
source: { type: "memory" },
info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
removed: { description: "Removed later" },
},
@@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
@@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "*", action: "deny" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
+6 -6
View File
@@ -170,8 +170,8 @@ describe("Config", () => {
enterprise: { url: "https://share.example.com" },
username: "test-user",
permissions: [
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
],
agents: {
reviewer: {
@@ -188,7 +188,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
},
snapshots: false,
@@ -254,8 +254,8 @@ describe("Config", () => {
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5",
@@ -271,7 +271,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
+169 -2
View File
@@ -4,9 +4,15 @@ import { fileURLToPath } from "url"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
@@ -37,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
}),
)
})
@@ -71,6 +77,167 @@ describe("DatabaseMigration", () => {
)
})
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
// Windows-shaped rows (drive + backslash) must be normalized.
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
"C:\\Repo\\Thing\\sandbox",
])})`,
)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
)
// UNC worktrees and their sandboxes must normalize too (not just drive paths).
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
"\\\\server\\share\\sandbox",
])})`,
)
// The "/" worktree sentinel and POSIX paths (including a pathological
// backslash in a POSIX filename) must survive byte-for-byte.
yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
)
yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
worktree: "//server/share",
sandboxes: JSON.stringify(["//server/share/sandbox"]),
})
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
directory: "/home/me/we\\ird",
path: "src\\weird",
})
}),
)
})
test("maps native Windows paths through database columns", async () => {
if (process.platform !== "win32") return
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const projectID = ProjectV2.ID.make("codec_project")
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
const directory = "C:\\Repo\\Thing\\packages\\api"
const sessionID = SessionSchema.ID.make("ses_codec")
expect(() =>
Effect.runSync(
db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.make("invalid_path"),
worktree: AbsolutePath.make("not-absolute"),
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run(),
),
).toThrow()
yield* db
.insert(ProjectTable)
.values({
id: projectID,
worktree,
sandboxes: [sandbox],
time_created: 1,
time_updated: 1,
})
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "codec",
directory,
path: "packages\\api",
title: "Codec",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
expect(
yield* db.get<{ worktree: string; sandboxes: string }>(
sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
),
).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(
yield* db.get<{ directory: string; path: string }>(
sql`SELECT directory, path FROM session WHERE id = ${sessionID}`,
),
).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
expect(project?.worktree).toBe(worktree)
expect(project?.sandboxes).toEqual([sandbox])
expect(session?.directory).toBe(directory)
expect(session?.path).toBe("packages/api")
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
sessionID,
)
const moved = AbsolutePath.make("D:\\Moved\\Thing")
const updated = yield* db
.update(ProjectTable)
.set({ worktree: moved, sandboxes: [moved] })
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
expect(updated?.worktree).toBe(moved)
expect(updated?.sandboxes).toEqual([moved])
expect(
yield* db.get<{ worktree: string; sandboxes: string }>(
sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
),
).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
expect(
(yield* db
.select()
.from(ProjectTable)
.where(inArray(ProjectTable.worktree, [moved]))
.get())?.id,
).toBe(projectID)
yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
expect(() =>
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
).toThrow()
}),
)
})
test("imports existing drizzle migration state", async () => {
await run(
Effect.gen(function* () {
@@ -0,0 +1,92 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
function provide(directory: string) {
return Effect.provide(
LocationFileSystem.locationLayer.pipe(
Layer.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
),
),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path).pipe(provide(tmp.path))))
}
describe("LocationFileSystem", () => {
it.live("reads text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
const service = yield* LocationFileSystem.Service
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
type: "text",
content: "hello",
mime: "text/plain",
})
expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
type: "binary",
content: "AAEC",
encoding: "base64",
mime: "application/octet-stream",
})
}),
),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* LocationFileSystem.Service
expect(yield* service.list()).toEqual([
{
path: RelativePath.make("src"),
uri: pathToFileURL(path.join(directory, "src")).href,
type: "directory",
mime: "application/x-directory",
},
{
path: RelativePath.make("README.md"),
uri: pathToFileURL(path.join(directory, "README.md")).href,
type: "file",
mime: "text/markdown",
},
])
}),
),
)
it.live("rejects paths outside the location", () =>
withTmp((directory) =>
Effect.gen(function* () {
const service = yield* LocationFileSystem.Service
expect(
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
).toBe(true)
}),
),
)
})
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const events = EventV2.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(Layer.provide(database))
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database),
Layer.provideMerge(events),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(saved),
)
const it = testEffect(layer)
function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: SessionV2.ID.make("ses_test"),
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* setRules(rules)
})
}
function setRules(rules: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
)
})
}
function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
return {
id: PermissionV2.ID.create("per_test"),
sessionID: SessionV2.ID.make("ses_test"),
action: "read",
resources: ["src/index.ts"],
...input,
} satisfies PermissionV2.AssertInput
}
function waitForRequest() {
return Effect.gen(function* () {
const service = yield* PermissionV2.Service
const events = yield* EventV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
return { service, fiber, request }
})
}
describe("PermissionV2", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
yield* setRules([])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
expect(yield* service.list()).toEqual([request])
expect(yield* service.forSession(request.sessionID)).toEqual([request])
expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
expect(yield* service.get(request.id)).toEqual(request)
yield* service.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
expect(yield* service.list()).toEqual([])
expect(yield* service.get(request.id)).toBeUndefined()
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* PermissionV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
yield* service.reply({ requestID: request.id, reply: "always" })
yield* Fiber.join(fiber)
const { db } = yield* Database.Service
expect(
yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all(),
).toMatchObject([{ action: "read", resource: "src/*" }])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
}),
)
})
+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,
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
@@ -36,7 +37,7 @@ export const Info = Schema.Struct({
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Permission } from "../permission"
import type { Agent } from "./agent"
@@ -15,10 +16,10 @@ import type { Agent } from "./agent"
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentSessionPermission: PermissionLegacy.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
}): PermissionLegacy.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies =
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path"
@@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
throw new PermissionLegacy.DeniedError({ ruleset })
}
}
})
@@ -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.
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
// CLI entry point for `opencode run`.
//
// Handles three modes:
@@ -221,7 +222,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",
@@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: Permission.Ruleset = args.interactive
const rules: PermissionLegacy.Ruleset = args.interactive
? []
: [
{
@@ -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,
}
}
+73 -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
@@ -685,7 +739,11 @@ export class RunFooter implements FooterApi {
return
}
const previous = this.currentModel()
this.setCurrentModel(model)
if (!previous || previous.providerID !== model.providerID || previous.modelID !== model.modelID) {
this.setCurrentVariant(undefined)
}
void Promise.resolve()
.then(() => this.options.onModelSelect?.(model))
.then((result) => {
@@ -872,6 +930,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 +962,8 @@ export class RunFooter implements FooterApi {
},
),
)
.catch(() => {})
.catch((error) => {
this.flushError = error
})
}
}
+136 -23
View File
@@ -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,
@@ -51,6 +53,7 @@ import type {
RunTuiConfig,
} from "./types"
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
const EMPTY_BORDER = {
topLeft: "",
@@ -79,6 +82,7 @@ type RunFooterViewProps = {
state: () => FooterState
view?: () => FooterView
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme?: RunTheme
diffStyle?: RunDiffStyle
tuiConfig: RunTuiConfig
@@ -100,6 +104,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 +124,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 +158,15 @@ export function RunFooterView(props: RunFooterViewProps) {
label: count === 1 ? "agent" : "agents",
}
})
const queuedIndicator = createMemo(() => {
const count = queuedPrompts().length
if (count === 0) return
return { count }
})
const model = createMemo(() => {
const current = props.currentModel()
return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined }
})
const detail = createMemo(() => {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
@@ -180,11 +196,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 +283,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 +323,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 +338,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 +375,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 +428,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 +443,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 +522,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()
@@ -533,16 +622,33 @@ export function RunFooterView(props: RunFooterViewProps) {
{shell() ? "Shell" : props.agent}
</text>
<Show when={!shell()}>
<text
id="run-direct-footer-model"
fg={theme().text}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
>
{props.state().model}
</text>
<box id="run-direct-footer-model" flexDirection="row" gap={1} flexGrow={1} flexShrink={1}>
<text fg={theme().muted} wrapMode="none" flexShrink={0}>
·
</text>
<text fg={theme().text} wrapMode="none" truncate flexShrink={1}>
{model().model}
</text>
<Show when={model().provider}>
{(provider) => (
<text fg={theme().muted} wrapMode="none" truncate flexShrink={1}>
{provider()}
</text>
)}
</Show>
<Show when={props.currentVariant()}>
{(variant) => (
<>
<text fg={theme().muted} wrapMode="none" flexShrink={0}>
·
</text>
<text wrapMode="none" truncate flexShrink={1}>
<span style={{ fg: theme().warning, bold: true }}>{variant()}</span>
</text>
</>
)}
</Show>
</box>
</Show>
</box>
</Show>
@@ -615,7 +721,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>
@@ -660,13 +768,18 @@ export function RunFooterView(props: RunFooterViewProps) {
<Show when={subagentIndicator()}>
{(info) => (
<text id="run-direct-footer-subagents-label" fg={theme().text} wrapMode="none" truncate>
<Show when={busy() || exiting() || duration().length > 0}>
<span style={{ fg: theme().muted }}>· </span>
</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().muted }}> to view</span>
<span style={{ fg: theme().highlight }}> </span>
{info().count} <span style={{ fg: theme().muted }}>{info().label} </span>
<span style={{ fg: theme().highlight }}>{subagentShortcut() || "leader+down"}</span>
</text>
)}
</Show>
<Show when={queuedIndicator()}>
{(info) => (
<text id="run-direct-footer-queued-label" fg={theme().text} wrapMode="none" truncate>
<span style={{ fg: theme().warning }}> </span>
{info().count} <span style={{ fg: theme().muted }}>queued </span>
<span style={{ fg: theme().highlight }}>{queuedShortcut() || "leader+q"}</span>
</text>
)}
</Show>
@@ -686,9 +799,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,47 @@ 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)
// @ts-expect-error resetSplitFooterForReplay is restored with the native OpenTUI update.
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)
}

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