diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2c7f4ea001..8d7a823b144 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,13 +29,9 @@ permissions: packages: write jobs: - test: - if: github.event_name == 'workflow_dispatch' - uses: ./.github/workflows/test.yml publish: - needs: test runs-on: blacksmith-4vcpu-ubuntu-2404 - if: always() && github.repository == 'anomalyco/opencode' && (github.event_name != 'workflow_dispatch' || needs.test.result == 'success') + if: github.repository == 'anomalyco/opencode' steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fca227fa869..d95de94d232 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,6 @@ on: - dev pull_request: workflow_dispatch: - workflow_call: jobs: test: name: test (${{ matrix.settings.name }}) diff --git a/bun.lock b/bun.lock index 13647ffa151..16157835459 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,10 +182,11 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -211,7 +212,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -240,7 +241,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -256,7 +257,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.36", + "version": "1.1.37", "bin": { "opencode": "./bin/opencode", }, @@ -360,7 +361,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -380,7 +381,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.36", + "version": "1.1.37", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -391,7 +392,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -404,7 +405,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -446,7 +447,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "zod": "catalog:", }, @@ -457,7 +458,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/nix/hashes.json b/nix/hashes.json index da2f2eaf1fc..0b735b35d6e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-AkI3guNjnE+bLZQVfzm0z14UENOECv2QBqMo5Lzkvt8=", - "aarch64-linux": "sha256-dBfdyVTqW+fBZKCxC9Ld+1m3cP+nIbS6UDo0tUfPOSk=", - "aarch64-darwin": "sha256-tOw31AMnHkW2cEDi+iqT3P93lU3SiMve26TEIqPz97k=", - "x86_64-darwin": "sha256-wL/DmdZmxCmh+r4dsS1XGXuj8VPwR4pUqy5VIA76jl0=" + "x86_64-linux": "sha256-9oI1gekRbjY6L8VwlkLdPty/9rCxC20EJlESkazEX8Y=", + "aarch64-linux": "sha256-vn+eCVanOSNfjyqHRJn4VdqbpdMoBFm49REuIkByAio=", + "aarch64-darwin": "sha256-0dMP5WbqDq3qdLRrKfmCjXz2kUDjTttGTqD3v6PDbkg=", + "x86_64-darwin": "sha256-9dEWluRXY7RTPdSEhhPsDJeGo+qa3V8dqh6n6WsLeGw=" } } diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/file-tree.spec.ts index 12ea7a081fc..c22a810f4f0 100644 --- a/packages/app/e2e/file-tree.spec.ts +++ b/packages/app/e2e/file-tree.spec.ts @@ -3,9 +3,10 @@ import { test, expect } from "./fixtures" test("file tree can expand folders and open a file", async ({ page, gotoSession }) => { await gotoSession() - await page.getByRole("button", { name: "Toggle file tree" }).click() - + const toggle = page.getByRole("button", { name: "Toggle file tree" }) const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]') + + if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click() await expect(treeTabs).toBeVisible() await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click() diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/titlebar-history.spec.ts index b8141b98295..d4aa605e6dd 100644 --- a/packages/app/e2e/titlebar-history.spec.ts +++ b/packages/app/e2e/titlebar-history.spec.ts @@ -29,8 +29,8 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() - const back = page.getByRole("button", { name: "Go back" }) - const forward = page.getByRole("button", { name: "Go forward" }) + const back = page.getByRole("button", { name: "Back" }) + const forward = page.getByRole("button", { name: "Forward" }) await expect(back).toBeVisible() await expect(back).toBeEnabled() diff --git a/packages/app/package.json b/packages/app/package.json index 8284234c5cb..dc1dbc09b2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.36", + "version": "1.1.37", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 1ecefa2cbbf..9ee48736ca0 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -1,16 +1,33 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" +import { Button } from "@opencode-ai/ui/button" import type { Component } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectProvider } from "./dialog-select-provider" export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() + const dialog = useDialog() + + const handleConnectProvider = () => { + dialog.show(() => ) + } + return ( - + + {language.t("command.provider.connect")} + + } + > dirs: Set @@ -26,9 +29,11 @@ export default function FileTree(props: { path: string class?: string nodeClass?: string + active?: string level?: number allowed?: readonly string[] modified?: readonly string[] + kinds?: ReadonlyMap draggable?: boolean tooltip?: boolean onFileClick?: (file: FileNode) => void @@ -36,6 +41,7 @@ export default function FileTree(props: { _filter?: Filter _marks?: Set _deeps?: Map + _kinds?: ReadonlyMap }) { const file = useFile() const level = props.level ?? 0 @@ -66,9 +72,16 @@ export default function FileTree(props: { const marks = createMemo(() => { if (props._marks) return props._marks - const modified = props.modified - if (!modified || modified.length === 0) return - return new Set(modified) + const out = new Set() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + if (out.size === 0) return + return out + }) + + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds }) const deeps = createMemo(() => { @@ -136,7 +149,8 @@ export default function FileTree(props: { {local.children} - - {local.node.name} - - {local.node.type === "file" && marks()?.has(local.node.path) ? ( -
- ) : null} + {(() => { + const kind = kinds()?.get(local.node.path) + const marked = marks()?.has(local.node.path) ?? false + const active = !!kind && marked && !local.node.ignored + const color = + kind === "add" + ? "color: var(--icon-diff-add-base)" + : kind === "del" + ? "color: var(--icon-diff-delete-base)" + : kind === "mix" + ? "color: var(--icon-diff-modified-base)" + : undefined + return ( + + {local.node.name} + + ) + })()} + {(() => { + const kind = kinds()?.get(local.node.path) + if (!kind) return null + if (!marks()?.has(local.node.path)) return null + + if (local.node.type === "file") { + const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" + const color = + kind === "add" + ? "color: var(--icon-diff-add-base)" + : kind === "del" + ? "color: var(--icon-diff-delete-base)" + : "color: var(--icon-diff-modified-base)" + + return ( + + {text} + + ) + } + + if (local.node.type === "directory") { + const color = + kind === "add" + ? "background-color: var(--icon-diff-add-base)" + : kind === "del" + ? "background-color: var(--icon-diff-delete-base)" + : "background-color: var(--icon-diff-modified-base)" + + return
+ } + + return null + })()} ) } @@ -194,8 +255,56 @@ export default function FileTree(props: { const deep = () => deeps().get(node.path) ?? -1 const Wrapper = (p: ParentProps) => { if (!tooltip()) return p.children + + const parts = node.path.split("/") + const leaf = parts[parts.length - 1] ?? node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + + const kind = () => kinds()?.get(node.path) + const label = () => { + const k = kind() + if (!k) return + if (k === "add") return "Additions" + if (k === "del") return "Deletions" + return "Modifications" + } + + const ignored = () => node.type === "directory" && node.ignored + return ( - + + + {prefix} + + {leaf} + + {(t: () => string) => ( + <> + + {t()} + + )} + + + <> + + Ignored + + +
+ } + > {p.children} ) @@ -235,12 +344,15 @@ export default function FileTree(props: { level={level + 1} allowed={props.allowed} modified={props.modified} + kinds={props.kinds} + active={props.active} draggable={props.draggable} tooltip={props.tooltip} onFileClick={props.onFileClick} _filter={filter()} _marks={marks()} _deeps={deeps()} + _kinds={kinds()} /> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1bd7aa4ebcd..d46ce095af9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -190,6 +190,7 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { layout.fileTree.setTab("changes") + if (!layout.fileTree.opened()) tabs().open("review") requestAnimationFrame(() => comments.setFocus(focus)) return } @@ -1557,13 +1558,17 @@ export const PromptInput: Component = (props) => { }) const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } const timeout = new Promise>>((resolve) => { - setTimeout(() => { + timer.id = window.setTimeout(() => { resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) }, timeoutMs) }) - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]) + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 022369afeac..d388448024b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -67,6 +67,19 @@ export const Terminal = (props: TerminalProps) => { let handleTextareaFocus: () => void let handleTextareaBlur: () => void let disposed = false + const cleanups: VoidFunction[] = [] + + const cleanup = () => { + if (!cleanups.length) return + const fns = cleanups.splice(0).reverse() + for (const fn of fns) { + try { + fn() + } catch { + // ignore + } + } + } const getTerminalColors = (): TerminalColors => { const mode = theme.mode() @@ -128,7 +141,7 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return const mod = loaded.mod - ghostty = loaded.ghostty + const g = loaded.ghostty const once = { value: false } @@ -138,6 +151,13 @@ export const Terminal = (props: TerminalProps) => { url.password = window.__OPENCODE__?.serverPassword } const socket = new WebSocket(url) + cleanups.push(() => { + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() + }) + if (disposed) { + cleanup() + return + } ws = socket const t = new mod.Terminal({ @@ -148,8 +168,14 @@ export const Terminal = (props: TerminalProps) => { allowTransparency: true, theme: terminalColors(), scrollback: 10_000, - ghostty, + ghostty: g, }) + cleanups.push(() => t.dispose()) + if (disposed) { + cleanup() + return + } + ghostty = g term = t const copy = () => { @@ -201,13 +227,17 @@ export const Terminal = (props: TerminalProps) => { return false }) - fitAddon = new mod.FitAddon() - serializeAddon = new SerializeAddon() - t.loadAddon(serializeAddon) - t.loadAddon(fitAddon) + const fit = new mod.FitAddon() + const serializer = new SerializeAddon() + cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) + t.loadAddon(serializer) + t.loadAddon(fit) + fitAddon = fit + serializeAddon = serializer t.open(container) container.addEventListener("pointerdown", handlePointerDown) + cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown)) handleTextareaFocus = () => { t.options.cursorBlink = true @@ -218,6 +248,8 @@ export const Terminal = (props: TerminalProps) => { t.textarea?.addEventListener("focus", handleTextareaFocus) t.textarea?.addEventListener("blur", handleTextareaBlur) + cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus)) + cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur)) focusTerminal() @@ -233,10 +265,11 @@ export const Terminal = (props: TerminalProps) => { }) } - fitAddon.observeResize() - handleResize = () => fitAddon.fit() + fit.observeResize() + handleResize = () => fit.fit() window.addEventListener("resize", handleResize) - t.onResize(async (size) => { + cleanups.push(() => window.removeEventListener("resize", handleResize)) + const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { await sdk.client.pty .update({ @@ -249,20 +282,24 @@ export const Terminal = (props: TerminalProps) => { .catch(() => {}) } }) - t.onData((data) => { + cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) + const onData = t.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data) } }) - t.onKey((key) => { + cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) + const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) + cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) - socket.addEventListener("open", () => { + + const handleOpen = () => { local.onConnect?.() sdk.client.pty .update({ @@ -273,18 +310,27 @@ export const Terminal = (props: TerminalProps) => { }, }) .catch(() => {}) - }) - socket.addEventListener("message", (event) => { + } + socket.addEventListener("open", handleOpen) + cleanups.push(() => socket.removeEventListener("open", handleOpen)) + + const handleMessage = (event: MessageEvent) => { t.write(event.data) - }) - socket.addEventListener("error", (error) => { + } + socket.addEventListener("message", handleMessage) + cleanups.push(() => socket.removeEventListener("message", handleMessage)) + + const handleError = (error: Event) => { if (disposed) return if (once.value) return once.value = true console.error("WebSocket error:", error) local.onConnectError?.(error) - }) - socket.addEventListener("close", (event) => { + } + socket.addEventListener("error", handleError) + cleanups.push(() => socket.removeEventListener("error", handleError)) + + const handleClose = (event: CloseEvent) => { if (disposed) return // Normal closure (code 1000) means PTY process exited - server event handles cleanup // For other codes (network issues, server restart), trigger error handler @@ -293,7 +339,9 @@ export const Terminal = (props: TerminalProps) => { once.value = true local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) } - }) + } + socket.addEventListener("close", handleClose) + cleanups.push(() => socket.removeEventListener("close", handleClose)) } void run().catch((err) => { @@ -309,13 +357,6 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true - if (handleResize) { - window.removeEventListener("resize", handleResize) - } - container.removeEventListener("pointerdown", handlePointerDown) - term?.textarea?.removeEventListener("focus", handleTextareaFocus) - term?.textarea?.removeEventListener("blur", handleTextareaBlur) - const t = term if (serializeAddon && props.onCleanup && t) { const buffer = (() => { @@ -334,8 +375,7 @@ export const Terminal = (props: TerminalProps) => { }) } - ws?.close() - t?.dispose() + cleanup() }) return ( diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 7a08456c7b7..e3831c70fe5 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -165,7 +165,7 @@ export function Titlebar() { />
-
+
+ ) + createEffect( on( () => tabs().active(), @@ -1141,6 +1223,7 @@ export default function Page() { const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) + setActiveDiff(path) setPendingDiff(path) } @@ -1188,33 +1271,70 @@ export default function Page() { const activeTab = createMemo(() => { const active = tabs().active() if (active === "context") return "context" + if (active === "review" && reviewTab()) return "review" if (active && file.pathFromTab(active)) return normalizeTab(active) const first = openedTabs()[0] if (first) return first if (contextOpen()) return "context" + if (reviewTab() && hasReview()) return "review" return "empty" }) createEffect(() => { if (!layout.ready()) return if (tabs().active()) return - if (openedTabs().length === 0 && !contextOpen()) return + if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return const next = activeTab() if (next === "empty") return tabs().setActive(next) }) + createEffect( + on( + () => layout.fileTree.opened(), + (opened, prev) => { + if (prev === undefined) return + if (!isDesktop()) return + + if (opened) { + const active = tabs().active() + const tab = active === "review" || (!active && hasReview()) ? "changes" : "all" + layout.fileTree.setTab(tab) + return + } + + if (fileTreeTab() !== "changes") return + tabs().setActive("review") + }, + { defer: true }, + ), + ) + createEffect(() => { const id = params.id if (!id) return - const wants = isDesktop() ? fileTreeTab() === "changes" : store.mobileTab === "changes" + const wants = isDesktop() + ? layout.fileTree.opened() + ? fileTreeTab() === "changes" + : activeTab() === "review" + : store.mobileTab === "changes" if (!wants) return if (sync.data.session_diff[id] !== undefined) return + if (sync.status === "loading") return - sync.session.diff(id) + void sync.session.diff(id) + }) + + createEffect(() => { + if (!isDesktop()) return + if (!layout.fileTree.opened()) return + if (sync.status === "loading") return + + fileTreeTab() + void file.tree.list("") }) const autoScroll = createAutoScroll({ @@ -1222,9 +1342,15 @@ export default function Page() { overflowAnchor: "dynamic", }) + const clearMessageHash = () => { + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() + clearMessageHash() } // When the user returns to the bottom, treat the active message as "latest". @@ -1234,6 +1360,7 @@ export default function Page() { (scrolled) => { if (scrolled) return setStore("messageId", undefined) + clearMessageHash() }, { defer: true }, ), @@ -1308,7 +1435,8 @@ export default function Page() { requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight - if (delta) el.scrollTop = beforeTop + delta + if (!delta) return + el.scrollTop = beforeTop + delta }) scheduleTurnBackfill() @@ -1439,6 +1567,7 @@ export default function Page() { const match = hash.match(/^message-(.+)$/) if (match) { + autoScroll.pause() const msg = visibleUserMessages().find((m) => m.id === match[1]) if (msg) { scrollToMessage(msg, behavior) @@ -1452,6 +1581,7 @@ export default function Page() { const target = document.getElementById(hash) if (target) { + autoScroll.pause() scrollToElement(target, behavior) return } @@ -1548,6 +1678,7 @@ export default function Page() { const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) + autoScroll.pause() requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) @@ -1687,6 +1818,7 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" + focusedFile={activeDiff()} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -1727,27 +1859,102 @@ export default function Page() { >
markScrollGesture(e.target)} - onTouchMove={(e) => markScrollGesture(e.target)} + onWheel={(e) => { + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + markScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + markScrollGesture(root) + return + } + + const max = nested.scrollHeight - nested.clientHeight + if (max <= 1) { + markScrollGesture(root) + return + } + + const delta = + e.deltaMode === 1 + ? e.deltaY * 40 + : e.deltaMode === 2 + ? e.deltaY * root.clientHeight + : e.deltaY + if (!delta) return + + if (delta < 0) { + if (nested.scrollTop + delta <= 0) markScrollGesture(root) + return + } + + const remaining = max - nested.scrollTop + if (delta > remaining) markScrollGesture(root) + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + markScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + markScrollGesture(root) + return + } + + const max = nested.scrollHeight - nested.clientHeight + if (max <= 1) { + markScrollGesture(root) + return + } + + if (delta < 0) { + if (nested.scrollTop + delta <= 0) markScrollGesture(root) + return + } + + const remaining = max - nested.scrollTop + if (delta > remaining) markScrollGesture(root) + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} onPointerDown={(e) => { if (e.target !== e.currentTarget) return - markScrollGesture(e.target) + markScrollGesture(e.currentTarget) }} onScroll={(e) => { - autoScroll.handleScroll() if (!hasScrollGesture()) return - markScrollGesture(e.target) + autoScroll.handleScroll() + markScrollGesture(e.currentTarget) if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} onClick={autoScroll.handleInteraction} @@ -1976,7 +2183,7 @@ export default function Page() { >
+ + +
+ + + +
+
{language.t("session.tab.review")}
+ +
+ {reviewCount()} +
+
+
+
+
+
+ + + + {reviewPanel()} + + +
@@ -2571,46 +2802,7 @@ export default function Page() { } > -
-
- - - {language.t("session.review.loadingChanges")}
- } - > - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - showAllFiles() - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> - - - -
- -
- {language.t("session.review.empty")} -
-
-
- -
-
+ {reviewPanel()}
@@ -2652,8 +2844,9 @@ export default function Page() { focusReviewDiff(node.path)} /> @@ -2669,7 +2862,7 @@ export default function Page() { openTab(file.tab(node.path))} /> diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 129695f8649..0ca3abad069 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -18,7 +18,52 @@ const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" const LOCAL_PREFIX = "opencode." const fallback = { disabled: false } -const cache = new Map() + +const CACHE_MAX_ENTRIES = 500 +const CACHE_MAX_BYTES = 8 * 1024 * 1024 + +type CacheEntry = { value: string; bytes: number } +const cache = new Map() +const cacheTotal = { bytes: 0 } + +function cacheDelete(key: string) { + const entry = cache.get(key) + if (!entry) return + cacheTotal.bytes -= entry.bytes + cache.delete(key) +} + +function cachePrune() { + for (;;) { + if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return + const oldest = cache.keys().next().value as string | undefined + if (!oldest) return + cacheDelete(oldest) + } +} + +function cacheSet(key: string, value: string) { + const bytes = value.length * 2 + if (bytes > CACHE_MAX_BYTES) { + cacheDelete(key) + return + } + + const entry = cache.get(key) + if (entry) cacheTotal.bytes -= entry.bytes + cache.delete(key) + cache.set(key, { value, bytes }) + cacheTotal.bytes += bytes + cachePrune() +} + +function cacheGet(key: string) { + const entry = cache.get(key) + if (!entry) return + cache.delete(key) + cache.set(key, entry) + return entry.value +} function quota(error: unknown) { if (error instanceof DOMException) { @@ -63,9 +108,11 @@ function evict(storage: Storage, keep: string, value: string) { for (const item of items) { storage.removeItem(item.key) + cacheDelete(item.key) try { storage.setItem(keep, value) + cacheSet(keep, value) return true } catch (error) { if (!quota(error)) throw error @@ -78,6 +125,7 @@ function evict(storage: Storage, keep: string, value: string) { function write(storage: Storage, key: string, value: string) { try { storage.setItem(key, value) + cacheSet(key, value) return true } catch (error) { if (!quota(error)) throw error @@ -85,13 +133,17 @@ function write(storage: Storage, key: string, value: string) { try { storage.removeItem(key) + cacheDelete(key) storage.setItem(key, value) + cacheSet(key, value) return true } catch (error) { if (!quota(error)) throw error } - return evict(storage, key, value) + const ok = evict(storage, key, value) + if (!ok) cacheSet(key, value) + return ok } function snapshot(value: unknown) { @@ -148,7 +200,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { return { getItem: (key) => { const name = item(key) - const cached = cache.get(name) + const cached = cacheGet(name) if (fallback.disabled && cached !== undefined) return cached const stored = (() => { @@ -160,12 +212,12 @@ function localStorageWithPrefix(prefix: string): SyncStorage { } })() if (stored === null) return cached ?? null - cache.set(name, stored) + cacheSet(name, stored) return stored }, setItem: (key, value) => { const name = item(key) - cache.set(name, value) + cacheSet(name, value) if (fallback.disabled) return try { if (write(localStorage, name, value)) return @@ -177,7 +229,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { }, removeItem: (key) => { const name = item(key) - cache.delete(name) + cacheDelete(name) if (fallback.disabled) return try { localStorage.removeItem(name) @@ -191,7 +243,7 @@ function localStorageWithPrefix(prefix: string): SyncStorage { function localStorageDirect(): SyncStorage { return { getItem: (key) => { - const cached = cache.get(key) + const cached = cacheGet(key) if (fallback.disabled && cached !== undefined) return cached const stored = (() => { @@ -203,11 +255,11 @@ function localStorageDirect(): SyncStorage { } })() if (stored === null) return cached ?? null - cache.set(key, stored) + cacheSet(key, stored) return stored }, setItem: (key, value) => { - cache.set(key, value) + cacheSet(key, value) if (fallback.disabled) return try { if (write(localStorage, key, value)) return @@ -218,7 +270,7 @@ function localStorageDirect(): SyncStorage { fallback.disabled = true }, removeItem: (key) => { - cache.delete(key) + cacheDelete(key) if (fallback.disabled) return try { localStorage.removeItem(key) diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index c8acf5241c1..201c1261bd1 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -78,6 +78,7 @@ export function createSpeechRecognition(opts?: { let lastInterimSuffix = "" let shrinkCandidate: string | undefined let commitTimer: number | undefined + let restartTimer: number | undefined const cancelPendingCommit = () => { if (commitTimer === undefined) return @@ -85,6 +86,26 @@ export function createSpeechRecognition(opts?: { commitTimer = undefined } + const clearRestart = () => { + if (restartTimer === undefined) return + window.clearTimeout(restartTimer) + restartTimer = undefined + } + + const scheduleRestart = () => { + clearRestart() + if (!shouldContinue) return + if (!recognition) return + restartTimer = window.setTimeout(() => { + restartTimer = undefined + if (!shouldContinue) return + if (!recognition) return + try { + recognition.start() + } catch {} + }, 150) + } + const commitSegment = (segment: string) => { const nextCommitted = appendSegment(committedText, segment) if (nextCommitted === committedText) return @@ -214,17 +235,14 @@ export function createSpeechRecognition(opts?: { } recognition.onerror = (e: { error: string }) => { + clearRestart() cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined if (e.error === "no-speech" && shouldContinue) { setStore("interim", "") if (opts?.onInterim) opts.onInterim("") - setTimeout(() => { - try { - recognition?.start() - } catch {} - }, 150) + scheduleRestart() return } shouldContinue = false @@ -232,6 +250,7 @@ export function createSpeechRecognition(opts?: { } recognition.onstart = () => { + clearRestart() sessionCommitted = "" pendingHypothesis = "" cancelPendingCommit() @@ -243,22 +262,20 @@ export function createSpeechRecognition(opts?: { } recognition.onend = () => { + clearRestart() cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined setStore("isRecording", false) if (shouldContinue) { - setTimeout(() => { - try { - recognition?.start() - } catch {} - }, 150) + scheduleRestart() } } } const start = () => { if (!recognition) return + clearRestart() shouldContinue = true sessionCommitted = "" pendingHypothesis = "" @@ -274,6 +291,7 @@ export function createSpeechRecognition(opts?: { const stop = () => { if (!recognition) return shouldContinue = false + clearRestart() promotePending() cancelPendingCommit() lastInterimSuffix = "" @@ -287,6 +305,7 @@ export function createSpeechRecognition(opts?: { onCleanup(() => { shouldContinue = false + clearRestart() promotePending() cancelPendingCommit() lastInterimSuffix = "" diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts index 7c0055920b7..581afd5535e 100644 --- a/packages/app/src/utils/worktree.ts +++ b/packages/app/src/utils/worktree.ts @@ -13,7 +13,21 @@ type State = } const state = new Map() -const waiters = new Map void>>() +const waiters = new Map< + string, + { + promise: Promise + resolve: (state: State) => void + } +>() + +function deferred() { + const box = { resolve: (_: State) => {} } + const promise = new Promise((resolve) => { + box.resolve = resolve + }) + return { promise, resolve: box.resolve } +} export const Worktree = { get(directory: string) { @@ -27,32 +41,33 @@ export const Worktree = { }, ready(directory: string) { const key = normalize(directory) - state.set(key, { status: "ready" }) - const list = waiters.get(key) - if (!list) return + const next = { status: "ready" } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return waiters.delete(key) - for (const fn of list) fn({ status: "ready" }) + waiter.resolve(next) }, failed(directory: string, message: string) { const key = normalize(directory) - state.set(key, { status: "failed", message }) - const list = waiters.get(key) - if (!list) return + const next = { status: "failed", message } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return waiters.delete(key) - for (const fn of list) fn({ status: "failed", message }) + waiter.resolve(next) }, wait(directory: string) { const key = normalize(directory) const current = state.get(key) if (current && current.status !== "pending") return Promise.resolve(current) - return new Promise((resolve) => { - const list = waiters.get(key) - if (!list) { - waiters.set(key, [resolve]) - return - } - list.push(resolve) - }) + const existing = waiters.get(key) + if (existing) return existing.promise + + const waiter = deferred() + + waiters.set(key, waiter) + return waiter.promise }, } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 169f9a7e897..c19c597df19 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 95f019b1cd9..a355b2cf586 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7202c4cfaef..b98859d0be4 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.36", + "version": "1.1.37", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index fc980b6fbfe..0badbf49643 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.36", + "version": "1.1.37", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cc6b3af99f7..28a5aa56cb9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e086acd9364..dab98f4a006 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -328,7 +328,15 @@ pub fn run() { .hidden_title(true); #[cfg(windows)] - let window_builder = window_builder.decorations(false); + let window_builder = window_builder + // Some VPNs set a global/system proxy that WebView2 applies even for loopback + // connections, which breaks the app's localhost sidecar server. + // Note: when setting additional args, we must re-apply wry's default + // `--disable-features=...` flags. + .additional_browser_args( + "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection", + ) + .decorations(false); let window = window_builder.build().expect("Failed to create window"); @@ -525,4 +533,4 @@ async fn spawn_local_server( break Ok(child); } } -} \ No newline at end of file +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index e3be4a44dab..9ffee8aa5ce 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -52,7 +52,32 @@ fn configure_display_backend() -> Option { } fn main() { - unsafe { std::env::set_var("NO_PROXY", "127.0.0.1,localhost,::1") }; + // Ensure loopback connections are never sent through proxy settings. + // Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost. + const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"]; + + let upsert = |key: &str| { + let mut items = std::env::var(key) + .unwrap_or_default() + .split(',') + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()) + .collect::>(); + + for host in LOOPBACK { + if items.iter().any(|v| v.eq_ignore_ascii_case(host)) { + continue; + } + items.push(host.to_string()); + } + + // Safety: called during startup before any threads are spawned. + unsafe { std::env::set_var(key, items.join(",")) }; + }; + + upsert("NO_PROXY"); + upsert("no_proxy"); #[cfg(target_os = "linux")] { diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts index 965ed6ddc00..5a8875cf893 100644 --- a/packages/desktop/src/cli.ts +++ b/packages/desktop/src/cli.ts @@ -1,13 +1,15 @@ import { invoke } from "@tauri-apps/api/core" import { message } from "@tauri-apps/plugin-dialog" +import { initI18n, t } from "./i18n" + export async function installCli(): Promise { + await initI18n() + try { const path = await invoke("install_cli") - await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, { - title: "CLI Installed", - }) + await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") }) } catch (e) { - await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" }) + await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") }) } } diff --git a/packages/desktop/src/i18n/ar.ts b/packages/desktop/src/i18n/ar.ts new file mode 100644 index 00000000000..c3205cb85e7 --- /dev/null +++ b/packages/desktop/src/i18n/ar.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...", + "desktop.menu.installCli": "تثبيت CLI...", + "desktop.menu.reloadWebview": "إعادة تحميل Webview", + "desktop.menu.restart": "إعادة تشغيل", + + "desktop.dialog.chooseFolder": "اختر مجلدًا", + "desktop.dialog.chooseFile": "اختر ملفًا", + "desktop.dialog.saveFile": "حفظ ملف", + + "desktop.updater.checkFailed.title": "فشل التحقق من التحديثات", + "desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات", + "desktop.updater.none.title": "لا توجد تحديثات متاحة", + "desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode", + "desktop.updater.downloadFailed.title": "فشل التحديث", + "desktop.updater.downloadFailed.message": "فشل تنزيل التحديث", + "desktop.updater.downloaded.title": "تم تنزيل التحديث", + "desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟", + "desktop.updater.installFailed.title": "فشل التحديث", + "desktop.updater.installFailed.message": "فشل تثبيت التحديث", + + "desktop.cli.installed.title": "تم تثبيت CLI", + "desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.", + "desktop.cli.failed.title": "فشل التثبيت", + "desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "فشل تشغيل OpenCode", + "desktop.error.serverStartFailed.description": + "تعذر بدء تشغيل خادم OpenCode المحلي. أعد تشغيل التطبيق، أو تحقق من إعدادات الشبكة (VPN/proxy) وحاول مرة أخرى.", +} diff --git a/packages/desktop/src/i18n/br.ts b/packages/desktop/src/i18n/br.ts new file mode 100644 index 00000000000..8b5a58756d5 --- /dev/null +++ b/packages/desktop/src/i18n/br.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Verificar atualizações...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recarregar Webview", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Escolher uma pasta", + "desktop.dialog.chooseFile": "Escolher um arquivo", + "desktop.dialog.saveFile": "Salvar arquivo", + + "desktop.updater.checkFailed.title": "Falha ao verificar atualizações", + "desktop.updater.checkFailed.message": "Falha ao verificar atualizações", + "desktop.updater.none.title": "Nenhuma atualização disponível", + "desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode", + "desktop.updater.downloadFailed.title": "Falha na atualização", + "desktop.updater.downloadFailed.message": "Falha ao baixar a atualização", + "desktop.updater.downloaded.title": "Atualização baixada", + "desktop.updater.downloaded.prompt": + "A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?", + "desktop.updater.installFailed.title": "Falha na atualização", + "desktop.updater.installFailed.message": "Falha ao instalar a atualização", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.", + "desktop.cli.failed.title": "Falha na instalação", + "desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Falha ao iniciar o OpenCode", + "desktop.error.serverStartFailed.description": + "Não foi possível iniciar o servidor local do OpenCode. Reinicie o aplicativo ou verifique suas configurações de rede (VPN/proxy) e tente novamente.", +} diff --git a/packages/desktop/src/i18n/da.ts b/packages/desktop/src/i18n/da.ts new file mode 100644 index 00000000000..73d47db303b --- /dev/null +++ b/packages/desktop/src/i18n/da.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Tjek for opdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Genindlæs Webview", + "desktop.menu.restart": "Genstart", + + "desktop.dialog.chooseFolder": "Vælg en mappe", + "desktop.dialog.chooseFile": "Vælg en fil", + "desktop.dialog.saveFile": "Gem fil", + + "desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes", + "desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer", + "desktop.updater.none.title": "Ingen opdatering tilgængelig", + "desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode", + "desktop.updater.downloadFailed.title": "Opdatering mislykkedes", + "desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen", + "desktop.updater.downloaded.title": "Opdatering downloadet", + "desktop.updater.downloaded.prompt": + "Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?", + "desktop.updater.installFailed.title": "Opdatering mislykkedes", + "desktop.updater.installFailed.message": "Kunne ikke installere opdateringen", + + "desktop.cli.installed.title": "CLI installeret", + "desktop.cli.installed.message": + "CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installation mislykkedes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode kunne ikke starte", + "desktop.error.serverStartFailed.description": + "Den lokale OpenCode-server kunne ikke startes. Genstart appen, eller tjek dine netværksindstillinger (VPN/proxy) og prøv igen.", +} diff --git a/packages/desktop/src/i18n/de.ts b/packages/desktop/src/i18n/de.ts new file mode 100644 index 00000000000..2559d981e20 --- /dev/null +++ b/packages/desktop/src/i18n/de.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Nach Updates suchen...", + "desktop.menu.installCli": "CLI installieren...", + "desktop.menu.reloadWebview": "Webview neu laden", + "desktop.menu.restart": "Neustart", + + "desktop.dialog.chooseFolder": "Ordner auswählen", + "desktop.dialog.chooseFile": "Datei auswählen", + "desktop.dialog.saveFile": "Datei speichern", + + "desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen", + "desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden", + "desktop.updater.none.title": "Kein Update verfügbar", + "desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode", + "desktop.updater.downloadFailed.title": "Update fehlgeschlagen", + "desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden", + "desktop.updater.downloaded.title": "Update heruntergeladen", + "desktop.updater.downloaded.prompt": + "Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?", + "desktop.updater.installFailed.title": "Update fehlgeschlagen", + "desktop.updater.installFailed.message": "Update konnte nicht installiert werden", + + "desktop.cli.installed.title": "CLI installiert", + "desktop.cli.installed.message": + "CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.", + "desktop.cli.failed.title": "Installation fehlgeschlagen", + "desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode konnte nicht gestartet werden", + "desktop.error.serverStartFailed.description": + "Der lokale OpenCode-Server konnte nicht gestartet werden. Starten Sie die App neu oder überprüfen Sie Ihre Netzwerkeinstellungen (VPN/Proxy) und versuchen Sie es erneut.", +} diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts new file mode 100644 index 00000000000..c2981f519d8 --- /dev/null +++ b/packages/desktop/src/i18n/en.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.installCli": "Install CLI...", + "desktop.menu.reloadWebview": "Reload Webview", + "desktop.menu.restart": "Restart", + + "desktop.dialog.chooseFolder": "Choose a folder", + "desktop.dialog.chooseFile": "Choose a file", + "desktop.dialog.saveFile": "Save file", + + "desktop.updater.checkFailed.title": "Update Check Failed", + "desktop.updater.checkFailed.message": "Failed to check for updates", + "desktop.updater.none.title": "No Update Available", + "desktop.updater.none.message": "You are already using the latest version of OpenCode", + "desktop.updater.downloadFailed.title": "Update Failed", + "desktop.updater.downloadFailed.message": "Failed to download update", + "desktop.updater.downloaded.title": "Update Downloaded", + "desktop.updater.downloaded.prompt": + "Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?", + "desktop.updater.installFailed.title": "Update Failed", + "desktop.updater.installFailed.message": "Failed to install update", + + "desktop.cli.installed.title": "CLI Installed", + "desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.", + "desktop.cli.failed.title": "Installation Failed", + "desktop.cli.failed.message": "Failed to install CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode failed to start", + "desktop.error.serverStartFailed.description": + "The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.", +} diff --git a/packages/desktop/src/i18n/es.ts b/packages/desktop/src/i18n/es.ts new file mode 100644 index 00000000000..d1045a90cf3 --- /dev/null +++ b/packages/desktop/src/i18n/es.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Buscar actualizaciones...", + "desktop.menu.installCli": "Instalar CLI...", + "desktop.menu.reloadWebview": "Recargar Webview", + "desktop.menu.restart": "Reiniciar", + + "desktop.dialog.chooseFolder": "Elegir una carpeta", + "desktop.dialog.chooseFile": "Elegir un archivo", + "desktop.dialog.saveFile": "Guardar archivo", + + "desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida", + "desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones", + "desktop.updater.none.title": "No hay actualizaciones disponibles", + "desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode", + "desktop.updater.downloadFailed.title": "Actualización fallida", + "desktop.updater.downloadFailed.message": "No se pudo descargar la actualización", + "desktop.updater.downloaded.title": "Actualización descargada", + "desktop.updater.downloaded.prompt": + "Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?", + "desktop.updater.installFailed.title": "Actualización fallida", + "desktop.updater.installFailed.message": "No se pudo instalar la actualización", + + "desktop.cli.installed.title": "CLI instalada", + "desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.", + "desktop.cli.failed.title": "Instalación fallida", + "desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode no pudo iniciarse", + "desktop.error.serverStartFailed.description": + "No se pudo iniciar el servidor local de OpenCode. Reinicia la aplicación o revisa tu configuración de red (VPN/proxy) y vuelve a intentarlo.", +} diff --git a/packages/desktop/src/i18n/fr.ts b/packages/desktop/src/i18n/fr.ts new file mode 100644 index 00000000000..5c574edf53c --- /dev/null +++ b/packages/desktop/src/i18n/fr.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Vérifier les mises à jour...", + "desktop.menu.installCli": "Installer la CLI...", + "desktop.menu.reloadWebview": "Recharger la Webview", + "desktop.menu.restart": "Redémarrer", + + "desktop.dialog.chooseFolder": "Choisir un dossier", + "desktop.dialog.chooseFile": "Choisir un fichier", + "desktop.dialog.saveFile": "Enregistrer le fichier", + + "desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour", + "desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour", + "desktop.updater.none.title": "Aucune mise à jour disponible", + "desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode", + "desktop.updater.downloadFailed.title": "Échec de la mise à jour", + "desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour", + "desktop.updater.downloaded.title": "Mise à jour téléchargée", + "desktop.updater.downloaded.prompt": + "La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?", + "desktop.updater.installFailed.title": "Échec de la mise à jour", + "desktop.updater.installFailed.message": "Impossible d'installer la mise à jour", + + "desktop.cli.installed.title": "CLI installée", + "desktop.cli.installed.message": + "CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.", + "desktop.cli.failed.title": "Échec de l'installation", + "desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}", + + "desktop.error.serverStartFailed.title": "Échec du démarrage d'OpenCode", + "desktop.error.serverStartFailed.description": + "Impossible de démarrer le serveur OpenCode local. Redémarrez l'application ou vérifiez vos paramètres réseau (VPN/proxy) et réessayez.", +} diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts new file mode 100644 index 00000000000..f2496346fcb --- /dev/null +++ b/packages/desktop/src/i18n/index.ts @@ -0,0 +1,147 @@ +import * as i18n from "@solid-primitives/i18n" +import { Store } from "@tauri-apps/plugin-store" + +import { dict as desktopEn } from "./en" +import { dict as desktopZh } from "./zh" +import { dict as desktopZht } from "./zht" +import { dict as desktopKo } from "./ko" +import { dict as desktopDe } from "./de" +import { dict as desktopEs } from "./es" +import { dict as desktopFr } from "./fr" +import { dict as desktopDa } from "./da" +import { dict as desktopJa } from "./ja" +import { dict as desktopPl } from "./pl" +import { dict as desktopRu } from "./ru" +import { dict as desktopAr } from "./ar" +import { dict as desktopNo } from "./no" +import { dict as desktopBr } from "./br" + +import { dict as appEn } from "../../../app/src/i18n/en" +import { dict as appZh } from "../../../app/src/i18n/zh" +import { dict as appZht } from "../../../app/src/i18n/zht" +import { dict as appKo } from "../../../app/src/i18n/ko" +import { dict as appDe } from "../../../app/src/i18n/de" +import { dict as appEs } from "../../../app/src/i18n/es" +import { dict as appFr } from "../../../app/src/i18n/fr" +import { dict as appDa } from "../../../app/src/i18n/da" +import { dict as appJa } from "../../../app/src/i18n/ja" +import { dict as appPl } from "../../../app/src/i18n/pl" +import { dict as appRu } from "../../../app/src/i18n/ru" +import { dict as appAr } from "../../../app/src/i18n/ar" +import { dict as appNo } from "../../../app/src/i18n/no" +import { dict as appBr } from "../../../app/src/i18n/br" + +export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br" + +type RawDictionary = typeof appEn & typeof desktopEn +type Dictionary = i18n.Flatten + +const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"] + +function detectLocale(): Locale { + if (typeof navigator !== "object") return "en" + + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) { + if (language.toLowerCase().includes("hant")) return "zht" + return "zh" + } + if (language.toLowerCase().startsWith("ko")) return "ko" + if (language.toLowerCase().startsWith("de")) return "de" + if (language.toLowerCase().startsWith("es")) return "es" + if (language.toLowerCase().startsWith("fr")) return "fr" + if (language.toLowerCase().startsWith("da")) return "da" + if (language.toLowerCase().startsWith("ja")) return "ja" + if (language.toLowerCase().startsWith("pl")) return "pl" + if (language.toLowerCase().startsWith("ru")) return "ru" + if (language.toLowerCase().startsWith("ar")) return "ar" + if ( + language.toLowerCase().startsWith("no") || + language.toLowerCase().startsWith("nb") || + language.toLowerCase().startsWith("nn") + ) + return "no" + if (language.toLowerCase().startsWith("pt")) return "br" + } + + return "en" +} + +function parseLocale(value: unknown): Locale | null { + if (!value) return null + if (typeof value !== "string") return null + if ((LOCALES as readonly string[]).includes(value)) return value as Locale + return null +} + +function parseRecord(value: unknown) { + if (!value || typeof value !== "object") return null + if (Array.isArray(value)) return null + return value as Record +} + +function pickLocale(value: unknown): Locale | null { + const direct = parseLocale(value) + if (direct) return direct + + const record = parseRecord(value) + if (!record) return null + + return parseLocale(record.locale) +} + +const base = i18n.flatten({ ...appEn, ...desktopEn }) + +function build(locale: Locale): Dictionary { + if (locale === "en") return base + if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) } + if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) } + if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) } + if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) } + if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) } + if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) } + if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) } + if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) } + if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) } + if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) } + if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) } + if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) } + return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) } +} + +const state = { + locale: detectLocale(), + dict: base as Dictionary, + init: undefined as Promise | undefined, +} + +state.dict = build(state.locale) + +const translate = i18n.translator(() => state.dict, i18n.resolveTemplate) + +export function t(key: keyof Dictionary, params?: Record) { + return translate(key, params) +} + +export function initI18n(): Promise { + const cached = state.init + if (cached) return cached + + const promise = (async () => { + const store = await Store.load("opencode.global.dat").catch(() => null) + if (!store) return state.locale + + const raw = await store.get("language").catch(() => null) + const value = typeof raw === "string" ? JSON.parse(raw) : raw + const next = pickLocale(value) ?? state.locale + + state.locale = next + state.dict = build(next) + return next + })().catch(() => state.locale) + + state.init = promise + return promise +} diff --git a/packages/desktop/src/i18n/ja.ts b/packages/desktop/src/i18n/ja.ts new file mode 100644 index 00000000000..94681ab0699 --- /dev/null +++ b/packages/desktop/src/i18n/ja.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "アップデートを確認...", + "desktop.menu.installCli": "CLI をインストール...", + "desktop.menu.reloadWebview": "Webview を再読み込み", + "desktop.menu.restart": "再起動", + + "desktop.dialog.chooseFolder": "フォルダーを選択", + "desktop.dialog.chooseFile": "ファイルを選択", + "desktop.dialog.saveFile": "ファイルを保存", + + "desktop.updater.checkFailed.title": "アップデートの確認に失敗しました", + "desktop.updater.checkFailed.message": "アップデートを確認できませんでした", + "desktop.updater.none.title": "利用可能なアップデートはありません", + "desktop.updater.none.message": "すでに最新バージョンの OpenCode を使用しています", + "desktop.updater.downloadFailed.title": "アップデートに失敗しました", + "desktop.updater.downloadFailed.message": "アップデートをダウンロードできませんでした", + "desktop.updater.downloaded.title": "アップデートをダウンロードしました", + "desktop.updater.downloaded.prompt": + "OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?", + "desktop.updater.installFailed.title": "アップデートに失敗しました", + "desktop.updater.installFailed.message": "アップデートをインストールできませんでした", + + "desktop.cli.installed.title": "CLI をインストールしました", + "desktop.cli.installed.message": + "CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。", + "desktop.cli.failed.title": "インストールに失敗しました", + "desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode の起動に失敗しました", + "desktop.error.serverStartFailed.description": + "ローカルの OpenCode サーバーを起動できませんでした。アプリを再起動するか、ネットワーク設定 (VPN/proxy) を確認して再試行してください。", +} diff --git a/packages/desktop/src/i18n/ko.ts b/packages/desktop/src/i18n/ko.ts new file mode 100644 index 00000000000..93136f2ddd0 --- /dev/null +++ b/packages/desktop/src/i18n/ko.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "업데이트 확인...", + "desktop.menu.installCli": "CLI 설치...", + "desktop.menu.reloadWebview": "Webview 새로고침", + "desktop.menu.restart": "다시 시작", + + "desktop.dialog.chooseFolder": "폴더 선택", + "desktop.dialog.chooseFile": "파일 선택", + "desktop.dialog.saveFile": "파일 저장", + + "desktop.updater.checkFailed.title": "업데이트 확인 실패", + "desktop.updater.checkFailed.message": "업데이트를 확인하지 못했습니다", + "desktop.updater.none.title": "사용 가능한 업데이트 없음", + "desktop.updater.none.message": "이미 최신 버전의 OpenCode를 사용하고 있습니다", + "desktop.updater.downloadFailed.title": "업데이트 실패", + "desktop.updater.downloadFailed.message": "업데이트를 다운로드하지 못했습니다", + "desktop.updater.downloaded.title": "업데이트 다운로드 완료", + "desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?", + "desktop.updater.installFailed.title": "업데이트 실패", + "desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다", + + "desktop.cli.installed.title": "CLI 설치됨", + "desktop.cli.installed.message": + "CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.", + "desktop.cli.failed.title": "설치 실패", + "desktop.cli.failed.message": "CLI 설치 실패: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 시작 실패", + "desktop.error.serverStartFailed.description": + "로컬 OpenCode 서버를 시작할 수 없습니다. 앱을 다시 시작하거나 네트워크 설정(VPN/proxy)을 확인한 후 다시 시도하세요.", +} diff --git a/packages/desktop/src/i18n/no.ts b/packages/desktop/src/i18n/no.ts new file mode 100644 index 00000000000..7deb74687d3 --- /dev/null +++ b/packages/desktop/src/i18n/no.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Se etter oppdateringer...", + "desktop.menu.installCli": "Installer CLI...", + "desktop.menu.reloadWebview": "Last inn Webview på nytt", + "desktop.menu.restart": "Start på nytt", + + "desktop.dialog.chooseFolder": "Velg en mappe", + "desktop.dialog.chooseFile": "Velg en fil", + "desktop.dialog.saveFile": "Lagre fil", + + "desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes", + "desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer", + "desktop.updater.none.title": "Ingen oppdatering tilgjengelig", + "desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode", + "desktop.updater.downloadFailed.title": "Oppdatering mislyktes", + "desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen", + "desktop.updater.downloaded.title": "Oppdatering lastet ned", + "desktop.updater.downloaded.prompt": + "Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?", + "desktop.updater.installFailed.title": "Oppdatering mislyktes", + "desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen", + + "desktop.cli.installed.title": "CLI installert", + "desktop.cli.installed.message": + "CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.", + "desktop.cli.failed.title": "Installasjon mislyktes", + "desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode kunne ikke starte", + "desktop.error.serverStartFailed.description": + "Den lokale OpenCode-serveren kunne ikke startes. Start appen på nytt, eller sjekk nettverksinnstillingene dine (VPN/proxy) og prøv igjen.", +} diff --git a/packages/desktop/src/i18n/pl.ts b/packages/desktop/src/i18n/pl.ts new file mode 100644 index 00000000000..dac2992ba47 --- /dev/null +++ b/packages/desktop/src/i18n/pl.ts @@ -0,0 +1,32 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Sprawdź aktualizacje...", + "desktop.menu.installCli": "Zainstaluj CLI...", + "desktop.menu.reloadWebview": "Przeładuj Webview", + "desktop.menu.restart": "Restartuj", + + "desktop.dialog.chooseFolder": "Wybierz folder", + "desktop.dialog.chooseFile": "Wybierz plik", + "desktop.dialog.saveFile": "Zapisz plik", + + "desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji", + "desktop.updater.none.title": "Brak dostępnych aktualizacji", + "desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode", + "desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji", + "desktop.updater.downloaded.title": "Aktualizacja pobrana", + "desktop.updater.downloaded.prompt": + "Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?", + "desktop.updater.installFailed.title": "Aktualizacja nie powiodła się", + "desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji", + + "desktop.cli.installed.title": "CLI zainstalowane", + "desktop.cli.installed.message": + "CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.", + "desktop.cli.failed.title": "Instalacja nie powiodła się", + "desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Nie udało się uruchomić OpenCode", + "desktop.error.serverStartFailed.description": + "Nie udało się uruchomić lokalnego serwera OpenCode. Uruchom ponownie aplikację lub sprawdź ustawienia sieciowe (VPN/proxy) i spróbuj ponownie.", +} diff --git a/packages/desktop/src/i18n/ru.ts b/packages/desktop/src/i18n/ru.ts new file mode 100644 index 00000000000..6e34e1aa7da --- /dev/null +++ b/packages/desktop/src/i18n/ru.ts @@ -0,0 +1,31 @@ +export const dict = { + "desktop.menu.checkForUpdates": "Проверить обновления...", + "desktop.menu.installCli": "Установить CLI...", + "desktop.menu.reloadWebview": "Перезагрузить Webview", + "desktop.menu.restart": "Перезапустить", + + "desktop.dialog.chooseFolder": "Выберите папку", + "desktop.dialog.chooseFile": "Выберите файл", + "desktop.dialog.saveFile": "Сохранить файл", + + "desktop.updater.checkFailed.title": "Не удалось проверить обновления", + "desktop.updater.checkFailed.message": "Не удалось проверить обновления", + "desktop.updater.none.title": "Обновлений нет", + "desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode", + "desktop.updater.downloadFailed.title": "Обновление не удалось", + "desktop.updater.downloadFailed.message": "Не удалось скачать обновление", + "desktop.updater.downloaded.title": "Обновление загружено", + "desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?", + "desktop.updater.installFailed.title": "Обновление не удалось", + "desktop.updater.installFailed.message": "Не удалось установить обновление", + + "desktop.cli.installed.title": "CLI установлен", + "desktop.cli.installed.message": + "CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.", + "desktop.cli.failed.title": "Ошибка установки", + "desktop.cli.failed.message": "Не удалось установить CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "Не удалось запустить OpenCode", + "desktop.error.serverStartFailed.description": + "Не удалось запустить локальный сервер OpenCode. Перезапустите приложение или проверьте настройки сети (VPN/proxy) и попробуйте снова.", +} diff --git a/packages/desktop/src/i18n/zh.ts b/packages/desktop/src/i18n/zh.ts new file mode 100644 index 00000000000..3f5fe59d40d --- /dev/null +++ b/packages/desktop/src/i18n/zh.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "检查更新...", + "desktop.menu.installCli": "安装 CLI...", + "desktop.menu.reloadWebview": "重新加载 Webview", + "desktop.menu.restart": "重启", + + "desktop.dialog.chooseFolder": "选择文件夹", + "desktop.dialog.chooseFile": "选择文件", + "desktop.dialog.saveFile": "保存文件", + + "desktop.updater.checkFailed.title": "检查更新失败", + "desktop.updater.checkFailed.message": "无法检查更新", + "desktop.updater.none.title": "没有可用更新", + "desktop.updater.none.message": "你已经在使用最新版本的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失败", + "desktop.updater.downloadFailed.message": "无法下载更新", + "desktop.updater.downloaded.title": "更新已下载", + "desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?", + "desktop.updater.installFailed.title": "更新失败", + "desktop.updater.installFailed.message": "无法安装更新", + + "desktop.cli.installed.title": "CLI 已安装", + "desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。", + "desktop.cli.failed.title": "安装失败", + "desktop.cli.failed.message": "无法安装 CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 启动失败", + "desktop.error.serverStartFailed.description": + "无法启动本地 OpenCode 服务器。请重启应用,或检查网络设置 (VPN/proxy) 后重试。", +} diff --git a/packages/desktop/src/i18n/zht.ts b/packages/desktop/src/i18n/zht.ts new file mode 100644 index 00000000000..b09bff742c7 --- /dev/null +++ b/packages/desktop/src/i18n/zht.ts @@ -0,0 +1,30 @@ +export const dict = { + "desktop.menu.checkForUpdates": "檢查更新...", + "desktop.menu.installCli": "安裝 CLI...", + "desktop.menu.reloadWebview": "重新載入 Webview", + "desktop.menu.restart": "重新啟動", + + "desktop.dialog.chooseFolder": "選擇資料夾", + "desktop.dialog.chooseFile": "選擇檔案", + "desktop.dialog.saveFile": "儲存檔案", + + "desktop.updater.checkFailed.title": "檢查更新失敗", + "desktop.updater.checkFailed.message": "無法檢查更新", + "desktop.updater.none.title": "沒有可用更新", + "desktop.updater.none.message": "你已在使用最新版的 OpenCode", + "desktop.updater.downloadFailed.title": "更新失敗", + "desktop.updater.downloadFailed.message": "無法下載更新", + "desktop.updater.downloaded.title": "更新已下載", + "desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?", + "desktop.updater.installFailed.title": "更新失敗", + "desktop.updater.installFailed.message": "無法安裝更新", + + "desktop.cli.installed.title": "CLI 已安裝", + "desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。", + "desktop.cli.failed.title": "安裝失敗", + "desktop.cli.failed.message": "無法安裝 CLI: {{error}}", + + "desktop.error.serverStartFailed.title": "OpenCode 啟動失敗", + "desktop.error.serverStartFailed.description": + "無法啟動本地 OpenCode 伺服器。請重新啟動應用程式,或檢查網路設定 (VPN/proxy) 後再試一次。", +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b19adfeda5a..344c6be8d9c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -18,16 +18,17 @@ import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" +import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error( - "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", - ) + throw new Error(t("error.dev.rootNotFound")) } +void initI18n() + // Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements). // This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows. const originalGetComputedStyle = window.getComputedStyle @@ -54,7 +55,7 @@ const createPlatform = (password: Accessor): Platform => ({ const result = await open({ directory: true, multiple: opts?.multiple ?? false, - title: opts?.title ?? "Choose a folder", + title: opts?.title ?? t("desktop.dialog.chooseFolder"), }) return result }, @@ -63,14 +64,14 @@ const createPlatform = (password: Accessor): Platform => ({ const result = await open({ directory: false, multiple: opts?.multiple ?? false, - title: opts?.title ?? "Choose a file", + title: opts?.title ?? t("desktop.dialog.chooseFile"), }) return result }, async saveFilePickerDialog(opts) { const result = await save({ - title: opts?.title ?? "Save file", + title: opts?.title ?? t("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, }) return result @@ -380,7 +381,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. const errorMessage = () => { const error = serverData.error - if (!error) return "Unknown error" + if (!error) return t("error.chain.unknown") if (typeof error === "string") return error if (error instanceof Error) return error.message return String(error) @@ -410,16 +411,15 @@ function ServerGate(props: { children: (data: Accessor) => JSX. } >
-
OpenCode failed to start
+
{t("desktop.error.serverStartFailed.title")}
- The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) - and try again. + {t("desktop.error.serverStartFailed.description")}
{errorMessage()}
diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 1b4c611353a..2edeff42b25 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -5,10 +5,13 @@ import { relaunch } from "@tauri-apps/plugin-process" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" +import { initI18n, t } from "./i18n" export async function createMenu() { if (ostype() !== "macos") return + await initI18n() + const menu = await Menu.new({ items: [ await Submenu.new({ @@ -20,22 +23,22 @@ export async function createMenu() { await MenuItem.new({ enabled: UPDATER_ENABLED, action: () => runUpdater({ alertOnFail: true }), - text: "Check For Updates...", + text: t("desktop.menu.checkForUpdates"), }), await MenuItem.new({ action: () => installCli(), - text: "Install CLI...", + text: t("desktop.menu.installCli"), }), await MenuItem.new({ action: async () => window.location.reload(), - text: "Reload Webview", + text: t("desktop.menu.reloadWebview"), }), await MenuItem.new({ action: async () => { await invoke("kill_sidecar").catch(() => undefined) await relaunch().catch(() => undefined) }, - text: "Restart", + text: t("desktop.menu.restart"), }), await PredefinedMenuItem.new({ item: "Separator", diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index 4753ee66390..b48bb6be025 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -4,41 +4,45 @@ import { ask, message } from "@tauri-apps/plugin-dialog" import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" +import { initI18n, t } from "./i18n" + export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { + await initI18n() + let update try { update = await check() } catch { - if (alertOnFail) await message("Failed to check for updates", { title: "Update Check Failed" }) + if (alertOnFail) + await message(t("desktop.updater.checkFailed.message"), { title: t("desktop.updater.checkFailed.title") }) return } if (!update) { - if (alertOnFail) - await message("You are already using the latest version of OpenCode", { title: "No Update Available" }) + if (alertOnFail) await message(t("desktop.updater.none.message"), { title: t("desktop.updater.none.title") }) return } try { await update.download() } catch { - if (alertOnFail) await message("Failed to download update", { title: "Update Failed" }) + if (alertOnFail) + await message(t("desktop.updater.downloadFailed.message"), { title: t("desktop.updater.downloadFailed.title") }) return } - const shouldUpdate = await ask( - `Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`, - { title: "Update Downloaded" }, - ) + const shouldUpdate = await ask(t("desktop.updater.downloaded.prompt", { version: update.version }), { + title: t("desktop.updater.downloaded.title"), + }) if (!shouldUpdate) return try { if (ostype() === "windows") await invoke("kill_sidecar") await update.install() } catch { - await message("Failed to install update", { title: "Update Failed" }) + await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") }) return } diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 1a216c88f9e..9940d5cf351 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7c43d144b9a..f0bdce2c4dd 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.36" +version = "1.1.37" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.36/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.37/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 90abb354281..0ee81b74f72 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.36", + "version": "1.1.37", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 68be07e0c14..1168b607f8f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.36", + "version": "1.1.37", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 10d7a25f88f..f8893a058cc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -389,10 +389,52 @@ function App() { slash: { name: "mcps", }, + keybind: "mcp_toggle", onSelect: () => { dialog.replace(() => ) }, }, + ...Array.from({ length: 10 }, (_, i) => { + const slot = i + 1 + return { + title: `Toggle MCP slot ${slot}`, + value: `mcp.toggle.${slot}`, + category: "Agent", + keybind: `mcp_toggle_${slot}` as keyof typeof sync.data.config.keybinds, + hidden: true, + onSelect: async () => { + const mcpNames = Object.keys(sync.data.mcp).sort() + const mcpName = mcpNames[i] + if (!mcpName) { + toast.show({ + variant: "warning", + message: `No MCP configured in slot ${slot}`, + duration: 2000, + }) + return + } + try { + await local.mcp.toggle(mcpName) + const status = await sdk.client.mcp.status() + if (status.data) { + sync.set("mcp", status.data) + } + const isEnabled = local.mcp.isEnabled(mcpName) + toast.show({ + variant: "info", + message: `${mcpName}: ${isEnabled ? "enabled" : "disabled"}`, + duration: 2000, + }) + } catch (error) { + toast.show({ + variant: "error", + message: `Failed to toggle ${mcpName}: ${error}`, + duration: 3000, + }) + } + }, + } + }), { title: "Agent cycle", value: "agent.cycle", @@ -511,6 +553,7 @@ function App() { title: "Toggle console", category: "System", value: "app.console", + keybind: "console_toggle", onSelect: (dialog) => { renderer.console.toggle() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 4e1171a4201..dc3f337370a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -127,7 +127,7 @@ function AutoMethod(props: AutoMethodProps) { useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { - const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url + const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url Clipboard.copy(code) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 771962b75d1..8e6208b140b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,16 +1,13 @@ import { TextAttributes, RGBA } from "@opentui/core" import { For, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" +import { logo, marks } from "@/cli/logo" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ~ = shadow top only (▀ with fg=shadow) -const SHADOW_MARKER = /[_^~]/ - -const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█__█ █__█ █^^^ █__█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀`] - -const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`] +const SHADOW_MARKER = new RegExp(`[${marks}]`) export function Logo() { const { theme } = useTheme() @@ -75,11 +72,11 @@ export function Logo() { return ( - + {(line, index) => ( {renderLine(line, theme.textMuted, false)} - {renderLine(LOGO_RIGHT[index()], theme.text, true)} + {renderLine(logo.right[index()], theme.text, true)} )} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5f47562d2e3..24994aaaee1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -58,6 +58,7 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" +import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import { Clipboard } from "../../util/clipboard" @@ -520,6 +521,7 @@ export function Session() { name: "timestamps", aliases: ["toggle-timestamps"], }, + keybind: "timestamps_toggle", onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -533,11 +535,25 @@ export function Session() { name: "thinking", aliases: ["toggle-thinking"], }, + keybind: "thinking_toggle", onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() }, }, + { + title: "Toggle diff wrapping", + value: "session.toggle.diffwrap", + category: "Session", + slash: { + name: "diffwrap", + }, + keybind: "diffwrap_toggle", + onSelect: (dialog) => { + setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) + dialog.clear() + }, + }, { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", @@ -1338,15 +1354,22 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess return ( - + + + + + + + + ) diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts new file mode 100644 index 00000000000..44fb93c15b3 --- /dev/null +++ b/packages/opencode/src/cli/logo.ts @@ -0,0 +1,6 @@ +export const logo = { + left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"], + right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], +} + +export const marks = "_^~" diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index acd1383a070..9df1f4ac550 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,15 +1,9 @@ import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/util/error" +import { logo as glyphs } from "./logo" export namespace UI { - const LOGO = [ - [`  `, ` ▄ `], - [`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`], - [`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`], - [`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`], - ] - export const CancelledError = NamedError.create("UICancelledError", z.void()) export const Style = { @@ -47,15 +41,50 @@ export namespace UI { } export function logo(pad?: string) { - const result = [] - for (const row of LOGO) { + const result: string[] = [] + const reset = "\x1b[0m" + const left = { + fg: Bun.color("gray", "ansi") ?? "", + shadow: "\x1b[38;5;235m", + bg: "\x1b[48;5;235m", + } + const right = { + fg: reset, + shadow: "\x1b[38;5;238m", + bg: "\x1b[48;5;238m", + } + const gap = " " + const draw = (line: string, fg: string, shadow: string, bg: string) => { + const parts: string[] = [] + for (const char of line) { + if (char === "_") { + parts.push(bg, " ", reset) + continue + } + if (char === "^") { + parts.push(fg, bg, "▀", reset) + continue + } + if (char === "~") { + parts.push(shadow, "▀", reset) + continue + } + if (char === " ") { + parts.push(" ") + continue + } + parts.push(fg, char, reset) + } + return parts.join("") + } + glyphs.left.forEach((row, index) => { if (pad) result.push(pad) - result.push(Bun.color("gray", "ansi")) - result.push(row[0]) - result.push("\x1b[0m") - result.push(row[1]) + result.push(draw(row, left.fg, left.shadow, left.bg)) + result.push(gap) + const other = glyphs.right[index] ?? "" + result.push(draw(other, right.fg, right.shadow, right.bg)) result.push(EOL) - } + }) return result.join("").trimEnd() } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..a70f11e6385 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -650,6 +650,21 @@ export namespace Config { sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), + console_toggle: z.string().optional().default("none").describe("Toggle console"), + mcp_toggle: z.string().optional().default("none").describe("Toggle MCP dialog"), + mcp_toggle_1: z.string().optional().default("none").describe("Toggle MCP slot 1"), + mcp_toggle_2: z.string().optional().default("none").describe("Toggle MCP slot 2"), + mcp_toggle_3: z.string().optional().default("none").describe("Toggle MCP slot 3"), + mcp_toggle_4: z.string().optional().default("none").describe("Toggle MCP slot 4"), + mcp_toggle_5: z.string().optional().default("none").describe("Toggle MCP slot 5"), + mcp_toggle_6: z.string().optional().default("none").describe("Toggle MCP slot 6"), + mcp_toggle_7: z.string().optional().default("none").describe("Toggle MCP slot 7"), + mcp_toggle_8: z.string().optional().default("none").describe("Toggle MCP slot 8"), + mcp_toggle_9: z.string().optional().default("none").describe("Toggle MCP slot 9"), + mcp_toggle_10: z.string().optional().default("none").describe("Toggle MCP slot 10"), + timestamps_toggle: z.string().optional().default("none").describe("Toggle timestamps visibility"), + thinking_toggle: z.string().optional().default("none").describe("Toggle thinking visibility"), + diffwrap_toggle: z.string().optional().default("none").describe("Toggle diff wrapping"), status_view: z.string().optional().default("s").describe("View status"), session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), @@ -685,6 +700,7 @@ export namespace Config { messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), messages_copy: z.string().optional().default("y").describe("Copy message"), + session_copy: z.string().optional().default("none").describe("Copy session transcript"), messages_undo: z.string().optional().default("u").describe("Undo message"), messages_redo: z.string().optional().default("r").describe("Redo message"), messages_toggle_conceal: z diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 0274dcc82b0..551435e72d0 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -46,6 +46,7 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK") export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") + export const OPENCODE_EXPERIMENTAL_MARKDOWN = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] function number(key: string) { diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 198e8ce25d7..b6f1a96a9f5 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -10,6 +10,7 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 interface PkceCodes { verifier: string @@ -461,7 +462,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { }, methods: [ { - label: "ChatGPT Pro/Plus", + label: "ChatGPT Pro/Plus (browser)", type: "oauth", authorize: async () => { const { redirectUri } = await startOAuthServer() @@ -490,6 +491,89 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } }, }, + { + label: "ChatGPT Pro/Plus (headless)", + type: "oauth", + authorize: async () => { + const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ client_id: CLIENT_ID }), + }) + + if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization") + + const deviceData = (await deviceResponse.json()) as { + device_auth_id: string + user_code: string + interval: string + } + const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000 + + return { + url: `${ISSUER}/codex/device`, + instructions: `Enter code: ${deviceData.user_code}`, + method: "auto" as const, + async callback() { + while (true) { + const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + device_auth_id: deviceData.device_auth_id, + user_code: deviceData.user_code, + }), + }) + + if (response.ok) { + const data = (await response.json()) as { + authorization_code: string + code_verifier: string + } + + const tokenResponse = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: data.authorization_code, + redirect_uri: `${ISSUER}/deviceauth/callback`, + client_id: CLIENT_ID, + code_verifier: data.code_verifier, + }).toString(), + }) + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.status}`) + } + + const tokens: TokenResponse = await tokenResponse.json() + + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + accountId: extractAccountId(tokens), + } + } + + if (response.status !== 403 && response.status !== 404) { + return { type: "failed" as const } + } + + await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS) + } + }, + } + }, + }, { label: "Manually enter API Key", type: "api", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7e8628de7a4..81c14f9d68f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index e6d968ed626..a752b3362ce 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2a63d721215..27f5ccad05b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -977,6 +977,26 @@ export type KeybindsConfig = { * Toggle username visibility */ username_toggle?: string + /** + * Toggle console + */ + console_toggle?: string + /** + * Toggle MCP dialog + */ + mcp_toggle?: string + /** + * Toggle timestamps visibility + */ + timestamps_toggle?: string + /** + * Toggle thinking visibility + */ + thinking_toggle?: string + /** + * Toggle diff wrapping + */ + diffwrap_toggle?: string /** * View status */ @@ -1085,6 +1105,10 @@ export type KeybindsConfig = { * Copy message */ messages_copy?: string + /** + * Copy session transcript + */ + session_copy?: string /** * Undo message */ diff --git a/packages/slack/package.json b/packages/slack/package.json index 731c2d15d52..81ebec8fe15 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4c663b1b1c0..27ac2f5cf58 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.36", + "version": "1.1.37", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 933c7ce5f24..d9b34592304 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -43,6 +43,10 @@ background-color: transparent; color: var(--text-strong); + [data-slot="icon-svg"] { + color: var(--icon-base); + } + &:hover:not(:disabled) { background-color: var(--surface-raised-base-hover); } @@ -54,8 +58,11 @@ } &:disabled { color: var(--text-weak); - opacity: 0.7; cursor: not-allowed; + + [data-slot="icon-svg"] { + color: var(--icon-disabled); + } } &[data-selected="true"]:not(:disabled) { background-color: var(--surface-raised-base-hover); diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index dd75b190524..20d2fef1529 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -54,6 +54,13 @@ background-color: var(--background-stronger) !important; } + [data-slot="session-review-accordion-item"][data-selected] { + [data-slot="session-review-accordion-content"] { + box-shadow: var(--shadow-xs-border-select); + border-radius: var(--radius-lg); + } + } + [data-slot="accordion-item"] { [data-slot="accordion-content"] { display: none; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 8dfbbb1ca68..84ec934e24d 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -44,6 +44,7 @@ export interface SessionReviewProps { comments?: SessionReviewComment[] focusedComment?: SessionReviewFocus | null onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void + focusedFile?: string open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -501,6 +502,7 @@ export const SessionReview = (props: SessionReviewProps) => { id={diffId(diff.file)} data-file={diff.file} data-slot="session-review-accordion-item" + data-selected={props.focusedFile === diff.file ? "" : undefined} > diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css index 6b432d045da..2ca474dc3c3 100644 --- a/packages/ui/src/components/spinner.css +++ b/packages/ui/src/components/spinner.css @@ -1,5 +1,5 @@ [data-component="spinner"] { - color: var(--text-base); + color: inherit; flex-shrink: 0; width: 18px; aspect-ratio: 1; diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 04a9837fd8a..951450d540a 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -286,6 +286,7 @@ --icon-diff-add-active: var(--mint-light-12); --icon-diff-delete-base: var(--ember-light-10); --icon-diff-delete-hover: var(--ember-light-11); + --icon-diff-modified-base: var(--icon-warning-base); --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); --syntax-string: #006656; @@ -543,6 +544,7 @@ --icon-diff-add-active: var(--mint-dark-11); --icon-diff-delete-base: var(--ember-dark-9); --icon-diff-delete-hover: var(--ember-dark-10); + --icon-diff-modified-base: var(--icon-warning-base); --syntax-comment: var(--text-weak); --syntax-regexp: var(--text-base); --syntax-string: #00ceb9; diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 8c25aefd7b8..2e9c3a52144 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -240,6 +240,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11] tokens["icon-diff-delete-base"] = diffDelete[isDark ? 8 : 9] tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 9 : 10] + tokens["icon-diff-modified-base"] = tokens["icon-warning-base"] tokens["syntax-comment"] = "var(--text-weak)" tokens["syntax-regexp"] = "var(--text-base)" diff --git a/packages/util/package.json b/packages/util/package.json index 6b9a8d26d54..c59ed182dfd 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.36", + "version": "1.1.37", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 12b3c689169..3725bbec53b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.36", + "version": "1.1.37", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index fed64bf77ba..07110dc1b5e 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,37 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | -| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | -| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | -| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | -| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | -| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | -| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | -| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | -| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | -| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | -| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | -| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | -| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | -| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | -| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | -| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | -| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | -| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | -| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | -| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | -| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | -| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | -| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | +| Name | Description | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | +| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | +| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | --- diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9812f6bf3a4..7ea0b8979db 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.36", + "version": "1.1.37", "publisher": "sst-dev", "repository": { "type": "git",