diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml new file mode 100644 index 0000000000..04b6ae7ac8 --- /dev/null +++ b/.github/workflows/close-issues.yml @@ -0,0 +1,24 @@ +name: close-issues + +on: + schedule: + - cron: "0 2 * * *" # Daily at 2:00 AM + workflow_dispatch: + +jobs: + close: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Close stale issues + env: + GITHUB_TOKEN: ${{ github.token }} + run: bun script/github/close-issues.ts diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml deleted file mode 100644 index a4b8583f92..0000000000 --- a/.github/workflows/stale-issues.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: stale-issues - -on: - schedule: - - cron: "30 1 * * *" # Daily at 1:30 AM - workflow_dispatch: - -env: - DAYS_BEFORE_STALE: 90 - DAYS_BEFORE_CLOSE: 7 - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/stale@v10 - with: - days-before-stale: ${{ env.DAYS_BEFORE_STALE }} - days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} - stale-issue-label: "stale" - close-issue-message: | - [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity. - - Feel free to reopen if you still need this! - stale-issue-message: | - [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days. - - It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity. - remove-stale-when-updated: true - exempt-issue-labels: "pinned,security,feature-request,on-hold" - start-date: "2025-12-27" diff --git a/bun.lock b/bun.lock index a4746ad5cb..82a785226b 100644 --- a/bun.lock +++ b/bun.lock @@ -329,7 +329,7 @@ "@effect/platform-node": "catalog:", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", - "@modelcontextprotocol/sdk": "1.25.2", + "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -1325,7 +1325,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], @@ -2889,7 +2889,7 @@ "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], @@ -5129,6 +5129,8 @@ "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -6311,6 +6313,8 @@ "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], "opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], diff --git a/github/index.ts b/github/index.ts index 1a0a992622..6bfa964623 100644 --- a/github/index.ts +++ b/github/index.ts @@ -496,7 +496,6 @@ async function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", "\x1b[33m\x1b[1m"], - todoread: ["Todo", "\x1b[33m\x1b[1m"], bash: ["Bash", "\x1b[31m\x1b[1m"], edit: ["Edit", "\x1b[32m\x1b[1m"], glob: ["Glob", "\x1b[34m\x1b[1m"], diff --git a/nix/hashes.json b/nix/hashes.json index e84178a402..78f82e5970 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=", - "aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=", - "aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=", - "x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg=" + "x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=", + "aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=", + "aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=", + "x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg=" } } diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 2667b89a1c..d70eb9a8eb 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,7 +6,8 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI -const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined +const workers = + Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? (process.platform === "win32" ? 2 : 5) : 0)) || undefined export default defineConfig({ testDir: "./e2e", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5247c951d3..0eb5b4e9e0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme" +import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 734958dd58..e7eaa1fb29 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ 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 { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const methods = createMemo( - () => - globalSync.data.provider_auth[props.provider] ?? [ - { - type: "api", - label: language.t("provider.connect.method.apiKey"), - }, - ], + const fallback = createMemo(() => [ + { + type: "api" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [auth] = createResource( + () => props.provider, + async () => { + const cached = globalSync.data.provider_auth[props.provider] + if (cached) return cached + const res = await globalSDK.client.provider.auth() + if (!alive.value) return fallback() + globalSync.set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) + const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo(() => method()?.prompts ?? []) + const prompts = createMemo>(() => { + const value = method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - onMount(() => { + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return if (methods().length === 1) { + auto = true selectMethod(0) } }) @@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // 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() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +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 type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" 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" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts index d804195c40..c8e2dbb5d0 100644 --- a/packages/app/src/context/command-keybind.test.ts +++ b/packages/app/src/context/command-keybind.test.ts @@ -32,6 +32,25 @@ describe("command keybind helpers", () => { expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) }) + test("matchKeybind supports bracket keys", () => { + const keybinds = parseKeybind("mod+alt+[, mod+alt+]") + const prev = keybinds[0] + const next = keybinds[1] + + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }), + ), + ).toBe(true) + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }), + ), + ).toBe(true) + }) + test("formatKeybind returns human readable output", () => { const display = formatKeybind("ctrl+alt+arrowup") diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..4790011a53 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" -import { cmp, normalizeProviderList } from "./utils" +import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" type GlobalStore = { @@ -31,73 +31,102 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -111,6 +140,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string -}) { - if (input.store.status !== "complete") input.setStore("status", "loading") - - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + global: { + config: Config + project: Project[] + provider: ProviderListResponse } +}) { + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + input.setStore("provider", input.global.provider) + } + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", input.global.config) + } + if (loading) input.setStore("status", "partial") - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] + + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errs[0], input.translate), }) - input.setStore("status", "partial") - return } - if (input.store.status !== "complete") input.setStore("status", "partial") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index cf2da135cb..892129788e 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => { }) test("updates vcs branch in store and cache", () => { - const [store, setStore] = createStore(baseState()) - const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) + const [cacheStore, setCacheStore] = createStore({ + value: { branch: "main", default_branch: "main" } as State["vcs"], + }) applyDirectoryEvent({ event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, @@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => { }, }) - expect(store.vcs).toEqual({ branch: "feature/test" }) - expect(cacheStore.value).toEqual({ branch: "feature/test" }) + expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) + expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) }) test("routes disposal and lsp events to side-effect handlers", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5d8b7c4e3d..4af6365535 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts new file mode 100644 index 0000000000..6d44ac9a89 --- /dev/null +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import type { Agent } from "@opencode-ai/sdk/v2/client" +import { normalizeAgentList } from "./utils" + +const agent = (name = "build") => + ({ + name, + mode: "primary", + permission: {}, + options: {}, + }) as Agent + +describe("normalizeAgentList", () => { + test("keeps array payloads", () => { + expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) + }) + + test("wraps a single agent payload", () => { + expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + }) + + test("extracts agents from keyed objects", () => { + expect( + normalizeAgentList({ + build: agent("build"), + docs: agent("docs"), + }), + ).toEqual([agent("build"), agent("docs")]) + }) + + test("drops invalid payloads", () => { + expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) + expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) + }) +}) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 6b78134a61..cac58f3174 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,7 +1,21 @@ -import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +function isAgent(input: unknown): input is Agent { + if (!input || typeof input !== "object") return false + const item = input as { name?: unknown; mode?: unknown } + if (typeof item.name !== "string") return false + return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" +} + +export function normalizeAgentList(input: unknown): Agent[] { + if (Array.isArray(input)) return input.filter(isAgent) + if (isAgent(input)) return [input] + if (!input || typeof input !== "object") return [] + return Object.values(input).filter(isAgent) +} + export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c8f58c796e..6e40e03007 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -722,8 +722,6 @@ export const dict = { "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة", - "settings.permissions.tool.todoread.title": "قراءة المهام", - "settings.permissions.tool.todoread.description": "قراءة قائمة المهام", "settings.permissions.tool.todowrite.title": "كتابة المهام", "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام", "settings.permissions.tool.webfetch.title": "جلب الويب", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 3112e91bbe..3c7ef9d828 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -732,8 +732,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem", - "settings.permissions.tool.todoread.title": "Ler Tarefas", - "settings.permissions.tool.todoread.description": "Ler a lista de tarefas", "settings.permissions.tool.todowrite.title": "Escrever Tarefas", "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas", "settings.permissions.tool.webfetch.title": "Buscar Web", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index f2dbd8493c..15b73453be 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -806,8 +806,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera", - "settings.permissions.tool.todoread.title": "Čitanje liste zadataka", - "settings.permissions.tool.todoread.description": "Čitanje liste zadataka", "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka", "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka", "settings.permissions.tool.webfetch.title": "Web preuzimanje", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index e90e1071ad..55212faccd 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -800,8 +800,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler", - "settings.permissions.tool.todoread.title": "Læs To-do", - "settings.permissions.tool.todoread.description": "Læs to-do listen", "settings.permissions.tool.todowrite.title": "Skriv To-do", "settings.permissions.tool.todowrite.description": "Opdater to-do listen", "settings.permissions.tool.webfetch.title": "Webhentning", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 69658b29e9..552375f572 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -743,8 +743,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen", - "settings.permissions.tool.todoread.title": "Todo lesen", - "settings.permissions.tool.todoread.description": "Die Todo-Liste lesen", "settings.permissions.tool.todowrite.title": "Todo schreiben", "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren", "settings.permissions.tool.webfetch.title": "Web-Abruf", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 579b740d3a..bdf97ec0fe 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -535,6 +535,8 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", @@ -900,8 +902,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Load a skill by name", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Run language server queries", - "settings.permissions.tool.todoread.title": "Todo Read", - "settings.permissions.tool.todoread.description": "Read the todo list", "settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.description": "Update the todo list", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 9e36e4de6d..31fd71c044 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -813,8 +813,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje", - "settings.permissions.tool.todoread.title": "Leer Todo", - "settings.permissions.tool.todoread.description": "Leer la lista de tareas", "settings.permissions.tool.todowrite.title": "Escribir Todo", "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f53b3882c6..e19282a76e 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -741,8 +741,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Charger une compétence par son nom", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage", - "settings.permissions.tool.todoread.title": "Lire Todo", - "settings.permissions.tool.todoread.description": "Lire la liste de tâches", "settings.permissions.tool.todowrite.title": "Écrire Todo", "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches", "settings.permissions.tool.webfetch.title": "Récupération Web", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index d66a7341d5..52e4ab6ed9 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -727,8 +727,6 @@ export const dict = { "settings.permissions.tool.skill.description": "名前によるスキルの読み込み", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "言語サーバークエリの実行", - "settings.permissions.tool.todoread.title": "Todo読み込み", - "settings.permissions.tool.todoread.description": "Todoリストの読み込み", "settings.permissions.tool.todowrite.title": "Todo書き込み", "settings.permissions.tool.todowrite.description": "Todoリストの更新", "settings.permissions.tool.webfetch.title": "Web取得", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index d534c27e8f..8d9efabb63 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -726,8 +726,6 @@ export const dict = { "settings.permissions.tool.skill.description": "이름으로 기술 로드", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행", - "settings.permissions.tool.todoread.title": "할 일 읽기", - "settings.permissions.tool.todoread.description": "할 일 목록 읽기", "settings.permissions.tool.todowrite.title": "할 일 쓰기", "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트", "settings.permissions.tool.webfetch.title": "웹 가져오기", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c23d0a2792..7342ec083d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -807,8 +807,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Last en ferdighet etter navn", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler", - "settings.permissions.tool.todoread.title": "Les gjøremål", - "settings.permissions.tool.todoread.description": "Les gjøremålslisten", "settings.permissions.tool.todowrite.title": "Skriv gjøremål", "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten", "settings.permissions.tool.webfetch.title": "Webhenting", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index dac847b217..d3a3d62662 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -729,8 +729,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego", - "settings.permissions.tool.todoread.title": "Odczyt Todo", - "settings.permissions.tool.todoread.description": "Odczyt listy zadań", "settings.permissions.tool.todowrite.title": "Zapis Todo", "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań", "settings.permissions.tool.webfetch.title": "Pobieranie z sieci", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 684d5deecd..ac02f8dbeb 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -808,8 +808,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Загрузка навыка по имени", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Запросы к языковому серверу", - "settings.permissions.tool.todoread.title": "Todo Read", - "settings.permissions.tool.todoread.description": "Чтение списка задач", "settings.permissions.tool.todowrite.title": "Todo Write", "settings.permissions.tool.todowrite.description": "Обновление списка задач", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 80f0da94ec..8d146123f2 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -796,8 +796,6 @@ export const dict = { "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา", - "settings.permissions.tool.todoread.title": "อ่านรายการงาน", - "settings.permissions.tool.todoread.description": "อ่านรายการงาน", "settings.permissions.tool.todowrite.title": "เขียนรายการงาน", "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน", "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 9041e0dd07..fb3c0c26f6 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -816,8 +816,6 @@ export const dict = { "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır", - "settings.permissions.tool.todoread.title": "Görev Oku", - "settings.permissions.tool.todoread.description": "Görev listesini oku", "settings.permissions.tool.todowrite.title": "Görev Yaz", "settings.permissions.tool.todowrite.description": "Görev listesini güncelle", "settings.permissions.tool.webfetch.title": "Web Getir", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index cf64ca9b2c..2a7ababb2b 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -795,8 +795,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名称加载技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "运行语言服务器查询", - "settings.permissions.tool.todoread.title": "读取待办", - "settings.permissions.tool.todoread.description": "读取待办列表", "settings.permissions.tool.todowrite.title": "更新待办", "settings.permissions.tool.todowrite.description": "更新待办列表", "settings.permissions.tool.webfetch.title": "网页获取", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 02c00d17a2..8ee29733ef 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -790,8 +790,6 @@ export const dict = { "settings.permissions.tool.skill.description": "按名稱載入技能", "settings.permissions.tool.lsp.title": "LSP", "settings.permissions.tool.lsp.description": "執行語言伺服器查詢", - "settings.permissions.tool.todoread.title": "讀取待辦", - "settings.permissions.tool.todoread.description": "讀取待辦清單", "settings.permissions.tool.todowrite.title": "更新待辦", "settings.permissions.tool.todowrite.description": "更新待辦清單", "settings.permissions.tool.webfetch.title": "Web Fetch", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1300f88a80..b5a96110f6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) { : projects[(index + offset + projects.length) % projects.length] if (!target) return + // warm up child store to prevent flicker + globalSync.child(target.worktree) openProject(target.worktree) } @@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 17a5add089..93e0ee98b6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" import { @@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] +type ChangeMode = "git" | "branch" | "session" | "turn" +type VcsMode = "git" | "branch" + type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -424,15 +427,16 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasReview = createMemo(() => reviewCount() > 0) + const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasSessionReview = createMemo(() => sessionCount() > 0) + const canReview = createMemo(() => !!params.id) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview, + hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -455,6 +459,12 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasSessionReview()) return true + return sync.data.session_diff[id] !== undefined + }) const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -508,11 +518,22 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "session" as "session" | "turn", + changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) + const [vcs, setVcs] = createStore({ + diff: { + git: [] as FileDiff[], + branch: [] as FileDiff[], + }, + ready: { + git: false, + branch: false, + }, + }) + const [followup, setFollowup] = createStore({ items: {} as Record, failed: {} as Record, @@ -539,6 +560,68 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined + const vcsTask = new Map>() + const vcsRun = new Map() + + const bumpVcs = (mode: VcsMode) => { + const next = (vcsRun.get(mode) ?? 0) + 1 + vcsRun.set(mode, next) + return next + } + + const resetVcs = (mode?: VcsMode) => { + const list = mode ? [mode] : (["git", "branch"] as const) + list.forEach((item) => { + bumpVcs(item) + vcsTask.delete(item) + setVcs("diff", item, []) + setVcs("ready", item, false) + }) + } + + const loadVcs = (mode: VcsMode, force = false) => { + if (sync.project?.vcs !== "git") return Promise.resolve() + if (!force && vcs.ready[mode]) return Promise.resolve() + + if (force) { + if (vcsTask.has(mode)) bumpVcs(mode) + vcsTask.delete(mode) + setVcs("ready", mode, false) + } + + const current = vcsTask.get(mode) + if (current) return current + + const run = bumpVcs(mode) + + const task = sdk.client.vcs + .diff({ mode }) + .then((result) => { + if (vcsRun.get(mode) !== run) return + setVcs("diff", mode, result.data ?? []) + setVcs("ready", mode, true) + }) + .catch((error) => { + if (vcsRun.get(mode) !== run) return + console.debug("[session-review] failed to load vcs diff", { mode, error }) + setVcs("diff", mode, []) + setVcs("ready", mode, true) + }) + .finally(() => { + if (vcsTask.get(mode) === task) vcsTask.delete(mode) + }) + + vcsTask.set(mode, task) + return task + } + + const refreshVcs = () => { + resetVcs() + const mode = untrack(vcsMode) + if (!mode) return + if (!untrack(wantsReview)) return + void loadVcs(mode, true) + } createComputed((prev) => { const open = desktopReviewOpen() @@ -554,7 +637,42 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const changesOptions = createMemo(() => { + const list: ChangeMode[] = [] + if (sync.project?.vcs === "git") list.push("git") + if ( + sync.project?.vcs === "git" && + sync.data.vcs?.branch && + sync.data.vcs?.default_branch && + sync.data.vcs.branch !== sync.data.vcs.default_branch + ) { + list.push("branch") + } + list.push("session", "turn") + return list + }) + const vcsMode = createMemo(() => { + if (store.changes === "git" || store.changes === "branch") return store.changes + }) + const reviewDiffs = createMemo(() => { + if (store.changes === "git") return vcs.diff.git + if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "session") return diffs() + return turnDiffs() + }) + const reviewCount = createMemo(() => { + if (store.changes === "git") return vcs.diff.git.length + if (store.changes === "branch") return vcs.diff.branch.length + if (store.changes === "session") return sessionCount() + return turnDiffs().length + }) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + if (store.changes === "git") return vcs.ready.git + if (store.changes === "branch") return vcs.ready.branch + if (store.changes === "session") return !hasSessionReview() || diffsReady() + return true + }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -620,13 +738,7 @@ export default function Page() { scrollToMessage(msgs[targetIndex], "auto") } - const diffsReady = createMemo(() => { - const id = params.id - if (!id) return true - if (!hasReview()) return true - return sync.data.session_diff[id] !== undefined - }) - const reviewEmptyKey = createMemo(() => { + const sessionEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -748,13 +860,46 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "session") + setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) + createEffect( + on( + () => sdk.directory, + () => { + resetVcs() + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const, + (next, prev) => { + if (prev === undefined || same(next, prev)) return + refreshVcs() + }, + { defer: true }, + ), + ) + + const stopVcs = sdk.event.listen((evt) => { + if (evt.details.type !== "file.watcher.updated") return + const props = + typeof evt.details.properties === "object" && evt.details.properties + ? (evt.details.properties as Record) + : undefined + const file = typeof props?.file === "string" ? props.file : undefined + if (!file || file.startsWith(".git/")) return + refreshVcs() + }) + onCleanup(stopVcs) + createEffect( on( () => params.dir, @@ -877,6 +1022,40 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ) + + createEffect(() => { + const list = changesOptions() + if (list.includes(store.changes)) return + const next = list[0] + if (!next) return + setStore("changes", next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + void loadVcs(mode) + }) + + createEffect( + on( + () => sync.data.session_status[params.id ?? ""]?.type, + (next, prev) => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void loadVcs(mode, true) + }, + { defer: true }, + ), + ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -923,21 +1102,23 @@ export default function Page() { loadFile: file.load, }) - const changesOptions = ["session", "turn"] as const - const changesOptionsList = [...changesOptions] - const changesTitle = () => { - if (!hasReview()) { + if (!canReview()) { return null } + const label = (option: ChangeMode) => { + if (option === "git") return language.t("ui.sessionReview.title.git") + if (option === "branch") return language.t("ui.sessionReview.title.branch") + if (option === "session") return language.t("ui.sessionReview.title") + return language.t("ui.sessionReview.title.lastTurn") + } + return ( +
+ +
+ {loaded()} • {session().title || session().id} • {state.messages.length} message + {state.messages.length === 1 ? "" : "s"} +
+
+ +
+ {issue()} +
+
+ + {/* ---- User messages ---- */} +
User messages
+
+ Creates a new turn (user + empty assistant) +
+
+ + {(key) => ( + + )} + +
+
+ +
+ + {/* ---- Text and reasoning blocks ---- */} +
Text and reasoning blocks
+
+ Appends to the last turn's assistant parts +
+
+ + {(key) => ( + + )} + + +
+ + {/* ---- Tool calls ---- */} +
Tool calls
+
+ Appends to the last turn's assistant parts +
+
+ + {(key) => ( + + )} + +
+ + {/* ---- Composite (full turns) ---- */} +
Composite turns
+
+ Creates complete user + assistant turns +
+
+ + + +
+ +
+ +
+
+ + + + {/* CSS Controls section */} +
+ + +
+ + + + {([group, controls]) => ( +
+ + +
+ + {(ctrl) => ( +
+
+ + + {css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial} + {ctrl.unit ?? ""} + +
+ setCssValue(ctrl.key, e.currentTarget.value)} + style={{ + width: "100%", + height: "4px", + "accent-color": "var(--text-interactive-base)", + cursor: "pointer", + }} + /> +
+ )} +
+
+
+
+ )} +
+
+
+
+ + {/* Export section */} +
+ + +
+ + + 0}> +
+ + {(ctrl) => ( +
+ {ctrl.source!.file}: {ctrl.property} = {css[ctrl.key]} + {ctrl.unit} +
+ )} +
+
+
+ +
+                  {applyResult()}
+                
+
+ +
+                  {exported()}
+                
+
+
+
+
+ + + {/* Main area: timeline preview */} +
+ + +
+ 0} + fallback={ +
+ Click a generator button or import a session +
+ } + > +
+ + {(msg) => ( +
+ +
+ )} +
+
+
+
+
+
+
+ + ) +} + +// --------------------------------------------------------------------------- +// Story export +// --------------------------------------------------------------------------- +export default { + title: "Playground/Timeline", + id: "playground-timeline", + parameters: { + layout: "fullscreen", + }, +} + +export const Basic = { + render: () => , +} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 18823aeaa1..d9f724fce1 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,5 +1,7 @@ export const dict: Record = { "ui.sessionReview.title": "Session changes", + "ui.sessionReview.title.git": "Git changes", + "ui.sessionReview.title.branch": "Branch changes", "ui.sessionReview.title.lastTurn": "Last turn changes", "ui.sessionReview.diffStyle.unified": "Unified", "ui.sessionReview.diffStyle.split": "Split", diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - window.addEventListener("storage", (e) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index de27ccd533..de12baede0 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -355,7 +355,6 @@ export default function Share(props: { if (x.type === "patch") return false if (x.type === "step-finish") return false if (x.type === "text" && x.synthetic === true) return false - if (x.type === "tool" && x.tool === "todoread") return false if (x.type === "text" && !x.text) return false if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) return false diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 45bd97fe33..c7d177df7d 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -90,9 +90,6 @@ export function Part(props: PartProps) { - - - diff --git a/packages/web/src/content/docs/ar/modes.mdx b/packages/web/src/content/docs/ar/modes.mdx index f4889819aa..ac57b98e96 100644 --- a/packages/web/src/content/docs/ar/modes.mdx +++ b/packages/web/src/content/docs/ar/modes.mdx @@ -236,7 +236,6 @@ Provide constructive feedback without making direct changes. | `list` | سرد محتويات الدليل | | `patch` | تطبيق تصحيحات على الملفات | | `todowrite` | إدارة قوائم المهام | -| `todoread` | قراءة قوائم المهام | | `webfetch` | جلب محتوى الويب | --- diff --git a/packages/web/src/content/docs/ar/permissions.mdx b/packages/web/src/content/docs/ar/permissions.mdx index ee22c951d4..4391514b43 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -138,7 +138,6 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `task` — تشغيل وكلاء فرعيين (يطابق نوع الوكيل الفرعي) - `skill` — تحميل مهارة (يطابق اسم المهارة) - `lsp` — تشغيل استعلامات LSP (حاليًا دون قواعد دقيقة) -- `todoread`, `todowrite` — قراءة/تحديث قائمة المهام - `webfetch` — جلب عنوان URL (يطابق الـ URL) - `websearch`, `codesearch` — بحث الويب/الكود (يطابق الاستعلام) - `external_directory` — يُفعَّل عندما تلمس أداة مسارات خارج دليل عمل المشروع diff --git a/packages/web/src/content/docs/ar/tools.mdx b/packages/web/src/content/docs/ar/tools.mdx index fde4403569..d820778b40 100644 --- a/packages/web/src/content/docs/ar/tools.mdx +++ b/packages/web/src/content/docs/ar/tools.mdx @@ -248,27 +248,6 @@ description: إدارة الأدوات التي يمكن لـ LLM استخدام --- -### todoread - -اقرأ قوائم المهام الموجودة. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -تقرأ هذه الأداة الحالة الحالية لقائمة المهام. يستخدمها LLM لتتبع المهام المعلقة أو المكتملة. - -:::note -هذه الأداة معطلة للوكلاء الفرعيين افتراضيا، لكن يمكنك تفعيلها يدويا. [اعرف المزيد](/docs/agents/#permissions) -::: - ---- - ### webfetch اجلب محتوى الويب. diff --git a/packages/web/src/content/docs/bs/modes.mdx b/packages/web/src/content/docs/bs/modes.mdx index 9cff5d5840..6bf4bd27ca 100644 --- a/packages/web/src/content/docs/bs/modes.mdx +++ b/packages/web/src/content/docs/bs/modes.mdx @@ -222,7 +222,6 @@ Ovdje su svi alati koji se mogu kontrolirati kroz konfiguraciju načina rada. | `list` | Lista sadržaja direktorija | | `patch` | Primijenite zakrpe na datoteke | | `todowrite` | Upravljanje listama zadataka | -| `todoread` | Pročitajte liste obaveza | | `webfetch` | Dohvati web sadržaj | --- diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx index 8b2061ee0d..b6a194ad28 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -133,7 +133,6 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `task` — pokretanje subagenta (odgovara tipu podagenta) - `skill` — učitavanje vještine (odgovara nazivu vještine) - `lsp` — pokretanje LSP upita (trenutno negranularno) -- `todoread`, `todowrite` — čitanje/ažuriranje liste obaveza - `webfetch` — dohvaćanje URL-a (odgovara URL-u) - `websearch`, `codesearch` — pretraživanje weba/koda (odgovara upitu) - `external_directory` — pokreće se kada alat dodirne staze izvan radnog direktorija projekta diff --git a/packages/web/src/content/docs/bs/tools.mdx b/packages/web/src/content/docs/bs/tools.mdx index cef02ddda1..d0ae9a4460 100644 --- a/packages/web/src/content/docs/bs/tools.mdx +++ b/packages/web/src/content/docs/bs/tools.mdx @@ -248,27 +248,6 @@ Ovaj alat je po defaultu iskljucen za subagente, ali ga mozete rucno ukljuciti. --- -### todoread - -Cita postojece todo liste. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Cita trenutno stanje todo liste. LLM ga koristi da prati sta je na cekanju i sta je zavrseno. - -:::note -Ovaj alat je po defaultu iskljucen za subagente, ali ga mozete rucno ukljuciti. [Saznajte vise](/docs/agents/#permissions) -::: - ---- - ### webfetch Preuzima web sadrzaj. diff --git a/packages/web/src/content/docs/da/modes.mdx b/packages/web/src/content/docs/da/modes.mdx index 40a5303f20..34fb2b3595 100644 --- a/packages/web/src/content/docs/da/modes.mdx +++ b/packages/web/src/content/docs/da/modes.mdx @@ -236,7 +236,6 @@ Her er alle de værktøjer, der kan styres gennem tilstandskonfigurationen. | `list` | Liste biblioteksindhold | | `patch` | Anvend patches til filer | | `todowrite` | Administrer todo-lister | -| `todoread` | Læs todo-lister | | `webfetch` | Hent webindhold | --- diff --git a/packages/web/src/content/docs/da/permissions.mdx b/packages/web/src/content/docs/da/permissions.mdx index 72c839833f..72ebff606c 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -138,7 +138,6 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `task` — lancering af underagenter (matcher underagenttypen) - `skill` — indlæsning af en færdighed (matcher færdighedsnavnet) - `lsp` — kører LSP forespørgsler (i øjeblikket ikke-granulære) -- `todoread`, `todowrite` — reading/updating todo-listen - `webfetch` — henter en URL (matcher URL) - `websearch`, `codesearch` — web/code søgning (matcher forespørgslen) - `external_directory` — udløses, når et værktøj berører stier uden for projektets arbejdsmappe diff --git a/packages/web/src/content/docs/da/tools.mdx b/packages/web/src/content/docs/da/tools.mdx index 2b8b20b15c..a610e8cc39 100644 --- a/packages/web/src/content/docs/da/tools.mdx +++ b/packages/web/src/content/docs/da/tools.mdx @@ -248,27 +248,6 @@ Dette verktøyet er deaktivert for subagenter som standard, men du kan aktivere --- -### todoread - -Les eksisterende to-doslister. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Leser nuværende to-doslistestatus. Bruges av LLM for at spore hvilke oppgaver som venter eller er fullført. - -:::note -Dette verktøyet er deaktivert for subagenter som standard, men du kan aktivere det manuelt. [Finn ut mer](/docs/agents/#permissions) -::: - ---- - ### webfetch Hent nettinnhold. diff --git a/packages/web/src/content/docs/de/modes.mdx b/packages/web/src/content/docs/de/modes.mdx index b0d9393073..38a2e34b38 100644 --- a/packages/web/src/content/docs/de/modes.mdx +++ b/packages/web/src/content/docs/de/modes.mdx @@ -236,7 +236,6 @@ Hier sind alle Tools aufgeführt, die über den Konfigurationsmodus gesteuert we | `list` | Verzeichnisinhalte auflisten | | `patch` | Patches auf Dateien anwenden | | `todowrite` | Aufgabenlisten verwalten | -| `todoread` | Aufgabenlisten lesen | | `webfetch` | Webinhalte abrufen | --- diff --git a/packages/web/src/content/docs/de/permissions.mdx b/packages/web/src/content/docs/de/permissions.mdx index 0fb23831aa..ba7c802040 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -138,7 +138,6 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `task` – Subagenten starten (entspricht dem Subagententyp) - `skill` – Laden einer Fertigkeit (entspricht dem Fertigkeitsnamen) - `lsp` – Ausführen von LSP-Abfragen (derzeit nicht granular) -- `todoread`, `todowrite` – lesen/aktualisieren der Aufgabenliste - `webfetch` – Abrufen eines URL (entspricht dem URL) - `websearch`, `codesearch` – web/code Suche (entspricht der Abfrage) - `external_directory` – wird ausgelöst, wenn ein Tool Pfade außerhalb des Projektarbeitsverzeichnisses berührt diff --git a/packages/web/src/content/docs/de/tools.mdx b/packages/web/src/content/docs/de/tools.mdx index 0038f25184..b33163df85 100644 --- a/packages/web/src/content/docs/de/tools.mdx +++ b/packages/web/src/content/docs/de/tools.mdx @@ -255,27 +255,6 @@ Dieses Tool ist fuer Sub-Agenten standardmaessig deaktiviert, kann aber manuell --- -### todoread - -Liest existierende Todo-Listen. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Liest den aktuellen Status der Todo-Liste. Wird vom LLM genutzt, um offene oder erledigte Aufgaben zu verfolgen. - -:::note -Dieses Tool ist fuer Sub-Agenten standardmaessig deaktiviert, kann aber manuell aktiviert werden. [Mehr dazu](/docs/agents/#permissions) -::: - ---- - ### webfetch Ruft Webinhalte ab. diff --git a/packages/web/src/content/docs/es/modes.mdx b/packages/web/src/content/docs/es/modes.mdx index c57e8e6a95..cefc4a4e2d 100644 --- a/packages/web/src/content/docs/es/modes.mdx +++ b/packages/web/src/content/docs/es/modes.mdx @@ -236,7 +236,6 @@ Aquí están todas las herramientas que se pueden controlar a través del modo d | `list` | Listar el contenido del directorio | | `patch` | Aplicar parches a archivos | | `todowrite` | Administrar listas de tareas pendientes | -| `todoread` | Leer listas de tareas pendientes | | `webfetch` | Obtener contenido web | --- diff --git a/packages/web/src/content/docs/es/permissions.mdx b/packages/web/src/content/docs/es/permissions.mdx index 3ebe67fd2c..603b3bdb3f 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -138,7 +138,6 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `task` — lanzamiento de subagentes (coincide con el tipo de subagente) - `skill` — cargar una habilidad (coincide con el nombre de la habilidad) - `lsp`: ejecución de consultas LSP (actualmente no granulares) -- `todoread`, `todowrite` — leer/actualizar la lista de tareas pendientes - `webfetch` — obteniendo una URL (coincide con la URL) - `websearch`, `codesearch` — búsqueda web/código (coincide con la consulta) - `external_directory`: se activa cuando una herramienta toca rutas fuera del directorio de trabajo del proyecto. diff --git a/packages/web/src/content/docs/es/tools.mdx b/packages/web/src/content/docs/es/tools.mdx index 69f1340466..f3a050c03b 100644 --- a/packages/web/src/content/docs/es/tools.mdx +++ b/packages/web/src/content/docs/es/tools.mdx @@ -248,27 +248,6 @@ Esta herramienta está deshabilitada para los subagentes de forma predeterminada --- -### todoread - -Leer listas de tareas pendientes existentes. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Lee el estado actual de la lista de tareas pendientes. Utilizado por LLM para rastrear qué tareas están pendientes o completadas. - -:::note -Esta herramienta está deshabilitada para los subagentes de forma predeterminada, pero puede habilitarla manualmente. [Más información](/docs/agents/#permissions) -::: - ---- - ### webfetch Obtener contenido web. diff --git a/packages/web/src/content/docs/fr/modes.mdx b/packages/web/src/content/docs/fr/modes.mdx index 340ce83bd2..8c3ad62e41 100644 --- a/packages/web/src/content/docs/fr/modes.mdx +++ b/packages/web/src/content/docs/fr/modes.mdx @@ -234,7 +234,6 @@ Voici tous les outils pouvant être contrôlés via le mode config. | `list` | Liste du contenu du répertoire | | `patch` | Appliquer des correctifs aux fichiers | | `todowrite` | Gérer les listes de tâches | -| `todoread` | Lire les listes de tâches | | `webfetch` | Récupérer du contenu Web | --- diff --git a/packages/web/src/content/docs/fr/permissions.mdx b/packages/web/src/content/docs/fr/permissions.mdx index 1533987f8d..176fa34ad2 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -138,7 +138,6 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `task` — lancement de sous-agents (correspond au type de sous-agent) - `skill` — chargement d'une compétence (correspond au nom de la compétence) - `lsp` — exécution de requêtes LSP (actuellement non granulaires) -- `todoread`, `todowrite` — lecture/mise à jour de la liste de tâches - `webfetch` — récupérer une URL (correspond à l'URL) - `websearch`, `codesearch` — recherche Web/code (correspond à la requête) - `external_directory` - déclenché lorsqu'un outil touche des chemins en dehors du répertoire de travail du projet diff --git a/packages/web/src/content/docs/fr/tools.mdx b/packages/web/src/content/docs/fr/tools.mdx index 20045e147c..62579c2bf8 100644 --- a/packages/web/src/content/docs/fr/tools.mdx +++ b/packages/web/src/content/docs/fr/tools.mdx @@ -248,27 +248,6 @@ Cet outil est désactivé par défaut pour les sous-agents, mais vous pouvez l'a --- -### todore - -Lisez les listes de tâches existantes. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Lit l'état actuel de la liste de tâches. Utilisé par le LLM pour suivre les tâches en attente ou terminées. - -:::note -Cet outil est désactivé par défaut pour les sous-agents, mais vous pouvez l'activer manuellement. [En savoir plus](/docs/agents/#permissions) -::: - ---- - ### récupération sur le Web Récupérer du contenu Web. diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx index 82e2058700..8f5c22d6e4 100644 --- a/packages/web/src/content/docs/it/modes.mdx +++ b/packages/web/src/content/docs/it/modes.mdx @@ -235,7 +235,6 @@ Ecco tutti gli strumenti che possono essere controllati tramite la configurazion | `list` | Elenca contenuti di una directory | | `patch` | Applica patch ai file | | `todowrite` | Gestisce liste todo | -| `todoread` | Legge liste todo | | `webfetch` | Recupera contenuti web | --- diff --git a/packages/web/src/content/docs/it/permissions.mdx b/packages/web/src/content/docs/it/permissions.mdx index d0f014a841..3f255c89dd 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -138,7 +138,6 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `task` — avvio subagenti (corrisponde al tipo di subagente) - `skill` — caricamento di una skill (corrisponde al nome della skill) - `lsp` — esecuzione query LSP (attualmente non granulare) -- `todoread`, `todowrite` — lettura/aggiornamento della todo list - `webfetch` — fetch di un URL (corrisponde all'URL) - `websearch`, `codesearch` — ricerca web/codice (corrisponde alla query) - `external_directory` — si attiva quando uno strumento tocca percorsi fuori dalla working directory del progetto diff --git a/packages/web/src/content/docs/it/tools.mdx b/packages/web/src/content/docs/it/tools.mdx index afd10b3313..50609fd616 100644 --- a/packages/web/src/content/docs/it/tools.mdx +++ b/packages/web/src/content/docs/it/tools.mdx @@ -248,27 +248,6 @@ Questo strumento e' disabilitato per i subagenti di default, ma puoi abilitarlo --- -### todoread - -Leggi le todo list esistenti. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Legge lo stato corrente della todo list. Usato dall'LLM per tenere traccia di quali task sono in sospeso o completati. - -:::note -Questo strumento e' disabilitato per i subagenti di default, ma puoi abilitarlo manualmente. [Scopri di piu'](/docs/agents/#permissions) -::: - ---- - ### webfetch Recupera contenuti dal web. diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx index 6e7bc121b6..c9f2a4d5ee 100644 --- a/packages/web/src/content/docs/ja/modes.mdx +++ b/packages/web/src/content/docs/ja/modes.mdx @@ -234,7 +234,6 @@ Markdown ファイル名はモード名になります (例: `review.md` は `re | `list` | ディレクトリの内容をリストする | | `patch` | ファイルにパッチを適用する | | `todowrite` | ToDo リストを管理する | -| `todoread` | ToDo リストを読む | | `webfetch` | Web コンテンツを取得する | --- diff --git a/packages/web/src/content/docs/ja/permissions.mdx b/packages/web/src/content/docs/ja/permissions.mdx index 93143f9ca2..5f5df6675c 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -138,7 +138,6 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `task` — サブエージェントの起動 (サブエージェントのタイプと一致) - `skill` — スキルをロードしています(スキル名と一致します) - `lsp` — LSP クエリの実行 (現在は非細分性) -- `todoread`、`todowrite` — ToDo リストの読み取り/更新 - `webfetch` — URL を取得します (URL と一致します) - `websearch`、`codesearch` — Web/コード検索 (クエリと一致) - `external_directory` — ツールがプロジェクトの作業ディレクトリ外のパスにアクセスするとトリガーされます。 diff --git a/packages/web/src/content/docs/ja/tools.mdx b/packages/web/src/content/docs/ja/tools.mdx index 6aa7e956f3..0e0f8fe951 100644 --- a/packages/web/src/content/docs/ja/tools.mdx +++ b/packages/web/src/content/docs/ja/tools.mdx @@ -248,27 +248,6 @@ OpenCode で利用可能なすべての組み込みツールを次に示しま --- -### todoread - -既存の ToDo リストを読み取ります。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -現在の Todo リストの状態を読み取ります。どのタスクが保留中または完了しているかを追跡するために LLM によって使用されます。 - -:::note -このツールはデフォルトではサブエージェントに対して無効になっていますが、手動で有効にすることができます。 [詳細はこちら](/docs/agents/#permissions) -::: - ---- - ### webfetch Web コンテンツを取得します。 diff --git a/packages/web/src/content/docs/ko/modes.mdx b/packages/web/src/content/docs/ko/modes.mdx index 4daa410001..35bc4d2264 100644 --- a/packages/web/src/content/docs/ko/modes.mdx +++ b/packages/web/src/content/docs/ko/modes.mdx @@ -235,7 +235,6 @@ Markdown 파일 이름은 모드 이름 (예 : `review.md`는 `review` 모드를 | `list` | 디렉토리 내용 보기 | | `patch` | 파일에 패치 적용 | | `todowrite` | 할 일(Todo) 목록 관리 | -| `todoread` | 할 일(Todo) 목록 읽기 | | `webfetch` | 웹사이트 가져오기 | --- diff --git a/packages/web/src/content/docs/ko/permissions.mdx b/packages/web/src/content/docs/ko/permissions.mdx index ac698b7cfb..ec129f45c0 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -138,7 +138,6 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `task` - 에이전트 실행 (작업 에이전트 유형) - `skill` - 기술을 로딩 (기술 이름을 매칭) - `lsp` - LSP 쿼리 실행 (현재 비 과립) -- `todoread`, `todowrite` - 토도 목록의 읽기 / 업데이트 - `webfetch` - URL을 fetching ( URL을 매칭) - `websearch`, `codesearch` - 웹 / 코드 검색 (문자 쿼리) - `external_directory` - 프로젝트 작업 디렉토리 외부의 도구 접촉 경로 때 트리거 diff --git a/packages/web/src/content/docs/ko/tools.mdx b/packages/web/src/content/docs/ko/tools.mdx index c9f4fdaf63..33976b66ff 100644 --- a/packages/web/src/content/docs/ko/tools.mdx +++ b/packages/web/src/content/docs/ko/tools.mdx @@ -248,27 +248,6 @@ LSP 서버가 프로젝트에 사용할 수 있는 구성하려면 [LSP Servers] --- -#### todoread - -기존의 todo 목록 읽기. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -현재 todo 목록 상태를 읽습니다. 작업이 종료되거나 완료되는 것을 추적하기 위해 LLM에 의해 사용됩니다. - -:::note -이 도구는 기본으로 시약을 비활성화하지만 수동으로 활성화 할 수 있습니다. [더 알아보기](/docs/agents/#permissions) -::: - ---- - #### webfetch Fetch 웹 콘텐츠. diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index 57c1c54a95..5f23df2540 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -236,7 +236,6 @@ Here are all the tools can be controlled through the mode config. | `list` | List directory contents | | `patch` | Apply patches to files | | `todowrite` | Manage todo lists | -| `todoread` | Read todo lists | | `webfetch` | Fetch web content | --- diff --git a/packages/web/src/content/docs/nb/modes.mdx b/packages/web/src/content/docs/nb/modes.mdx index ccf9180e4e..bf73ff040f 100644 --- a/packages/web/src/content/docs/nb/modes.mdx +++ b/packages/web/src/content/docs/nb/modes.mdx @@ -235,7 +235,6 @@ Her er alle verktøyene som kan kontrolleres gjennom moduskonfigurasjonen. | `list` | List opp kataloginnhold | | `patch` | Bruk patcher på filer | | `todowrite` | Administrer gjøremålslister | -| `todoread` | Les gjøremålslister | | `webfetch` | Hent webinnhold | --- diff --git a/packages/web/src/content/docs/nb/permissions.mdx b/packages/web/src/content/docs/nb/permissions.mdx index e551a7fee3..6437555a2f 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -138,7 +138,6 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `task` — start av subagenter (tilsvarer subagenttypen) - `skill` — laster en ferdighet (tilsvarer navnet på ferdigheten) - `lsp` — kjører LSP-spørringer (for øyeblikket ikke-granulære) -- `todoread`, `todowrite` — lesing/oppdatering av gjøremålslisten - `webfetch` — henter en URL (tilsvarer URL) - `websearch`, `codesearch` - nett-/kodesøk (samsvarer med søket) - `external_directory` - utløses når et verktøy berører stier utenfor prosjektets arbeidskatalog diff --git a/packages/web/src/content/docs/nb/tools.mdx b/packages/web/src/content/docs/nb/tools.mdx index 089a0cbb52..be80a0e2ba 100644 --- a/packages/web/src/content/docs/nb/tools.mdx +++ b/packages/web/src/content/docs/nb/tools.mdx @@ -248,27 +248,6 @@ Dette verktøyet er deaktivert for subagenter som standard, men du kan aktivere --- -### todoread - -Les eksisterende gjøremålslister. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Leser gjeldende gjøremålslistestatus. Brukes av LLM for å spore hvilke oppgaver som venter eller er fullført. - -:::note -Dette verktøyet er deaktivert for subagenter som standard, men du kan aktivere det manuelt. [Finn ut mer](/docs/agents/#permissions) -::: - ---- - ### webfetch Hent nettinnhold. diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index d48c2a084b..e4b82942d2 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -138,7 +138,6 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `task` — launching subagents (matches the subagent type) - `skill` — loading a skill (matches the skill name) - `lsp` — running LSP queries (currently non-granular) -- `todoread`, `todowrite` — reading/updating the todo list - `webfetch` — fetching a URL (matches the URL) - `websearch`, `codesearch` — web/code search (matches the query) - `external_directory` — triggered when a tool touches paths outside the project working directory diff --git a/packages/web/src/content/docs/pl/modes.mdx b/packages/web/src/content/docs/pl/modes.mdx index 52ab163346..b28b160866 100644 --- a/packages/web/src/content/docs/pl/modes.mdx +++ b/packages/web/src/content/docs/pl/modes.mdx @@ -236,7 +236,6 @@ Oto wszystkie narzędzia, które można sterować za pomocą konfiguracji trybó | `list` | Lista zawartości katalogu | | `patch` | Zastosuj poprawki do plików | | `todowrite` | Zarządzaj listami rzeczy do wykonania | -| `todoread` | Przeczytaj listy rzeczy do zrobienia | | `webfetch` | Pobierz zawartość internetową | --- diff --git a/packages/web/src/content/docs/pl/permissions.mdx b/packages/web/src/content/docs/pl/permissions.mdx index c9f50a8aa7..6a7840ac72 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -138,7 +138,6 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `task` — uruchamianie podagentów (odpowiada typowi podagenta) - `skill` — ładowanie umiejętności (pasuje do nazwy umiejętności) - `lsp` — uruchamianie zapytań LSP (obecnie nieszczegółowych) -- `todoread`, `todowrite` — czytanie/aktualizacja list rzeczy do wykonania - `webfetch` — pobieranie adresu URL (pasuje do adresu URL) - `websearch`, `codesearch` — wyszukiwanie sieci/kodu (pasuje do zapytań) - `external_directory` — wywoływacz, gdy narzędzie jest dostępne poza katalogiem roboczym projektu diff --git a/packages/web/src/content/docs/pl/tools.mdx b/packages/web/src/content/docs/pl/tools.mdx index 3e5fd1540d..649c744e04 100644 --- a/packages/web/src/content/docs/pl/tools.mdx +++ b/packages/web/src/content/docs/pl/tools.mdx @@ -248,27 +248,6 @@ To narzędzie jest domyślnie wyłączone dla subagentów, ale można je włącz --- -### todoread - -Odczytuj istniejące listy zadań (todo). - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Odczytuje bieżący stan listy rzeczy do zrobienia. Używany przez LLM do śledzenia zadań oczekujących lub ukończonych. - -:::note -To narzędzie jest domyślnie wyłączone dla subagentów, ale można je włączyć ręcznie. [Dowiedz się więcej](/docs/agents/#permissions) -::: - ---- - ### webfetch Pobieraj treści z sieci. diff --git a/packages/web/src/content/docs/pt-br/modes.mdx b/packages/web/src/content/docs/pt-br/modes.mdx index 62a7f947af..b549d69ded 100644 --- a/packages/web/src/content/docs/pt-br/modes.mdx +++ b/packages/web/src/content/docs/pt-br/modes.mdx @@ -233,7 +233,6 @@ Aqui estão todas as ferramentas que podem ser controladas através da configura | `list` | Listar conteúdos de diretório | | `patch` | Aplicar patches a arquivos | | `todowrite` | Gerenciar listas de tarefas | -| `todoread` | Ler listas de tarefas | | `webfetch` | Buscar conteúdo da web | --- diff --git a/packages/web/src/content/docs/pt-br/permissions.mdx b/packages/web/src/content/docs/pt-br/permissions.mdx index a815e73134..c3850c00ca 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -138,7 +138,6 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `task` — lançamento de subagentes (corresponde ao tipo de subagente) - `skill` — carregamento de uma habilidade (corresponde ao nome da habilidade) - `lsp` — execução de consultas LSP (atualmente não granular) -- `todoread`, `todowrite` — leitura/atualização da lista de tarefas - `webfetch` — busca de uma URL (corresponde à URL) - `websearch`, `codesearch` — busca na web/código (corresponde à consulta) - `external_directory` — acionado quando uma ferramenta toca em caminhos fora do diretório de trabalho do projeto diff --git a/packages/web/src/content/docs/pt-br/tools.mdx b/packages/web/src/content/docs/pt-br/tools.mdx index 53f9624858..d762fdf145 100644 --- a/packages/web/src/content/docs/pt-br/tools.mdx +++ b/packages/web/src/content/docs/pt-br/tools.mdx @@ -248,27 +248,6 @@ Esta ferramenta está desativada para subagentes por padrão, mas você pode ati --- -### todoread - -Leia listas de tarefas existentes. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Lê o estado atual da lista de tarefas. Usado pelo LLM para acompanhar quais tarefas estão pendentes ou concluídas. - -:::note -Esta ferramenta está desativada para subagentes por padrão, mas você pode ativá-la manualmente. [Saiba mais](/docs/agents/#permissions) -::: - ---- - ### webfetch Busque conteúdo da web. diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx index c412d78426..f1ebca386d 100644 --- a/packages/web/src/content/docs/ru/modes.mdx +++ b/packages/web/src/content/docs/ru/modes.mdx @@ -236,7 +236,6 @@ Provide constructive feedback without making direct changes. | `list` | List directory contents | | `patch` | Apply patches to files | | `todowrite` | Manage todo lists | -| `todoread` | Read todo lists | | `webfetch` | Fetch web content | --- diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx index efbacc8b5f..70f3a804a2 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -138,7 +138,6 @@ opencode использует конфигурацию `permission`, чтобы - `task` — запуск субагентов (соответствует типу субагента) - `skill` — загрузка навыка (соответствует названию навыка) - `lsp` — выполнение запросов LSP (в настоящее время не детализированных) -- `todoread`, `todowrite` — чтение/обновление списка дел. - `webfetch` — получение URL-адреса (соответствует URL-адресу) - `websearch`, `codesearch` — поиск в сети/коде (соответствует запросу) - `external_directory` — срабатывает, когда инструмент касается путей за пределами рабочего каталога проекта. diff --git a/packages/web/src/content/docs/ru/tools.mdx b/packages/web/src/content/docs/ru/tools.mdx index 333216b374..def6663fc1 100644 --- a/packages/web/src/content/docs/ru/tools.mdx +++ b/packages/web/src/content/docs/ru/tools.mdx @@ -248,27 +248,6 @@ description: Управляйте инструментами, которые м --- -### todoread - -Прочтите существующие списки дел. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Считывает текущее состояние списка дел. Используется LLM для отслеживания задач, ожидающих или завершенных. - -:::note -По умолчанию этот инструмент отключен для субагентов, но вы можете включить его вручную. [Подробнее](/docs/agents/#permissions) -::: - ---- - ### webfetch Получить веб-контент. diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx index 0ed3d4da56..2cbb05a26b 100644 --- a/packages/web/src/content/docs/th/modes.mdx +++ b/packages/web/src/content/docs/th/modes.mdx @@ -236,7 +236,6 @@ Provide constructive feedback without making direct changes. | `list` | แสดงรายการเนื้อหาไดเร็กทอรี | | `patch` | ใช้แพทช์กับไฟล์ | | `todowrite` | จัดการรายการสิ่งที่ต้องทำ | -| `todoread` | อ่านรายการสิ่งที่ต้องทำ | | `webfetch` | ดึงเนื้อหาเว็บ | --- diff --git a/packages/web/src/content/docs/th/permissions.mdx b/packages/web/src/content/docs/th/permissions.mdx index c81ee5b337..adf381dee3 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -138,7 +138,6 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `task` — การเปิดตัวตัวแทนย่อย (ตรงกับประเภทตัวแทนย่อย) - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) - `lsp` — กำลังเรียกใช้คำสั่ง LSP (ปัจจุบันยังไม่ละเอียด) -- `todoread`, `todowrite` — กำลังอ่าน/updating รายการสิ่งที่ต้องทำ - `webfetch` — กำลังดึง URL (ตรงกับ URL) - `websearch`, `codesearch` — การค้นหาเว็บ/code (ตรงกับข้อความค้นหา) - `external_directory` — ทริกเกอร์เมื่อเครื่องมือแตะเส้นทางนอกไดเร็กทอรีการทำงานของโปรเจ็กต์ diff --git a/packages/web/src/content/docs/th/tools.mdx b/packages/web/src/content/docs/th/tools.mdx index 6db4cfc2a7..17dbd9fdb3 100644 --- a/packages/web/src/content/docs/th/tools.mdx +++ b/packages/web/src/content/docs/th/tools.mdx @@ -248,27 +248,6 @@ description: จัดการเครื่องมือที่ LLM ส --- -### todoread - -อ่านรายการสิ่งที่ต้องทำที่มีอยู่ - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -อ่านสถานะรายการสิ่งที่ต้องทำปัจจุบัน ใช้โดย LLM เพื่อติดตามงานที่กำลังรอดำเนินการหรือเสร็จสิ้น - -:::note -เครื่องมือนี้ปิดใช้งานสำหรับตัวแทนย่อยตามค่าเริ่มต้น แต่คุณสามารถเปิดใช้งานได้ด้วยตนเอง [เรียนรู้เพิ่มเติม](/docs/agents/#สิทธิ์) -::: - ---- - ### webfetch ดึงเนื้อหาเว็บ diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index 736480030d..4c48d194b0 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -248,27 +248,6 @@ This tool is disabled for subagents by default, but you can enable it manually. --- -### todoread - -Read existing todo lists. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Reads the current todo list state. Used by the LLM to track what tasks are pending or completed. - -:::note -This tool is disabled for subagents by default, but you can enable it manually. [Learn more](/docs/agents/#permissions) -::: - ---- - ### webfetch Fetch web content. diff --git a/packages/web/src/content/docs/tr/modes.mdx b/packages/web/src/content/docs/tr/modes.mdx index b8dfc838ee..09538e788a 100644 --- a/packages/web/src/content/docs/tr/modes.mdx +++ b/packages/web/src/content/docs/tr/modes.mdx @@ -236,7 +236,6 @@ Hiçbir araç belirtilmezse tüm araçlar varsayılan olarak etkindir. | `list` | Dizinin içeriğini listele | | `patch` | Dosyalara yama uygula | | `todowrite` | Yapılacaklar listelerini yönet | -| `todoread` | Yapılacaklar listelerini oku | | `webfetch` | Web içeriğini getir | --- diff --git a/packages/web/src/content/docs/tr/permissions.mdx b/packages/web/src/content/docs/tr/permissions.mdx index 1194be9a43..f608ce7e0d 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -138,7 +138,6 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `task` — alt agent'ların başlatılması (alt agent türüyle eşleşir) - `skill` — bir skill yükleniyor (skill adıyla eşleşir) - `lsp` — LSP sorgularını çalıştırıyor (şu anda ayrıntılı değil) -- `todoread`, `todowrite` — yapılacaklar listesini okuma/güncelleme - `webfetch` — URL getiriliyor (URL ile eşleşiyor) - `websearch`, `codesearch` — web/kod arama (sorguyla eşleşir) - `external_directory` — bir araç proje çalışma dizini dışındaki yollara dokunduğunda tetiklenir diff --git a/packages/web/src/content/docs/tr/tools.mdx b/packages/web/src/content/docs/tr/tools.mdx index 2eded12d3c..e65ffec3a2 100644 --- a/packages/web/src/content/docs/tr/tools.mdx +++ b/packages/web/src/content/docs/tr/tools.mdx @@ -248,27 +248,6 @@ Bu araç alt agent'lar için varsayılan olarak devre dışıdır, ama manuel et --- -### todoread - -Mevcut yapılacaklar listesini okur. - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -Güncel todo listesi durumunu okur. LLM bunu hangi görevlerin beklediğini veya tamamlandığını takip etmek için kullanır. - -:::note -Bu araç alt agent'lar için varsayılan olarak devre dışıdır, ama manuel etkinleştirebilirsiniz. [Daha fazla bilgi](/docs/agents/#permissions) -::: - ---- - ### webfetch Web içeriği getirir. diff --git a/packages/web/src/content/docs/zh-cn/modes.mdx b/packages/web/src/content/docs/zh-cn/modes.mdx index 474126c946..4570c801c7 100644 --- a/packages/web/src/content/docs/zh-cn/modes.mdx +++ b/packages/web/src/content/docs/zh-cn/modes.mdx @@ -233,7 +233,6 @@ Markdown 文件名即为模式名称(例如,`review.md` 创建一个名为 ` | `list` | 列出目录内容 | | `patch` | 对文件应用补丁 | | `todowrite` | 管理待办事项列表 | -| `todoread` | 读取待办事项列表 | | `webfetch` | 获取网页内容 | --- diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx index 0f608976aa..24104e2a26 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -138,7 +138,6 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `task` — 启动子代理(匹配子代理类型) - `skill` — 加载技能(匹配技能名称) - `lsp` — 运行 LSP 查询(当前不支持细粒度配置) -- `todoread`、`todowrite` — 读取/更新待办事项列表 - `webfetch` — 获取 URL(匹配 URL) - `websearch`、`codesearch` — 网页/代码搜索(匹配查询内容) - `external_directory` — 当工具访问项目工作目录之外的路径时触发 diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 5292921898..4f68a9cf35 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -248,27 +248,6 @@ description: 管理 LLM 可以使用的工具。 --- -### todoread - -读取现有的待办事项列表。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -读取当前待办事项列表的状态。LLM 使用此工具来跟踪哪些任务待处理、哪些已完成。 - -:::note -该工具默认对子代理禁用,但您可以手动启用。[了解更多](/docs/agents/#permissions) -::: - ---- - ### webfetch 获取网页内容。 diff --git a/packages/web/src/content/docs/zh-tw/modes.mdx b/packages/web/src/content/docs/zh-tw/modes.mdx index 73e6bd141c..c97aeb61b5 100644 --- a/packages/web/src/content/docs/zh-tw/modes.mdx +++ b/packages/web/src/content/docs/zh-tw/modes.mdx @@ -233,7 +233,6 @@ Markdown 檔案名稱即為模式名稱(例如,`review.md` 建立一個名 | `list` | 列出目錄內容 | | `patch` | 對檔案套用補丁 | | `todowrite` | 管理待辦事項清單 | -| `todoread` | 讀取待辦事項清單 | | `webfetch` | 擷取網頁內容 | --- diff --git a/packages/web/src/content/docs/zh-tw/permissions.mdx b/packages/web/src/content/docs/zh-tw/permissions.mdx index b2b43a2094..05b522e9c7 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -138,7 +138,6 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `task` — 啟動子代理(比對子代理類型) - `skill` — 載入技能(比對技能名稱) - `lsp` — 執行 LSP 查詢(目前不支援細粒度設定) -- `todoread`、`todowrite` — 讀取/更新待辦事項清單 - `webfetch` — 擷取 URL(比對 URL) - `websearch`、`codesearch` — 網頁/程式碼搜尋(比對查詢內容) - `external_directory` — 當工具存取專案工作目錄之外的路徑時觸發 diff --git a/packages/web/src/content/docs/zh-tw/tools.mdx b/packages/web/src/content/docs/zh-tw/tools.mdx index 529b706194..80e27ea0cc 100644 --- a/packages/web/src/content/docs/zh-tw/tools.mdx +++ b/packages/web/src/content/docs/zh-tw/tools.mdx @@ -248,27 +248,6 @@ description: 管理 LLM 可以使用的工具。 --- -### todoread - -讀取現有的待辦事項清單。 - -```json title="opencode.json" {4} -{ - "$schema": "https://opencode.ai/config.json", - "permission": { - "todoread": "allow" - } -} -``` - -讀取當前待辦事項清單的狀態。LLM 使用此工具來追蹤哪些任務待處理、哪些已完成。 - -:::note -該工具預設對子代理停用,但您可以手動啟用。[了解更多](/docs/agents/#permissions) -::: - ---- - ### webfetch 擷取網頁內容。 diff --git a/script/beta.ts b/script/beta.ts index 61f9cf8620..6f4ff4ebf9 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,6 +1,9 @@ #!/usr/bin/env bun import { $ } from "bun" +import fs from "fs/promises" + +const model = "opencode/gpt-5.3-codex" interface PR { number: number @@ -50,17 +53,76 @@ async function cleanup() { } catch {} } -async function fix(pr: PR, files: string[]) { +function lines(prs: PR[]) { + return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)" +} + +async function typecheck() { + console.log(" Running typecheck...") + + try { + await $`bun typecheck`.cwd("packages/opencode") + return true + } catch (err) { + console.log(`Typecheck failed: ${err}`) + return false + } +} + +async function build() { + console.log(" Running final build smoke check...") + + try { + await $`./script/build.ts --single`.cwd("packages/opencode") + return true + } catch (err) { + console.log(`Build failed: ${err}`) + return false + } +} + +async function install() { + console.log(" Regenerating bun.lock...") + + try { + await fs.rm("bun.lock", { force: true }) + await $`bun install` + await $`git add bun.lock` + return true + } catch (err) { + console.log(`Install failed: ${err}`) + return false + } +} + +async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) { console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) + + const done = lines(prs.filter((x) => applied.includes(x.number))) + const next = lines(prs.slice(idx + 1)) + const prompt = [ `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, - `Only touch these files: ${files.join(", ")}.`, + `PR #${pr.number}: ${pr.title}`, + `Start with these conflicted files: ${files.join(", ")}.`, + `Merged PRs on HEAD:\n${done}`, + `Pending PRs after this one (context only):\n${next}`, + "IMPORTANT: The conflict resolution must be consistent with already-merged PRs.", + "Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.", + "Prefer already-merged PRs over the base branch when resolving stacked conflicts.", + "If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.", + "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", + "If a PR already changed an import, keep that change.", + "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.", + "If typecheck fails, you may also update any files reported by typecheck.", + "Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.", + "Fix any merge-caused typecheck errors before finishing.", "Keep the merge in progress, do not abort the merge, and do not create a commit.", - "When done, leave the working tree with no unmerged files.", + "When done, leave the working tree with no unmerged files and a passing typecheck.", ].join("\n") try { - await $`opencode run -m opencode/gpt-5.3-codex ${prompt}` + await $`opencode run -m ${model} ${prompt}` } catch (err) { console.log(` opencode failed: ${err}`) return false @@ -72,10 +134,68 @@ async function fix(pr: PR, files: string[]) { return false } + if (files.includes("bun.lock") && !(await install())) return false + + if (!(await typecheck())) return false + console.log(" Conflicts resolved with opencode") return true } +async function smoke(prs: PR[], applied: number[]) { + console.log("\nRunning final smoke check with opencode...") + + const done = lines(prs.filter((x) => applied.includes(x.number))) + const prompt = [ + "The beta merge batch is complete.", + `Merged PRs on HEAD:\n${done}`, + "Run `bun typecheck` in `packages/opencode`.", + "Run `./script/build.ts --single` in `packages/opencode`.", + "Fix any merge-caused issues until both commands pass.", + "Do not create a commit.", + ].join("\n") + + try { + await $`opencode run -m ${model} ${prompt}` + } catch (err) { + console.log(`Smoke fix failed: ${err}`) + return false + } + + if (!(await typecheck())) { + return false + } + + if (!(await build())) { + return false + } + + const out = await $`git status --porcelain`.text() + if (!out.trim()) { + console.log("Smoke check passed") + return true + } + + try { + await $`git add -A` + await $`git commit -m "Fix beta integration"` + } catch (err) { + console.log(`Failed to commit smoke fixes: ${err}`) + return false + } + + if (!(await typecheck())) { + return false + } + + if (!(await build())) { + return false + } + + console.log("Smoke check passed") + return true +} + async function main() { console.log("Fetching open PRs with beta label...") @@ -99,8 +219,8 @@ async function main() { const applied: number[] = [] const failed: FailedPR[] = [] - for (const pr of prs) { - console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) + for (const [idx, pr] of prs.entries()) { + console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") try { @@ -119,7 +239,7 @@ async function main() { const files = await conflicts() if (files.length > 0) { console.log(" Failed to merge (conflicts)") - if (!(await fix(pr, files))) { + if (!(await fix(pr, files, prs, applied, idx))) { await cleanup() failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) await commentOnPR(pr.number, "Merge conflicts with dev branch") @@ -174,6 +294,13 @@ async function main() { throw new Error(`${failed.length} PR(s) failed to merge`) } + if (applied.length > 0) { + const ok = await smoke(prs, applied) + if (!ok) { + throw new Error("Final smoke check failed") + } + } + console.log("\nChecking if beta branch has changes...") await $`git fetch origin beta` diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts new file mode 100755 index 0000000000..7b38bf6758 --- /dev/null +++ b/script/github/close-issues.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun + +const repo = "anomalyco/opencode" +const days = 60 +const msg = + "To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one." + +const token = process.env.GITHUB_TOKEN +if (!token) { + console.error("GITHUB_TOKEN environment variable is required") + process.exit(1) +} + +const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + +type Issue = { + number: number + updated_at: string +} + +const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +async function close(num: number) { + const base = `https://api.github.com/repos/${repo}/issues/${num}` + + const comment = await fetch(`${base}/comments`, { + method: "POST", + headers, + body: JSON.stringify({ body: msg }), + }) + if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`) + + const patch = await fetch(base, { + method: "PATCH", + headers, + body: JSON.stringify({ state: "closed", state_reason: "completed" }), + }) + if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`) + + console.log(`Closed https://github.com/${repo}/issues/${num}`) +} + +async function main() { + let page = 1 + let closed = 0 + + while (true) { + const res = await fetch( + `https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`, + { headers }, + ) + if (!res.ok) throw new Error(res.statusText) + + const all = (await res.json()) as Issue[] + if (all.length === 0) break + console.log(`Fetched page ${page} ${all.length} issues`) + + const stale: number[] = [] + for (const i of all) { + const updated = new Date(i.updated_at) + if (updated < cutoff) { + stale.push(i.number) + } else { + console.log(`\nFound fresh issue #${i.number}, stopping`) + if (stale.length > 0) { + for (const num of stale) { + await close(num) + closed++ + } + } + console.log(`Closed ${closed} issues total`) + return + } + } + + if (stale.length > 0) { + for (const num of stale) { + await close(num) + closed++ + } + } + + page++ + } + + console.log(`Closed ${closed} issues total`) +} + +main().catch((err) => { + console.error("Error:", err) + process.exit(1) +})