mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8378856a90 | |||
| 1e1dc56a09 | |||
| a78adb1b09 | |||
| cd0fd9941d | |||
| 8936914e9d | |||
| d5a0ddb520 | |||
| 787f170682 | |||
| 8af7371c13 | |||
| 90d85636e0 | |||
| 6a5ef7f2ea | |||
| 5937e606df | |||
| 0136f03fa9 | |||
| 4df3c98c6b | |||
| 98b7230c2a | |||
| 8d03f6c5be | |||
| 1b85d55a0e | |||
| 497ff36e91 | |||
| c1c02f80dd | |||
| bbec00fbd5 | |||
| 4668db8fa2 | |||
| ab69f41067 | |||
| 687c66248c | |||
| 61b5046f88 | |||
| 4e70eabbcc | |||
| 1cae8f89c5 | |||
| 8dc2ffd48f | |||
| 9b815bcbd2 | |||
| acd620f411 | |||
| f0c7febb02 | |||
| a821029258 | |||
| 57d602d07a | |||
| 6072a68d6b | |||
| ae92f3158f | |||
| fa23fb5d38 | |||
| 5f2b4eab28 | |||
| 32d5058f8c | |||
| 363d6d1a26 | |||
| 2bf85b8479 | |||
| 1813256d8e | |||
| d7ba8b1125 | |||
| 44350bced9 | |||
| 913c4ef0b1 | |||
| 10095bfad4 | |||
| 6a3b2f339a | |||
| d85f8cd4d8 | |||
| 68676f2c5c | |||
| 2d2f587bfa | |||
| bba76009a8 | |||
| 50b4ad89b3 | |||
| fd2278eefc | |||
| 7ccb7889af |
@@ -0,0 +1,2 @@
|
||||
packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsList } from "./settings-list"
|
||||
|
||||
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
|
||||
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
||||
const IconButtonV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
|
||||
)
|
||||
const TextInputV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
|
||||
)
|
||||
const SettingsListV2 = lazy(() =>
|
||||
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
|
||||
)
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
@@ -257,7 +269,7 @@ function useKeyCapture(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component = () => {
|
||||
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -371,85 +383,178 @@ export const SettingsKeybinds: Component = () => {
|
||||
if (store.active) command.keybinds(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
const emptyResults = (
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
|
||||
"settings-v2-shortcuts-status": props.v2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-weak": !props.v2,
|
||||
}}
|
||||
>
|
||||
{language.t("settings.shortcuts.search.empty")}
|
||||
</span>
|
||||
<Show when={store.filter}>
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-strong mt-1": !props.v2,
|
||||
"settings-v2-shortcuts-status-filter": props.v2,
|
||||
}}
|
||||
>
|
||||
"{store.filter}"
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
const List = props.v2 ? SettingsListV2 : SettingsList
|
||||
|
||||
const groups = (
|
||||
<div
|
||||
classList={{
|
||||
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
|
||||
"flex flex-col gap-8 max-w-[720px]": !props.v2,
|
||||
}}
|
||||
>
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div
|
||||
classList={{
|
||||
"settings-v2-section": props.v2,
|
||||
"flex flex-col gap-1": !props.v2,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
classList={{
|
||||
"settings-v2-section-title": props.v2,
|
||||
"text-14-medium text-text-strong pb-2": !props.v2,
|
||||
}}
|
||||
>
|
||||
{language.t(groupKey[group])}
|
||||
</h3>
|
||||
<List>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span
|
||||
classList={{
|
||||
"text-14-regular text-text-strong": !props.v2,
|
||||
}}
|
||||
>
|
||||
{title(id)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"settings-v2-keybind-button": props.v2,
|
||||
"settings-v2-keybind-button--active": props.v2 && store.active === id,
|
||||
"h-8 px-3 rounded-md text-12-regular": !props.v2,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
!props.v2 && store.active !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak":
|
||||
!props.v2 && store.active === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show
|
||||
when={store.active === id}
|
||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</List>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
{emptyResults}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.v2}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
|
||||
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
|
||||
<TextField
|
||||
variant="ghost"
|
||||
type="text"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groups}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<div class="settings-v2-tab-header-row">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
{language.t("settings.shortcuts.reset.button")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onChange={(v) => setStore("filter", v)}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.shortcuts.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="flex-1"
|
||||
aria-label={language.t("settings.shortcuts.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
|
||||
<SettingsList>
|
||||
<For each={filtered().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-keybind-id={id}
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
store.active !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show
|
||||
when={store.active === id}
|
||||
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
|
||||
>
|
||||
{language.t("settings.shortcuts.pressKeys")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={store.filter && !hasResults()}>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
|
||||
<Show when={store.filter}>
|
||||
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-tab-body">{groups}</div>
|
||||
</>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SettingsGeneralV2 } from "./general"
|
||||
import { SettingsKeybinds } from "../settings-keybinds"
|
||||
import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" class="settings-v2-dialog" data-component="settings-v2-dialog">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
defaultValue="general"
|
||||
class="settings-v2"
|
||||
data-component="settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
{language.t("settings.tab.general")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
{language.t("settings.tab.shortcuts")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<TabsV2.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
{language.t("settings.providers.title")}
|
||||
</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</TabsV2.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsV2.List>
|
||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||
<SettingsGeneralV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||
<SettingsKeybinds v2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SelectV2 } from "@opencode-ai/ui/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneralV2: Component = () => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value) return false
|
||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||
return permission.isAutoAccepting(params.id, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value) return
|
||||
|
||||
if (!params.id) {
|
||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||
permission.toggleAutoAcceptDirectory(value)
|
||||
return
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(params.id, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(params.id, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const globalSync = useServerSync()
|
||||
const globalSdk = useServerSDK()
|
||||
|
||||
const [shells] = createResource(
|
||||
() =>
|
||||
globalSdk.client.pty
|
||||
.shells()
|
||||
.then((res) => res.data ?? [])
|
||||
.catch(() => [] as ShellOption[]),
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
|
||||
() => (linux() && platform.getDisplayBackend ? true : false),
|
||||
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
|
||||
{ initialValue: null as DisplayBackend | null },
|
||||
)
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
{ initialValue: false },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = globalSync.data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onDisplayBackendChange = (checked: boolean) => {
|
||||
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
|
||||
if (!update) return
|
||||
void update.finally(() => {
|
||||
void refetchDisplayBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const onPinchZoomChange = (checked: boolean) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
if (!update) return
|
||||
void update.catch(() => setPinchZoom(!checked))
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
label: language.label(locale),
|
||||
})),
|
||||
)
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
const GeneralSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.language.title")}
|
||||
description={language.t("settings.general.row.language.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-language"
|
||||
options={languageOptions()}
|
||||
current={languageOptions().find((o) => o.value === language.locale())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && language.setLocale(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
globalSync.updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-feed-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSessionProgressBar.title")}
|
||||
description={language.t("settings.general.row.showSessionProgressBar.description")}
|
||||
>
|
||||
<div data-action="settings-show-session-progress-bar">
|
||||
<Switch
|
||||
checked={settings.general.showSessionProgressBar()}
|
||||
onChange={(checked) => settings.general.setShowSessionProgressBar(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.newLayoutDesigns.title")}
|
||||
description={language.t("settings.general.row.newLayoutDesigns.description")}
|
||||
>
|
||||
<div data-action="settings-new-layout-designs">
|
||||
<Switch
|
||||
checked={settings.general.newLayoutDesigns()}
|
||||
onChange={(checked) => {
|
||||
settings.general.setNewLayoutDesigns(checked)
|
||||
if (checked) return
|
||||
void import("@/components/dialog-settings").then((module) => {
|
||||
dialog.show(() => <module.DialogSettings />)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdvancedSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showFileTree.title")}
|
||||
description={language.t("settings.general.row.showFileTree.description")}
|
||||
>
|
||||
<div data-action="settings-show-file-tree">
|
||||
<Switch
|
||||
checked={settings.general.showFileTree()}
|
||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showNavigation.title")}
|
||||
description={language.t("settings.general.row.showNavigation.description")}
|
||||
>
|
||||
<div data-action="settings-show-navigation">
|
||||
<Switch
|
||||
checked={settings.general.showNavigation()}
|
||||
onChange={(checked) => settings.general.setShowNavigation(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showTerminal.title")}
|
||||
description={language.t("settings.general.row.showTerminal.description")}
|
||||
>
|
||||
<div data-action="settings-show-terminal">
|
||||
<Switch
|
||||
checked={settings.general.showTerminal()}
|
||||
onChange={(checked) => settings.general.setShowTerminal(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
>
|
||||
<div data-action="settings-show-status">
|
||||
<Switch
|
||||
checked={settings.general.showStatus()}
|
||||
onChange={(checked) => settings.general.setShowStatus(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
>
|
||||
<div data-action="settings-show-custom-agents">
|
||||
<Switch
|
||||
checked={settings.general.showCustomAgents()}
|
||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewColorScheme(option.value)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewTheme(option.id)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-ui-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={sans()}
|
||||
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.uiFont.title")}
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-code-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={mono()}
|
||||
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.font.title")}
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.terminalFont.title")}
|
||||
description={language.t("settings.general.row.terminalFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-terminal-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={terminal()}
|
||||
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
||||
placeholder={terminalDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.terminalFont.title")}
|
||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.agent.title")}
|
||||
description={language.t("settings.general.notifications.agent.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-agent">
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.permissions.title")}
|
||||
description={language.t("settings.general.notifications.permissions.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-permissions">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.notifications.errors.title")}
|
||||
description={language.t("settings.general.notifications.errors.description")}
|
||||
>
|
||||
<div data-action="settings-notifications-errors">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<SelectV2
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
>
|
||||
<div data-action="settings-release-notes">
|
||||
<Switch
|
||||
checked={settings.general.releaseNotes()}
|
||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DisplaySection = () => (
|
||||
<Show when={desktop()}>
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.pinchZoom.title")}
|
||||
description={language.t("settings.general.row.pinchZoom.description")}
|
||||
>
|
||||
<div data-action="settings-pinch-zoom">
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={linux()}>
|
||||
<SettingsRowV2
|
||||
title={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("settings.general.row.wayland.title")}</span>
|
||||
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
|
||||
<span class="text-text-weak">
|
||||
<Icon name="help" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
description={language.t("settings.general.row.wayland.description")}
|
||||
>
|
||||
<div data-action="settings-wayland">
|
||||
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection />
|
||||
|
||||
<UpdatesSection />
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<AdvancedSection />
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DialogSettings } from "./dialog-settings-v2"
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsModelsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
|
||||
const aName = a.items[0].provider.name
|
||||
const bName = b.items[0].provider.name
|
||||
return aName.localeCompare(bName)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<div class="settings-v2-tab-search">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-models">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header">
|
||||
<ProviderIcon
|
||||
id={group.category}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-models-provider-icon shrink-0"
|
||||
/>
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
return (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={models.visible(key)}
|
||||
onChange={(checked) => {
|
||||
models.setVisibility(key, checked)
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-v2-list">{props.children}</div>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "../settings-v2.css"
|
||||
|
||||
export interface SettingsRowV2Props {
|
||||
title: string | JSX.Element
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const SettingsRowV2: Component<SettingsRowV2Props> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-v2-row">
|
||||
<div data-slot="settings-v2-row-copy">
|
||||
<div data-slot="settings-v2-row-title">{props.title}</div>
|
||||
<div data-slot="settings-v2-row-description">{props.description}</div>
|
||||
</div>
|
||||
<div data-slot="settings-v2-row-control">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider } from "../dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "../dialog-select-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||
] as const
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const globalSDK = useServerSDK()
|
||||
const globalSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||
const items = providers
|
||||
.popular()
|
||||
.filter((p) => !connectedIDs.has(p.id))
|
||||
.slice()
|
||||
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
||||
return items
|
||||
})
|
||||
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
return
|
||||
}
|
||||
|
||||
const type = (item: ProviderItem) => {
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = globalSync.data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const disableProvider = async (providerID: string, name: string) => {
|
||||
const before = globalSync.data.config.disabled_providers ?? []
|
||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
||||
globalSync.set("config", "disabled_providers", next)
|
||||
|
||||
await globalSync
|
||||
.updateConfig({ disabled_providers: next })
|
||||
.then(() => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
globalSync.set("config", "disabled_providers", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
if (isConfigCustom(providerID)) {
|
||||
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
|
||||
await disableProvider(providerID, name)
|
||||
return
|
||||
}
|
||||
await globalSDK.client.auth
|
||||
.remove({ providerID })
|
||||
.then(async () => {
|
||||
await globalSDK.client.global.dispose()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-v2-tab-header">
|
||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-tab-body settings-v2-providers">
|
||||
<div class="settings-v2-section" data-component="connected-providers-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsListV2>
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-provider-empty">{language.t("settings.providers.connected.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={connected()}>
|
||||
{(item) => (
|
||||
<div class="settings-v2-provider-row group">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name truncate">{item.name}</span>
|
||||
<Tag>{type(item)}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-v2-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
{language.t("common.disconnect")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.providers.section.popular")}</h3>
|
||||
<SettingsListV2>
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="settings-v2-provider-row">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id={item.id}
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{item.name}</span>
|
||||
<Show when={item.id === "opencode" || item.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note(item.id)}>
|
||||
{(key) => <p class="settings-v2-provider-description">{language.t(key())}</p>}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogConnectProvider provider={item.id} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="settings-v2-provider-row" data-component="custom-provider-section">
|
||||
<div class="settings-v2-provider-lead">
|
||||
<ProviderIcon
|
||||
id="synthetic"
|
||||
width={PROVIDER_ICON_SIZE}
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-v2-provider-icon shrink-0"
|
||||
/>
|
||||
<div class="settings-v2-provider-copy">
|
||||
<div class="settings-v2-provider-main">
|
||||
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider back="close" />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</SettingsListV2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-providers-view-all"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
@import "@opencode-ai/ui/v2/text-input-v2.css";
|
||||
@import "@opencode-ai/ui/v2/button-v2.css";
|
||||
|
||||
[data-component="settings-v2"] {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"] [data-slot="dialog-body"] {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.settings-v2-panel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: 40px 40px 32px;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-v2-tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] a.settings-v2-link {
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] a.settings-v2-link:hover {
|
||||
color: var(--v2-text-text-accent-hover);
|
||||
}
|
||||
|
||||
.settings-v2-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-section-title {
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-list"] {
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
padding-inline: 20px;
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
}
|
||||
|
||||
[data-slot="dialog-container"]:has(.settings-v2-dialog) {
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-component="settings-v2-row"]:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
[data-component="settings-v2-row"] {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-copy"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-title"] {
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]),
|
||||
[data-slot="settings-v2-row-control"] > [data-component="switch"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
[data-slot="settings-v2-row-control"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="settings-v2-row-control"] [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"] [data-component="select"][data-trigger-style="settings-v2"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="settings-v2-dialog"]
|
||||
[data-component="select"][data-trigger-style="settings-v2"]
|
||||
[data-component="button-v2"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
.settings-v2-nav-footer > span {
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel [data-component="dialog"] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.settings-v2-provider-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.settings-v2-provider-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-v2-providers [data-component="provider-icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-lead {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-lead:not(:has(.settings-v2-provider-copy)) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-v2-provider-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-provider-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-provider-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-provider-empty {
|
||||
padding-block: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-v2-provider-env-hint {
|
||||
padding-right: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.group:hover .settings-v2-provider-env-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-v2-providers-view-all {
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-accent);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-v2-providers-view-all:hover {
|
||||
color: var(--v2-text-text-accent-hover);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-providers {
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header:has(+ .settings-v2-tab-body.settings-v2-providers) {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-providers .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-v2-providers .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header.settings-v2-tab-header--stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-header--stacked > .settings-v2-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.settings-v2-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-v2-tab-body.settings-v2-models {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-v2-models-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-v2-models .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-component="provider-icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-description"]:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-copy"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-v2-models [data-slot="settings-v2-row-title"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-v2-models-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-models-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts .settings-v2-section {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts .settings-v2-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts [data-component="settings-v2-list"] > div > span {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-variation-settings: "slnt" 0;
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 8px;
|
||||
margin: -6px -8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings:
|
||||
"tnum" on,
|
||||
"lnum" on;
|
||||
font-variation-settings: "slnt" 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button:hover {
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-v2-keybind-button--active {
|
||||
color: var(--v2-text-text-faint);
|
||||
border-radius: 2px;
|
||||
background-color: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-v2-shortcuts-status-filter {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -20,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,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: [],
|
||||
|
||||
@@ -12,6 +12,9 @@ import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
|
||||
export type { ProjectAvatarVariant }
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_SIDEBAR_WIDTH = 344
|
||||
@@ -33,6 +36,16 @@ export function getAvatarColors(key?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||
if (key === "orange") return "orange"
|
||||
if (key === "pink") return "pink"
|
||||
if (key === "cyan") return "cyan"
|
||||
if (key === "purple") return "purple"
|
||||
if (key === "mint") return "cyan"
|
||||
if (key === "lime") return "green"
|
||||
return "gray"
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -531,6 +531,8 @@ export const dict = {
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
"home.sessions.search.sessions": "Sessions",
|
||||
"home.sessions.search.noResults": "No sessions found for {{query}}",
|
||||
"home.sessions.empty": "No sessions found",
|
||||
"home.sessions.empty.description": "Start a new session for this project",
|
||||
"home.sessions.group.today": "Today",
|
||||
|
||||
@@ -502,6 +502,8 @@ export const dict = {
|
||||
"home.projects": "项目",
|
||||
"home.project.add": "添加项目",
|
||||
"home.sessions.search.placeholder": "搜索会话",
|
||||
"home.sessions.search.sessions": "会话",
|
||||
"home.sessions.search.noResults": "未找到与 {{query}} 相关的会话",
|
||||
"home.sessions.empty": "未找到会话",
|
||||
"home.sessions.group.today": "今天",
|
||||
"home.sessions.group.yesterday": "昨天",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||
|
||||
+530
-285
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,8 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { toaster } from "@opencode-ai/ui/toast"
|
||||
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
@@ -128,6 +129,7 @@ export default function Layout(props: ParentProps) {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
createEffect(() => setV2Toast(newDesign()))
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const location = useLocation()
|
||||
const route = createMemo(() => {
|
||||
@@ -1227,7 +1229,10 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function openSettings() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-settings").then((x) => {
|
||||
const module = settings.general.newLayoutDesigns()
|
||||
? import("@/components/settings-v2")
|
||||
: import("@/components/dialog-settings")
|
||||
void module.then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSettings />)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 '//%');
|
||||
+1560
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
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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))))
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)],
|
||||
)
|
||||
@@ -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" }]))
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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 }>(),
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
@@ -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>(),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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"] })
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -330,25 +330,34 @@ export function make(input: {
|
||||
}
|
||||
})
|
||||
|
||||
const abortBackingSession = Effect.fn("ACP.abortBackingSession")(function* (current: ACPSession.Info) {
|
||||
yield* request(
|
||||
() => input.sdk.session.abort({ directory: current.cwd, sessionID: current.id }, { throwOnError: true }),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to abort ACP backing session", { error, sessionID: current.id })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
sessionSnapshots.delete(params.sessionId)
|
||||
if (!removed) return {}
|
||||
|
||||
yield* request(
|
||||
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
"session",
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* abortBackingSession(removed)
|
||||
return {}
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("ACP.cancel")(function* (params: CancelNotification) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
yield* abortBackingSession(current)
|
||||
})
|
||||
|
||||
const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const forked = yield* request(
|
||||
@@ -563,9 +572,7 @@ export function make(input: {
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
}),
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user