diff --git a/.github/last-synced-tag b/.github/last-synced-tag index 19f5e1b57ed..a829bcbe438 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.1.12 +v1.1.13 diff --git a/.gitignore b/.gitignore index 76ec47537d7..41e6625a036 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ docker/workspace opencode-dev logs/ .loop* +*.bun-build diff --git a/bun.lock b/bun.lock index 8da6739c0d9..8c6317dcd28 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -71,7 +71,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -100,7 +100,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -127,7 +127,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -151,7 +151,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -175,7 +175,7 @@ }, "packages/desktop": { "name": "@shuvcode/desktop", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -204,7 +204,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -233,7 +233,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -249,7 +249,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.12", + "version": "1.1.13", "bin": { "opencode": "./bin/opencode", }, @@ -353,7 +353,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -373,7 +373,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.12", + "version": "1.1.13", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -384,7 +384,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -397,7 +397,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -436,7 +436,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "zod": "catalog:", }, @@ -447,7 +447,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.12", + "version": "1.1.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 53d87c132c0..4c6bc1d0a59 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.12", + "version": "1.1.13", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 7c13e40dee5..ef24e81f176 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,7 +34,7 @@ const Loading = () =>
localhost (same as upstream + shuv.ai) + // 2. Configured server URL (from desktop settings) + if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl + + // 3. Known production hosts -> localhost (same as upstream + shuv.ai) if (location.hostname.includes("opencode.ai") || location.hostname.includes("shuv.ai")) return "http://localhost:4096" - // 3. Desktop app (Tauri) with injected port + // 4. Desktop app (Tauri) with injected port if (window.__SHUVCODE__?.port) return `http://127.0.0.1:${window.__SHUVCODE__.port}` if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}` - // 4. Dev mode -> same-origin so Vite proxy handles LAN access + CORS - if (import.meta.env.DEV) return window.location.origin + // 5. Dev mode -> same-origin so Vite proxy handles LAN access + CORS + if (import.meta.env.DEV) { + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + } - // 5. Default -> same origin (production web command) + // 6. Default -> same origin (production web command) return window.location.origin }) diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx new file mode 100644 index 00000000000..472a1994f13 --- /dev/null +++ b/packages/app/src/components/dialog-fork.tsx @@ -0,0 +1,99 @@ +import { Component, createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { extractPromptFromParts } from "@/utils/prompt" +import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" + +interface ForkableMessage { + id: string + text: string + time: string +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString(undefined, { timeStyle: "short" }) +} + +export const DialogFork: Component = () => { + const params = useParams() + const navigate = useNavigate() + const sync = useSync() + const sdk = useSDK() + const prompt = usePrompt() + const dialog = useDialog() + + const messages = createMemo((): ForkableMessage[] => { + const sessionID = params.id + if (!sessionID) return [] + + const msgs = sync.data.message[sessionID] ?? [] + const result: ForkableMessage[] = [] + + for (const message of msgs) { + if (message.role !== "user") continue + + const parts = sync.data.part[message.id] ?? [] + const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) + if (!textPart) continue + + result.push({ + id: message.id, + text: textPart.text.replace(/\n/g, " ").slice(0, 200), + time: formatTime(new Date(message.time.created)), + }) + } + + return result.reverse() + }) + + const handleSelect = (item: ForkableMessage | undefined) => { + if (!item) return + + const sessionID = params.id + if (!sessionID) return + + const parts = sync.data.part[item.id] ?? [] + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + + dialog.close() + + sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => { + if (!forked.data) return + navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) + requestAnimationFrame(() => { + prompt.set(restored) + }) + }) + } + + return ( + + x.id} + items={messages} + filterKeys={["text"]} + onSelect={handleSelect} + > + {(item) => ( +
+ + {item.text} + + + {item.time} + +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 6d224c6c3f3..7e2bcc181ad 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -35,6 +35,8 @@ export function DialogSelectServer() { error: "", status: {} as Record, }) + const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) + const isDesktop = platform.platform === "desktop" const items = createMemo(() => { const current = server.url @@ -173,6 +175,53 @@ export function DialogSelectServer() {
+ + +
+
+

Default server

+

+ Connect to this server on app launch instead of starting a local server. Requires restart. +

+
+
+ No server selected} + > + + + } + > +
+ {serverDisplayName(defaultUrl()!)} +
+ + +
+
+
) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c257a93d045..0788e0945b7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -386,6 +386,7 @@ export const PromptInput: Component = (props) => { const { flat: atFlat, active: atActive, + setActive: setAtActive, onInput: atOnInput, onKeyDown: atOnKeyDown, } = useFilteredList({ @@ -463,6 +464,7 @@ export const PromptInput: Component = (props) => { const { flat: slashFlat, active: slashActive, + setActive: setSlashActive, onInput: slashOnInput, onKeyDown: slashOnKeyDown, refetch: slashRefetch, @@ -1313,6 +1315,7 @@ export const PromptInput: Component = (props) => { class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + onMouseDown={(e) => e.preventDefault()} > @@ -1327,10 +1330,8 @@ export const PromptInput: Component = (props) => { "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, "bg-surface-raised-base-hover": atActive() === atKey(item), }} - onMouseDown={(e) => { - e.preventDefault() - handleAtSelect(item) - }} + onClick={() => handleAtSelect(item)} + onMouseEnter={() => setAtActive(atKey(item))} > = (props) => { "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, "bg-surface-raised-base-hover": slashActive() === cmd.id, }} - onMouseDown={(e) => { - e.preventDefault() - handleSlashSelect(cmd) - }} + onClick={() => handleSlashSelect(cmd)} + onMouseEnter={() => setSlashActive(cmd.id)} >
/{cmd.trigger} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index aba1e515c46..898dca3a523 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -47,6 +47,12 @@ export type Platform = { /** Fetch override */ fetch?: typeof fetch + + /** Get the configured default server URL (desktop only) */ + getDefaultServerUrl?(): Promise + + /** Set the default server URL to use on app startup (desktop only) */ + setDefaultServerUrl?(url: string | null): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 06c37b59291..8945cd37e9e 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -16,10 +16,7 @@ export function normalizeServerUrl(input: string) { export function serverDisplayName(url: string) { if (!url) return "" - return url - .replace(/^https?:\/\//, "") - .replace(/\/+$/, "") - .split("/")[0] + return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") } function projectsKey(url: string) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 873b8fc1a55..af286c229dd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -31,6 +31,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" @@ -696,6 +697,15 @@ export default function Page() { }) }, }, + { + id: "session.fork", + title: "Fork from message", + description: "Create a new session from a previous message", + category: "Session", + slash: "fork", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: () => dialog.show(() => ), + }, ]) const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 05fb0c8bffd..6bd1ba0771f 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.12", + "version": "1.1.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 074932a91fd..5a30d55771c 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.12", + "version": "1.1.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index 15cbe80f8a7..b06fb5654c3 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -41,8 +41,8 @@ if (identifier.startsWith("wrk_")) { subscribed: SubscriptionTable.timeCreated, }) .from(UserTable) - .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) - .innerJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) + .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) + .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) .where(eq(UserTable.accountID, accountID)) .then((rows) => rows.map((row) => ({ @@ -113,6 +113,8 @@ async function printWorkspace(workspaceID: string) { .select({ balance: BillingTable.balance, customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + subscriptionCouponID: BillingTable.subscriptionCouponID, }) .from(BillingTable) .where(eq(BillingTable.workspaceID, workspace.id)) @@ -149,6 +151,7 @@ async function printWorkspace(workspaceID: string) { ), ) + /* await printTable("Usage", (tx) => tx .select({ @@ -174,6 +177,7 @@ async function printWorkspace(workspaceID: string) { })), ), ) + */ } function formatMicroCents(value: number | null | undefined) { diff --git a/packages/console/core/script/remove-black.ts b/packages/console/core/script/remove-black.ts new file mode 100644 index 00000000000..0803c8f8330 --- /dev/null +++ b/packages/console/core/script/remove-black.ts @@ -0,0 +1,78 @@ +import { Billing } from "../src/billing.js" +import { and, Database, eq } from "../src/drizzle/index.js" +import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" + +const workspaceID = process.argv[2] + +if (!workspaceID) { + console.error("Usage: bun remove-black.ts ") + process.exit(1) +} + +console.log(`Removing subscription from workspace ${workspaceID}`) + +// Look up the workspace billing +const billing = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), +) + +if (!billing) { + console.error(`Error: No billing record found for workspace ${workspaceID}`) + process.exit(1) +} + +if (!billing.subscriptionID) { + console.error(`Error: Workspace ${workspaceID} does not have a subscription`) + process.exit(1) +} + +console.log(` Customer ID: ${billing.customerID}`) +console.log(` Subscription ID: ${billing.subscriptionID}`) + +// Clear workspaceID from Stripe customer metadata +if (billing.customerID) { + //await Billing.stripe().customers.update(billing.customerID, { + // metadata: { + // workspaceID: "", + // }, + //}) + //console.log(`Cleared workspaceID from Stripe customer metadata`) +} + +await Database.transaction(async (tx) => { + // Clear subscription-related fields from billing table + await tx + .update(BillingTable) + .set({ + // customerID: null, + subscriptionID: null, + subscriptionCouponID: null, + // paymentMethodID: null, + // paymentMethodLast4: null, + // paymentMethodType: null, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + + // Delete from subscription table + await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID)) + + // Delete from payments table + await tx + .delete(PaymentTable) + .where( + and( + eq(PaymentTable.workspaceID, workspaceID), + eq(PaymentTable.enrichment, { type: "subscription" }), + eq(PaymentTable.amount, 20000000000), + ), + ) +}) + +console.log(`Successfully removed subscription from workspace ${workspaceID}`) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 33d1f5b3a1b..5e80e0670b4 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.12", + "version": "1.1.13", "$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 ea7ac4f5671..dcc7c61b181 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.12", + "version": "1.1.13", "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 1b151f183fe..62bb28d303c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@shuvcode/desktop", "private": true, - "version": "1.1.12", + "version": "1.1.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index c533bf9e95d..92953ea19ca 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2795,6 +2795,7 @@ dependencies = [ "futures", "gtk", "listeners", + "reqwest", "semver", "serde", "serde_json", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 3c898319a68..746651d1886 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -38,6 +38,7 @@ listeners = "0.3" tauri-plugin-os = "2" futures = "0.3.31" semver = "1.0.27" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index a8f357cfd95..9480c72ff24 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -13,12 +13,17 @@ use tauri::{ path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow, }; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; +use tauri_plugin_store::StoreExt; use tokio::net::TcpSocket; use crate::window_customizer::PinchZoomDisablePlugin; +const SETTINGS_STORE: &str = "opencode.settings.dat"; +const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; + #[derive(Clone)] struct ServerState { child: Arc>>, @@ -88,6 +93,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri .map_err(|_| "Failed to get server status".to_string())? } +#[tauri::command] +async fn get_default_server_url(app: AppHandle) -> Result, String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let value = store.get(DEFAULT_SERVER_URL_KEY); + match value { + Some(v) => Ok(v.as_str().map(String::from)), + None => Ok(None), + } +} + +#[tauri::command] +async fn set_default_server_url(app: AppHandle, url: Option) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + match url { + Some(u) => { + store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u)); + } + None => { + store.delete(DEFAULT_SERVER_URL_KEY); + } + } + + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + fn get_sidecar_port() -> u32 { option_env!("SHUVCODE_PORT") .map(|s| s.to_string()) @@ -199,6 +239,30 @@ async fn is_server_running(port: u32) -> bool { .is_ok() } +async fn check_server_health(url: &str) -> bool { + let health_url = format!("{}/health", url.trim_end_matches('/')); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build(); + + let Ok(client) = client else { + return false; + }; + + client + .get(&health_url) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +fn get_configured_server_url(app: &AppHandle) -> Option { + let store = app.store(SETTINGS_STORE).ok()?; + let value = store.get(DEFAULT_SERVER_URL_KEY)?; + value.as_str().map(String::from) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); @@ -225,7 +289,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ kill_sidecar, install_cli, - ensure_server_started + ensure_server_started, + get_default_server_url, + set_default_server_url ]) .setup(move |app| { let app = app.handle().clone(); @@ -242,6 +308,7 @@ pub fn run() { .unwrap_or(LogicalSize::new(1920, 1080)); // Create window immediately with serverReady = false + #[allow(unused_mut)] let mut window_builder = WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) .title("Shuvcode") @@ -273,35 +340,100 @@ pub fn run() { { let app = app.clone(); tauri::async_runtime::spawn(async move { - let should_spawn_sidecar = !is_server_running(port).await; - - let (child, res) = if should_spawn_sidecar { - let child = spawn_sidecar(&app, port); - - let timestamp = Instant::now(); - let res = loop { - if timestamp.elapsed() > Duration::from_secs(7) { - break Err(format!( - "Failed to spawn Shuvcode Server. Logs:\n{}", - get_logs(app.clone()).await.unwrap() - )); - } + // Check for configured default server URL + let configured_url = get_configured_server_url(&app); - tokio::time::sleep(Duration::from_millis(10)).await; + let (child, res, server_url) = if let Some(ref url) = configured_url { + println!("Configured default server URL: {}", url); - if is_server_running(port).await { - // give the server a little bit more time to warm up - tokio::time::sleep(Duration::from_millis(10)).await; + // Try to connect to the configured server + let mut healthy = false; + let mut should_fallback = false; - break Ok(()); + loop { + if check_server_health(url).await { + healthy = true; + println!("Connected to configured server: {}", url); + break; } - }; - println!("Server ready after {:?}", timestamp.elapsed()); + let res = app.dialog() + .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url)) + .title("Connection Failed") + .buttons(MessageDialogButtons::OkCancelCustom("Retry".to_string(), "Start Local".to_string())) + .blocking_show_with_result(); + + match res { + MessageDialogResult::Custom(name) if name == "Retry" => { + continue; + }, + _ => { + should_fallback = true; + break; + } + } + } + + if healthy { + (None, Ok(()), Some(url.clone())) + } else if should_fallback { + // Fall back to spawning local sidecar + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + let res = loop { + if timestamp.elapsed() > Duration::from_secs(7) { + break Err(format!( + "Failed to spawn Shuvcode Server. Logs:\n{}", + get_logs(app.clone()).await.unwrap() + )); + } - (Some(child), res) + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + tokio::time::sleep(Duration::from_millis(10)).await; + break Ok(()); + } + }; + + println!("Server ready after {:?}", timestamp.elapsed()); + (Some(child), res, None) + } else { + (None, Err("User cancelled".to_string()), None) + } } else { - (None, Ok(())) + // No configured URL, spawn local sidecar as before + let should_spawn_sidecar = !is_server_running(port).await; + + let (child, res) = if should_spawn_sidecar { + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + let res = loop { + if timestamp.elapsed() > Duration::from_secs(7) { + break Err(format!( + "Failed to spawn Shuvcode Server. Logs:\n{}", + get_logs(app.clone()).await.unwrap() + )); + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + tokio::time::sleep(Duration::from_millis(10)).await; + break Ok(()); + } + }; + + println!("Server ready after {:?}", timestamp.elapsed()); + + (Some(child), res) + } else { + (None, Ok(())) + }; + + (child, res, None) }; app.state::().set_child(child); @@ -310,6 +442,14 @@ pub fn run() { let _ = window.eval( "window.__SHUVCODE__.serverReady = true; window.__OPENCODE__.serverReady = true;", ); + + // If using a configured server URL, inject it + if let Some(url) = server_url { + let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\""); + let _ = window.eval(format!( + "window.__SHUVCODE__.serverUrl = \"{escaped_url}\"; window.__OPENCODE__.serverUrl = \"{escaped_url}\";", + )); + } } let _ = tx.send(res); diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 941ea8df707..ffb178672cb 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -257,6 +257,15 @@ const platform: Platform = { // @ts-expect-error fetch: tauriFetch, + + getDefaultServerUrl: async () => { + const result = await invoke("get_default_server_url").catch(() => null) + return result + }, + + setDefaultServerUrl: async (url: string | null) => { + await invoke("set_default_server_url", { url }) + }, } createMenu() diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 368243dd971..21218d64da9 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.12", + "version": "1.1.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ed340fc36f6..3561c73d5a0 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.12" +version = "1.1.13" 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.12/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/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.12/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.12/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/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.12/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/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.12/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 8597f2c8021..75ca27ad03e 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.12", + "version": "1.1.13", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 668483e46ea..c5de7e2245c 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.12", + "version": "1.1.13", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 6d8a64b7d02..ebd65bb26da 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -30,6 +30,7 @@ import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import { applyPatch } from "diff" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -105,6 +106,22 @@ export namespace ACP { }) return } + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + + const content = await Bun.file(filepath).text() + const newContent = getNewContent(content, diff) + + if (newContent) { + this.connection.writeTextFile({ + sessionId: sessionId, + path: filepath, + content: newContent, + }) + } + } await this.config.sdk.permission.reply({ requestID: permission.id, reply: res.outcome.optionId as "once" | "always" | "reject", @@ -1095,4 +1112,13 @@ export namespace ACP { } } } + + function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { + const result = applyPatch(fileOriginal, unifiedDiff) + if (result === false) { + log.error("Failed to apply unified diff (context mismatch)") + return undefined + } + return result + } } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b30c2db9f6e..a04e93d3b20 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -321,6 +321,38 @@ export function Prompt(props: PromptProps) { onMount(() => { promptPartTypeId = input.extmarks.registerType("prompt-part") + props.ref?.({ + get focused() { + return input.focused + }, + get current() { + return store.prompt + }, + focus() { + input.focus() + }, + blur() { + input.blur() + }, + set(prompt) { + input.setText(prompt.input) + setStore("prompt", prompt) + restoreExtmarksFromParts(prompt.parts) + input.gotoBufferEnd() + }, + reset() { + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + }, + submit() { + submit() + }, + }) }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -456,39 +488,6 @@ export function Prompt(props: PromptProps) { }, ]) - props.ref?.({ - get focused() { - return input.focused - }, - get current() { - return store.prompt - }, - focus() { - input.focus() - }, - blur() { - input.blur() - }, - set(prompt) { - input.setText(prompt.input) - setStore("prompt", prompt) - restoreExtmarksFromParts(prompt.parts) - input.gotoBufferEnd() - }, - reset() { - input.clear() - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - }, - submit() { - submit() - }, - }) - async function submit() { if (props.disabled) return if (autocomplete?.visible) return diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f2f2927c000..c5d36826c2c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -255,7 +255,7 @@ export function DialogSelect(props: DialogSelectProps) { props.onSelect?.(option) }} onMouseOver={() => { - const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) + const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) if (index === -1) return moveTo(index) }} diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 7bc4382b019..bb1062bc7b1 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -466,8 +466,15 @@ export namespace Ripgrep { return root.render() } - export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { + export async function search(input: { + cwd: string + pattern: string + glob?: string[] + limit?: number + follow?: boolean + }) { const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + if (input.follow !== false) args.push("--follow") if (input.glob) { for (const g of input.glob) { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index c818e2b3e94..24da77edcfe 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1528,7 +1528,11 @@ export namespace LSPServer { }) return { process: proc, - initialization: {}, + initialization: { + telemetry: { + enabled: false, + }, + }, } }, } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 580ae76bd41..930a3290764 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -112,13 +112,10 @@ export namespace Plugin { $: Bun.$, } - // Load internal plugins first - if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = await plugin(input) - hooks.push(init) - } + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = await plugin(input) + hooks.push(init) } const plugins = [...(config.plugin ?? [])] diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e59452e7403..9456734a3e1 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -100,12 +100,16 @@ export namespace ProviderAuth { }) } if ("refresh" in result) { - await Auth.set(input.providerID, { + const info: Auth.Info = { type: "oauth", access: result.access, refresh: result.refresh, expires: result.expires, - }) + } + if (result.accountId) { + info.accountId = result.accountId + } + await Auth.set(input.providerID, info) } return } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 08f329ec124..38b2c9aa13e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -318,7 +318,10 @@ export namespace ProviderTransform { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai if (id === "gpt-5-pro") return {} const openaiEfforts = iife(() => { - if (id.includes("codex") && !id.includes("5.2")) return WIDELY_SUPPORTED_EFFORTS + if (id.includes("codex")) { + if (id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] + return WIDELY_SUPPORTED_EFFORTS + } const arr = [...WIDELY_SUPPORTED_EFFORTS] if (id.includes("gpt-5-") || id === "gpt-5") { arr.unshift("minimal") diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c5792c16dea..9cd40f30221 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -73,7 +73,7 @@ export namespace LLM { const header = system[0] const original = clone(system) - await Plugin.trigger("experimental.chat.system.transform", {}, { system }) + await Plugin.trigger("experimental.chat.system.transform", { sessionID: input.sessionID }, { system }) if (system.length === 0) { system.push(...original) } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 787282ecd04..b68078f1428 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -15,6 +15,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" +import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -40,18 +41,7 @@ export const EditTool = Tool.define("edit", { } const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - if (!Filesystem.contains(Instance.directory, filePath)) { - const parentDir = path.dirname(filePath) - await ctx.ask({ - permission: "external_directory", - patterns: [parentDir, path.join(parentDir, "*")], - always: [parentDir + "/*"], - metadata: { - filepath: filePath, - parentDir, - }, - }) - } + await assertExternalDirectory(ctx, filePath) let diff = "" let contentOld = "" diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts new file mode 100644 index 00000000000..05f83c00db4 --- /dev/null +++ b/packages/opencode/src/tool/external-directory.ts @@ -0,0 +1,33 @@ +import path from "path" +import type { Tool } from "./tool" +import { Filesystem } from "../util/filesystem" +import { Instance } from "../project/instance" + +type Kind = "file" | "directory" + +type Options = { + bypass?: boolean + kind?: Kind +} + +export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { + if (!target) return + + if (options?.bypass) return + + if (Filesystem.contains(Instance.directory, target)) return + + const kind = options?.kind ?? "file" + const parentDir = kind === "directory" ? target : path.dirname(target) + const glob = path.join(parentDir, "*") + + await ctx.ask({ + permission: "external_directory", + patterns: [glob], + always: [glob], + metadata: { + filepath: target, + parentDir, + }, + }) +} diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0c643796def..dda57f6ee1b 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -4,6 +4,7 @@ import { Tool } from "./tool" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" +import { assertExternalDirectory } from "./external-directory" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -29,6 +30,7 @@ export const GlobTool = Tool.define("glob", { let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + await assertExternalDirectory(ctx, search, { kind: "directory" }) const limit = 100 const files = [] diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 464c6286f66..ef0da492837 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -4,6 +4,8 @@ import { Ripgrep } from "../file/ripgrep" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" +import path from "path" +import { assertExternalDirectory } from "./external-directory" const MAX_LINE_LENGTH = 2000 const MATCH_LIMIT = 100 @@ -31,10 +33,12 @@ export const GrepTool = Tool.define("grep", { }, }) - const searchPath = params.path || Instance.directory + let searchPath = params.path ?? Instance.directory + searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() - const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern] + const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern] if (params.include) { args.push("--glob", params.include) } diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b8638b3e904..cc3d750078f 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -4,6 +4,7 @@ import * as path from "path" import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" +import { assertExternalDirectory } from "./external-directory" export const IGNORE_PATTERNS = [ "node_modules/", @@ -42,6 +43,7 @@ export const ListTool = Tool.define("list", { }), async execute(params, ctx) { const searchPath = path.resolve(Instance.directory, params.path || ".") + await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) await ctx.ask({ permission: "list", diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index df4692bf6db..ca352280b2a 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -5,6 +5,7 @@ import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" import { pathToFileURL } from "url" +import { assertExternalDirectory } from "./external-directory" const operations = [ "goToDefinition", @@ -27,14 +28,15 @@ export const LspTool = Tool.define("lsp", { character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), }), execute: async (args, ctx) => { + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + await assertExternalDirectory(ctx, file) + await ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {}, }) - - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) const uri = pathToFileURL(file).href const position = { file, diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts index 62d9f70f204..08a58bfea9c 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/patch.ts @@ -7,8 +7,8 @@ import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" -import { Filesystem } from "../util/filesystem" import { createTwoFilesPatch } from "diff" +import { assertExternalDirectory } from "./external-directory" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -49,19 +49,7 @@ export const PatchTool = Tool.define("patch", { for (const hunk of hunks) { const filePath = path.resolve(Instance.directory, hunk.path) - - if (!Filesystem.contains(Instance.directory, filePath)) { - const parentDir = path.dirname(filePath) - await ctx.ask({ - permission: "external_directory", - patterns: [parentDir, path.join(parentDir, "*")], - always: [parentDir + "/*"], - metadata: { - filepath: filePath, - parentDir, - }, - }) - } + await assertExternalDirectory(ctx, filePath) switch (hunk.type) { case "add": @@ -103,12 +91,15 @@ export const PatchTool = Tool.define("patch", { const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + await assertExternalDirectory(ctx, movePath) + fileChanges.push({ filePath, oldContent, newContent, type: hunk.move_path ? "move" : "update", - movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined, + movePath, }) totalDiff += diff + "\n" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 56742517483..ce4ab28619d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -5,9 +5,9 @@ import { Tool } from "./tool" import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" -import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Identifier } from "../id/id" +import { assertExternalDirectory } from "./external-directory" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -27,18 +27,9 @@ export const ReadTool = Tool.define("read", { } const title = path.relative(Instance.worktree, filepath) - if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) { - const parentDir = path.dirname(filepath) - await ctx.ask({ - permission: "external_directory", - patterns: [parentDir], - always: [parentDir + "/*"], - metadata: { - filepath, - parentDir, - }, - }) - } + await assertExternalDirectory(ctx, filepath, { + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + }) await ctx.ask({ permission: "read", diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a0ca6b14f7c..222bac3f8fb 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,6 +10,7 @@ import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" +import { assertExternalDirectory } from "./external-directory" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -22,12 +23,7 @@ export const WriteTool = Tool.define("write", { }), async execute(params, ctx) { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) - /* TODO - if (!Filesystem.contains(Instance.directory, filepath)) { - const parentDir = path.dirname(filepath) - ... - } - */ + await assertExternalDirectory(ctx, filepath) const file = Bun.file(filepath) const exists = await file.exists() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts new file mode 100644 index 00000000000..b21f6a9715c --- /dev/null +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import type { Tool } from "../../src/tool/tool" +import { Instance } from "../../src/project/instance" +import { assertExternalDirectory } from "../../src/tool/external-directory" +import type { PermissionNext } from "../../src/permission/next" + +const baseCtx: Omit = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +describe("tool.assertExternalDirectory", () => { + test("no-ops for empty target", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: "/tmp", + fn: async () => { + await assertExternalDirectory(ctx) + }, + }) + + expect(requests.length).toBe(0) + }) + + test("no-ops for paths inside Instance.directory", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: "/tmp/project", + fn: async () => { + await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) + }, + }) + + expect(requests.length).toBe(0) + }) + + test("asks with a single canonical glob", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + const directory = "/tmp/project" + const target = "/tmp/outside/file.txt" + const expected = path.join(path.dirname(target), "*") + + await Instance.provide({ + directory, + fn: async () => { + await assertExternalDirectory(ctx, target) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }) + + test("uses target directory when kind=directory", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + const directory = "/tmp/project" + const target = "/tmp/outside" + const expected = path.join(target, "*") + + await Instance.provide({ + directory, + fn: async () => { + await assertExternalDirectory(ctx, target, { kind: "directory" }) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([expected]) + expect(req!.always).toEqual([expected]) + }) + + test("skips prompting when bypass=true", async () => { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: "/tmp/project", + fn: async () => { + await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) + }, + }) + + expect(requests.length).toBe(0) + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c62b65606a4..8792455fbab 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.12", + "version": "1.1.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 40025b281f7..eec72677746 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -199,7 +199,7 @@ export interface Hooks { }, ) => Promise "experimental.chat.system.transform"?: ( - input: {}, + input: { sessionID: string }, output: { system: string[] }, diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8585cbf1e86..d467a30c9ae 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.12", + "version": "1.1.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 23d32916673..b4083965d2c 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.12", + "version": "1.1.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index df45e4ba843..429c1f13e53 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.12", + "version": "1.1.13", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 7d191e8b4bf..1defd4cf2a3 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -76,12 +76,25 @@ } [data-slot="user-message-text"] { + position: relative; white-space: pre-wrap; word-break: break-word; overflow: hidden; background: var(--surface-base); padding: 8px 12px; border-radius: 4px; + + [data-slot="user-message-copy-wrapper"] { + position: absolute; + top: 7px; + right: 7px; + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover [data-slot="user-message-copy-wrapper"] { + opacity: 1; + } } .text-text-strong { @@ -387,7 +400,8 @@ [data-component="edit-content"], [data-component="write-content"], [data-component="todos"], -[data-component="diagnostics"] { +[data-component="diagnostics"], +.error-card { -webkit-user-select: text; user-select: text; } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d59f5cfa3e3..644690ed2f0 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -38,6 +38,8 @@ import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" +import { Tooltip } from "./tooltip" +import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" interface Diagnostic { @@ -278,6 +280,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() + const [copied, setCopied] = createSignal(false) const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, @@ -307,6 +310,14 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp dialog.show(() => ) } + const handleCopy = async () => { + const content = text() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return (
0}> @@ -341,6 +352,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
+
+ + + +
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index d34b6289d31..cdfb3af8527 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -225,6 +225,22 @@ } } + [data-slot="session-turn-summary-section"] { + position: relative; + + [data-slot="session-turn-summary-copy"] { + position: absolute; + top: 0; + right: 0; + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover [data-slot="session-turn-summary-copy"] { + opacity: 1; + } + } + [data-slot="session-turn-accordion"] { width: 100%; } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f69d414be58..075da218bb1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -11,7 +11,7 @@ import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -21,6 +21,8 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" import { Card } from "./card" import { Dynamic } from "solid-js/web" import { Button } from "./button" @@ -328,6 +330,15 @@ export function SessionTurn( const hasDiffs = createMemo(() => message()?.summary?.diffs?.length) const hideResponsePart = createMemo(() => !working() && !!responsePartId()) + const [responseCopied, setResponseCopied] = createSignal(false) + const handleCopyResponse = async () => { + const content = response() + if (!content) return + await navigator.clipboard.writeText(content) + setResponseCopied(true) + setTimeout(() => setResponseCopied(false), 2000) + } + function duration() { const msg = message() if (!msg) return "" @@ -556,6 +567,15 @@ export function SessionTurn( {/* Response */}
+
+ + + +

Response

(props: FilteredListProps) { const selected = flat()[selectedIndex] if (selected) props.onSelect?.(selected, selectedIndex) } else { + // Skip list navigation for text editing shortcuts (e.g., Option+Arrow, Option+Backspace on macOS) + if (event.altKey || event.metaKey) return list.onKeyDown(event) } } diff --git a/packages/util/package.json b/packages/util/package.json index c5ef19a6189..b264a7de3ef 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.12", + "version": "1.1.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 8f06a6cee11..bf6562cb6d0 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.12", + "version": "1.1.13", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4fc12100443..37c93adee4a 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "shuvcode", "displayName": "shuvcode", "description": "shuvcode for VS Code", - "version": "1.1.12", + "version": "1.1.13", "publisher": "latitudes-dev" "repository": { "type": "git",