diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a793bfb037..20d2bc18d8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -10,6 +10,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index cb5c45063f..e1ff4241c9 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -28,40 +28,98 @@ jobs: const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" - const stalePrs = [] core.info(`Dry run mode: ${dryRun}`) + core.info(`Cutoff date: ${cutoff.toISOString()}`) - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100, - sort: "updated", - direction: "asc", - }) + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + author { + login + } + createdAt + commits(last: 1) { + nodes { + commit { + committedDate + } + } + } + comments(last: 1) { + nodes { + createdAt + } + } + reviews(last: 1) { + nodes { + createdAt + } + } + } + } + } + } + ` - for (const pr of prs) { - const lastUpdated = new Date(pr.updated_at) - if (lastUpdated > cutoff) { - core.info(`PR ${pr.number} is fresh`) - continue + const allPrs = [] + let cursor = null + let hasNextPage = true + + while (hasNextPage) { + const result = await github.graphql(query, { + owner, + repo, + cursor, + }) + + allPrs.push(...result.repository.pullRequests.nodes) + hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage + cursor = result.repository.pullRequests.pageInfo.endCursor + } + + core.info(`Found ${allPrs.length} open pull requests`) + + const stalePrs = allPrs.filter((pr) => { + const dates = [ + new Date(pr.createdAt), + pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, + pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, + pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, + ].filter((d) => d !== null) + + const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] + + if (!lastActivity || lastActivity > cutoff) { + core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) + return false } - stalePrs.push(pr) - } + core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) + return true + }) if (!stalePrs.length) { core.info("No stale pull requests found.") return } + core.info(`Found ${stalePrs.length} stale pull requests`) + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) continue } @@ -79,5 +137,5 @@ jobs: state: "closed", }) - core.info(`Closed PR #${issue_number} from ${pr.user.login}`) + core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) } diff --git a/README.ar.md b/README.ar.md index 2abceb300d..4c8ac5fcc3 100644 --- a/README.ar.md +++ b/README.ar.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.br.md b/README.br.md index 6a58241c98..ee5e85fd44 100644 --- a/README.br.md +++ b/README.br.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.da.md b/README.da.md index 7e7dda42a8..79928fd944 100644 --- a/README.da.md +++ b/README.da.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.de.md b/README.de.md index c949dd00f4..ccb3ad07dc 100644 --- a/README.de.md +++ b/README.de.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.es.md b/README.es.md index 3e3797ed30..e5a7d8e8dd 100644 --- a/README.es.md +++ b/README.es.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.fr.md b/README.fr.md index 00133b1e9f..5436009903 100644 --- a/README.fr.md +++ b/README.fr.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.it.md b/README.it.md index 89692a3668..cbc8a5f6d2 100644 --- a/README.it.md +++ b/README.it.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ja.md b/README.ja.md index 5f3a9e189e..8827efae88 100644 --- a/README.ja.md +++ b/README.ja.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ko.md b/README.ko.md index 213f46bfe7..806dc642c1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.md b/README.md index 7e20902547..dd5adb9809 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ العربية | Norsk | Português (Brasil) | - ไทย + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.no.md b/README.no.md index 44371df5ed..90b631fef2 100644 --- a/README.no.md +++ b/README.no.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.pl.md b/README.pl.md index b183cd6245..ae653a7fa0 100644 --- a/README.pl.md +++ b/README.pl.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ru.md b/README.ru.md index c192036b54..cf15c6ebce 100644 --- a/README.ru.md +++ b/README.ru.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.th.md b/README.th.md index a4b306a6c4..4077abc011 100644 --- a/README.th.md +++ b/README.th.md @@ -30,7 +30,8 @@ العربية | Norsk | Português (Brasil) | - ไทย + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.tr.md b/README.tr.md new file mode 100644 index 0000000000..e3055e7a99 --- /dev/null +++ b/README.tr.md @@ -0,0 +1,135 @@ +

+ + + + + OpenCode logo + + +

+

Açık kaynaklı yapay zeka kodlama asistanı.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + العربية | + Norsk | + Português (Brasil) | + ไทย | + Türkçe +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Kurulum + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Paket yöneticileri +npm i -g opencode-ai@latest # veya bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel) +brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Tüm işletim sistemleri +nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode +``` + +> [!TIP] +> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın. + +### Masaüstü Uygulaması (BETA) + +OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz. + +| Platform | İndirme | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm` veya AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Kurulum Dizini (Installation Directory) + +Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder: + +1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini +2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol +3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa) +4. `$HOME/.opencode/bin` - Varsayılan yedek konum + +```bash +# Örnekler +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Ajanlar + +OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir. + +- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan +- **plan** - Analiz ve kod keşfi için salt okunur ajan + - Varsayılan olarak dosya düzenlemelerini reddeder + - Bash komutlarını çalıştırmadan önce izin ister + - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal + +Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır. +Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir. + +[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin. + +### Dokümantasyon + +OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs). + +### Katkıda Bulunma + +OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun. + +### OpenCode Üzerine Geliştirme + +OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin. + +### SSS + +#### Bu Claude Code'dan nasıl farklı? + +Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar: + +- %100 açık kaynak +- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir. +- Kurulum gerektirmeyen hazır LSP desteği +- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız. +- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir. + +--- + +**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.zh.md b/README.zh.md index 9ebbe8ce93..6970fe34ef 100644 --- a/README.zh.md +++ b/README.zh.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.zht.md b/README.zht.md index 298b5b35ac..a045f45490 100644 --- a/README.zht.md +++ b/README.zht.md @@ -29,7 +29,9 @@ Русский | العربية | Norsk | - Português (Brasil) + Português (Brasil) | + ไทย | + Türkçe

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) @@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 - 100% 開源。 - 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。 - 內建 LSP (語言伺服器協定) 支援。 -- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。 +- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。 - 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。 --- diff --git a/nix/desktop.nix b/nix/desktop.nix index 9625f75c27..efdc2bd72e 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: { rustc jq makeWrapper - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; + ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; buildInputs = lib.optionals stdenv.isLinux [ dbus @@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: { gst_all_1.gstreamer gst_all_1.gst-plugins-base gst_all_1.gst-plugins-good + gst_all_1.gst-plugins-bad ]; strictDeps = true; @@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: { mainProgram = "opencode-desktop"; inherit (opencode.meta) platforms; }; -}) \ No newline at end of file +}) diff --git a/nix/hashes.json b/nix/hashes.json index 8341023e4d..6fe8f61d3d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LGI4XJj9WhBwnnqCXVNOTygrB0rBFIIcMjMm1ZuqIQI=", - "aarch64-linux": "sha256-0L89lS1RcFmiz9qBRHftdtAZVOtoTG6X0RgEpaLI1sQ=", - "aarch64-darwin": "sha256-QdwEcYDtgo/5HIK5WPpV8cf/aZrH9ref/Fh2vS3m/CU=", - "x86_64-darwin": "sha256-YLMPQzo0hnSo722WbC+Cp88Db6oyQ+o9NQM8z/7t4uw=" + "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", + "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", + "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", + "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" } } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 6d75b9e750..836ef02a56 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -46,15 +46,16 @@ stdenvNoCC.mkDerivation { buildPhase = '' runHook preBuild - export HOME=$(mktemp -d) export BUN_INSTALL_CACHE_DIR=$(mktemp -d) bun install \ --cpu="${bunCpu}" \ --os="${bunOs}" \ + --filter '!./' \ + --filter './packages/opencode' \ + --filter './packages/desktop' \ --frozen-lockfile \ --ignore-scripts \ - --no-progress \ - --linker=isolated + --no-progress bun --bun ${./scripts/canonicalize-node-modules.ts} bun --bun ${./scripts/normalize-bun-binaries.ts} runHook postBuild diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 1eb2da1db7..5f80d67c24 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -8,11 +8,15 @@ import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + projectMenuTriggerSelector, + projectWorkspacesToggleSelector, titlebarRightSelector, popoverBodySelector, listItemSelector, listItemKeySelector, listItemKeyStartsWithSelector, + workspaceItemSelector, + workspaceMenuTriggerSelector, } from "./selectors" import type { createSdk } from "./utils" @@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) { return { rightSection, popoverBody } } + +export async function openProjectMenu(page: Page, projectSlug: string) { + const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() + await expect(trigger).toHaveCount(1) + + await trigger.focus() + await page.keyboard.press("Enter") + + const menu = page.locator(dropdownMenuContentSelector).first() + const opened = await menu + .waitFor({ state: "visible", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (opened) { + const viewport = page.viewportSize() + const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 + const y = viewport ? Math.max(viewport.height - 5, 0) : 800 + await page.mouse.move(x, y) + return menu + } + + await trigger.click({ force: true }) + + await expect(menu).toBeVisible() + + const viewport = page.viewportSize() + const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 + const y = viewport ? Math.max(viewport.height - 5, 0) : 800 + await page.mouse.move(x, y) + return menu +} + +export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { + const current = await page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) + + if (current === enabled) return + + await openProjectMenu(page, projectSlug) + + const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first() + await expect(toggle).toBeVisible() + await toggle.click({ force: true }) + + const expected = enabled ? "New workspace" : "New session" + await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() +} + +export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { + const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + await expect(item).toBeVisible() + await item.hover() + + const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first() + await expect(trigger).toBeVisible() + await trigger.click({ force: true }) + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + return menu +} diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts new file mode 100644 index 0000000000..80cd63aa2a --- /dev/null +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -0,0 +1,391 @@ +import { base64Decode } from "@opencode-ai/util/encode" +import fs from "node:fs/promises" +import path from "node:path" +import type { Page } from "@playwright/test" + +import { test, expect } from "../fixtures" + +test.describe.configure({ mode: "serial" }) +import { + cleanupTestProject, + clickMenuItem, + confirmDialog, + createTestProject, + openSidebar, + openWorkspaceMenu, + seedProjects, + setWorkspacesEnabled, +} from "../actions" +import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" +import { dirSlug } from "../utils" + +function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +} + +async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) { + const project = await createTestProject() + const rootSlug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(rootSlug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + return { project, rootSlug, slug, directory: dir } +} + +test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const slug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(slug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + + await openSidebar(page) + + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + + await setWorkspacesEnabled(page, slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible() + + await setWorkspacesEnabled(page, slug, false) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + } finally { + await cleanupTestProject(project) + } +}) + +test("can create a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const slug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(slug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + + await page.getByRole("button", { name: "New workspace" }).first().click() + + await expect + .poll( + () => { + const currentSlug = slugFromUrl(page.url()) + return currentSlug.length > 0 && currentSlug !== slug + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const workspaceSlug = slugFromUrl(page.url()) + const workspaceDir = base64Decode(workspaceSlug) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + + await cleanupTestProject(workspaceDir) + } finally { + await cleanupTestProject(project) + } +}) + +test("can rename a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const rename = `e2e workspace ${Date.now()}` + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Rename$/i, { force: true }) + + await expect(menu).toHaveCount(0) + + const item = page.locator(workspaceItemSelector(slug)).first() + await expect(item).toBeVisible() + const input = item.locator(inlineInputSelector).first() + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") + await expect(item).toContainText(rename) + } finally { + await cleanupTestProject(project) + } +}) + +test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const readme = path.join(createdDir, "README.md") + const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) + const original = await fs.readFile(readme, "utf8") + const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` + await fs.writeFile(readme, dirty, "utf8") + await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(true) + + await expect + .poll(async () => { + const files = await sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }) + .toBeGreaterThan(0) + + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Reset$/i, { force: true }) + await confirmDialog(page, /^Reset workspace$/i) + + await expect + .poll( + async () => { + const files = await sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }, + { timeout: 60_000 }, + ) + .toBe(0) + + await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original) + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(false) + } finally { + await cleanupTestProject(project) + } +}) + +test("can delete a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Delete$/i, { force: true }) + await confirmDialog(page, /^Delete workspace$/i) + + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() + } finally { + await cleanupTestProject(project) + } +}) + +test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const rootSlug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + const workspaces = [] as { directory: string; slug: string }[] + + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } + + const waitReady = async (slug: string) => { + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + } + + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() + + await src.scrollIntoViewIfNeeded() + await dst.scrollIntoViewIfNeeded() + + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(rootSlug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + workspaces.push({ slug, directory: dir }) + + await openSidebar(page) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) + } finally { + await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) + await cleanupTestProject(project) + } +}) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 90cfef8db9..317c70969d 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) => export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` +export const projectWorkspacesToggleSelector = (slug: string) => + `[data-action="project-workspaces-toggle"][data-project="${slug}"]` + export const titlebarRightSelector = "#opencode-titlebar-right" export const popoverBodySelector = '[data-slot="popover-body"]' @@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]' export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` +export const workspaceItemSelector = (slug: string) => + `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` + +export const workspaceMenuTriggerSelector = (slug: string) => + `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` + export const listItemSelector = '[data-slot="list-item"]' export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ff..57bf86b5a8 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI +const win = process.platform === "win32" export default defineConfig({ testDir: "./e2e", @@ -14,7 +15,8 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: !win, + workers: win ? 1 : undefined, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4f0dcc3ee6..2135b1edf4 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,9 +90,10 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element + children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T triggerProps?: ComponentProps + gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -175,14 +176,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={props.gutter ?? 8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {props.children} + {typeof props.children === "function" ? props.children(store.open) : props.children} = (props) => { @@ -517,6 +521,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, + source: cmd.source, })) return [...custom, ...builtin] @@ -1252,7 +1257,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .shell({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, command: text, @@ -1275,7 +1280,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session.id, + sessionID: session?.id || "", command: commandName, arguments: args.join(" "), agent, @@ -1431,13 +1436,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session.id, + sessionID: session?.id || "", messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session.id, + sessionID: session?.id || "", role: "user", time: { created: Date.now() }, agent, @@ -1448,9 +1453,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1466,9 +1471,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1485,7 +1490,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1498,7 +1503,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1519,15 +1524,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1544,7 +1549,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session.id, { abort: controller, cleanup }) + pending.set(session?.id || "", { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1572,7 +1577,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session.id) + pending.delete(session?.id || "") if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1582,7 +1587,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, messageID, @@ -1592,9 +1597,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + pending.delete(session?.id || "") + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1616,6 +1621,28 @@ export const PromptInput: Component = (props) => { }) } + const currrentModelVariant = createMemo(() => { + const modelVariant = local.model.variant.current() ?? "" + return modelVariant === "xhigh" + ? "xHigh" + : modelVariant.length > 0 + ? modelVariant[0].toUpperCase() + modelVariant.slice(1) + : "Default" + }) + + const reasoningPercentage = createMemo(() => { + const variants = local.model.variant.list() + const current = local.model.variant.current() + const totalEntries = variants.length + 1 + + if (totalEntries <= 2 || current === "Default") { + return 0 + } + + const currentIndex = current ? variants.indexOf(current) + 1 : 0 + return ((currentIndex + 1) / totalEntries) * 100 + }, [local.model.variant]) + return (
@@ -1668,7 +1695,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1701,9 +1728,13 @@ export const PromptInput: Component = (props) => {
- + - {language.t("prompt.slash.badge.custom")} + {cmd.source === "skill" + ? language.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? language.t("prompt.slash.badge.mcp") + : language.t("prompt.slash.badge.custom")} @@ -1729,9 +1760,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1770,7 +1801,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1787,7 +1818,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -1817,7 +1848,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1891,7 +1922,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1912,6 +1943,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" + gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1937,12 +1976,16 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1998,13 @@ export const PromptInput: Component = (props) => { @@ -1972,7 +2018,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "_hidden group-hover/prompt-input:flex items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -1994,7 +2040,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2036,7 +2083,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2047,7 +2094,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-5.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc7..a0251ed41b 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -130,7 +131,12 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( -
+

{language.t("settings.tab.general")}

@@ -226,7 +232,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} > {(option) => ( @@ -411,7 +417,7 @@ export const SettingsGeneral: Component = () => {
-
+ ) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index a24db13f5c..8655bca34b 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => { }) return ( -
+
@@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 1807d561ea..0ee5caf73d 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -39,7 +40,12 @@ export const SettingsModels: Component = () => { }) return ( -
+

{language.t("settings.models.title")}

@@ -125,6 +131,6 @@ export const SettingsModels: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index dcc597139e..2460534c05 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -115,7 +116,12 @@ export const SettingsProviders: Component = () => { } return ( -
+

{language.t("settings.providers.title")}

@@ -261,6 +267,6 @@ export const SettingsProviders: Component = () => {
-
+ ) } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 8ca05cdfeb..e3831e23c4 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.slash.badge.custom": "مخصص", + "prompt.slash.badge.skill": "مهارة", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "نشط", "prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", @@ -432,6 +434,7 @@ export const dict = { "session.review.noChanges": "لا توجد تغييرات", "session.files.selectToOpen": "اختر ملفًا لفتحه", "session.files.all": "كل الملفات", + "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)", "session.messages.renderEarlier": "عرض الرسائل السابقة", "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...", "session.messages.loadEarlier": "تحميل الرسائل السابقة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index ad0772cd8b..f930a66aff 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", @@ -433,6 +435,7 @@ export const dict = { "session.review.noChanges": "Sem alterações", "session.files.selectToOpen": "Selecione um arquivo para abrir", "session.files.all": "Todos os arquivos", + "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)", "session.messages.renderEarlier": "Renderizar mensagens anteriores", "session.messages.loadingEarlier": "Carregando mensagens anteriores...", "session.messages.loadEarlier": "Carregar mensagens anteriores", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 031d92d4b9..2b7d77456d 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.slash.badge.custom": "brugerdefineret", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", @@ -434,6 +436,7 @@ export const dict = { "session.review.noChanges": "Ingen ændringer", "session.files.selectToOpen": "Vælg en fil at åbne", "session.files.all": "Alle filer", + "session.files.binaryContent": "Binær fil (indhold kan ikke vises)", "session.messages.renderEarlier": "Vis tidligere beskeder", "session.messages.loadingEarlier": "Indlæser tidligere beskeder...", "session.messages.loadEarlier": "Indlæs tidligere beskeder", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 9febfcff1e..4648ad9c41 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.slash.badge.custom": "benutzerdefiniert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Aktive Datei einbeziehen", "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", @@ -442,6 +444,7 @@ export const dict = { "session.review.noChanges": "Keine Änderungen", "session.files.selectToOpen": "Datei zum Öffnen auswählen", "session.files.all": "Alle Dateien", + "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)", "session.messages.renderEarlier": "Frühere Nachrichten rendern", "session.messages.loadingEarlier": "Lade frühere Nachrichten...", "session.messages.loadEarlier": "Frühere Nachrichten laden", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a6a50506a0..12ddcb4cd8 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,6 +216,8 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", @@ -441,6 +443,7 @@ export const dict = { "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", + "session.files.binaryContent": "Binary file (content cannot be displayed)", "session.messages.renderEarlier": "Render earlier messages", "session.messages.loadingEarlier": "Loading earlier messages...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index ee75a143df..5d396f0b4f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "activo", "prompt.context.includeActiveFile": "Incluir archivo activo", "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", @@ -436,6 +438,7 @@ export const dict = { "session.review.noChanges": "Sin cambios", "session.files.selectToOpen": "Selecciona un archivo para abrir", "session.files.all": "Todos los archivos", + "session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)", "session.messages.renderEarlier": "Renderizar mensajes anteriores", "session.messages.loadingEarlier": "Cargando mensajes anteriores...", "session.messages.loadEarlier": "Cargar mensajes anteriores", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f0652a9814..4226d0c7e2 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.slash.badge.custom": "personnalisé", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "actif", "prompt.context.includeActiveFile": "Inclure le fichier actif", "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", @@ -441,6 +443,7 @@ export const dict = { "session.review.noChanges": "Aucune modification", "session.files.selectToOpen": "Sélectionnez un fichier à ouvrir", "session.files.all": "Tous les fichiers", + "session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)", "session.messages.renderEarlier": "Afficher les messages précédents", "session.messages.loadingEarlier": "Chargement des messages précédents...", "session.messages.loadEarlier": "Charger les messages précédents", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ffe5368142..28a925a0d3 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,6 +209,8 @@ export const dict = { "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.slash.badge.custom": "カスタム", + "prompt.slash.badge.skill": "スキル", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "アクティブ", "prompt.context.includeActiveFile": "アクティブなファイルを含める", "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", @@ -433,6 +435,7 @@ export const dict = { "session.review.noChanges": "変更なし", "session.files.selectToOpen": "開くファイルを選択", "session.files.all": "すべてのファイル", + "session.files.binaryContent": "バイナリファイル(内容を表示できません)", "session.messages.renderEarlier": "以前のメッセージを表示", "session.messages.loadingEarlier": "以前のメッセージを読み込み中...", "session.messages.loadEarlier": "以前のメッセージを読み込む", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 6c30e0123d..1be4e1eb4b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.slash.badge.custom": "사용자 지정", + "prompt.slash.badge.skill": "스킬", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "활성", "prompt.context.includeActiveFile": "활성 파일 포함", "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", @@ -435,6 +437,7 @@ export const dict = { "session.review.noChanges": "변경 없음", "session.files.selectToOpen": "열 파일을 선택하세요", "session.files.all": "모든 파일", + "session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)", "session.messages.renderEarlier": "이전 메시지 렌더링", "session.messages.loadingEarlier": "이전 메시지 로드 중...", "session.messages.loadEarlier": "이전 메시지 로드", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 132c0b6c1f..0a3b398856 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.slash.badge.custom": "egendefinert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", @@ -436,6 +438,7 @@ export const dict = { "session.review.noChanges": "Ingen endringer", "session.files.selectToOpen": "Velg en fil å åpne", "session.files.all": "Alle filer", + "session.files.binaryContent": "Binær fil (innhold kan ikke vises)", "session.messages.renderEarlier": "Vis tidligere meldinger", "session.messages.loadingEarlier": "Laster inn tidligere meldinger...", "session.messages.loadEarlier": "Last inn tidligere meldinger", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index efed3eeb15..f4457c6acf 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.slash.badge.custom": "własne", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktywny", "prompt.context.includeActiveFile": "Dołącz aktywny plik", "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", @@ -435,6 +437,7 @@ export const dict = { "session.review.noChanges": "Brak zmian", "session.files.selectToOpen": "Wybierz plik do otwarcia", "session.files.all": "Wszystkie pliki", + "session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)", "session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości", "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...", "session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0728c4a342..d5a4014d36 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.slash.badge.custom": "своё", + "prompt.slash.badge.skill": "навык", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "активно", "prompt.context.includeActiveFile": "Включить активный файл", "prompt.context.removeActiveFile": "Удалить активный файл из контекста", @@ -437,6 +439,7 @@ export const dict = { "session.review.noChanges": "Нет изменений", "session.files.selectToOpen": "Выберите файл, чтобы открыть", "session.files.all": "Все файлы", + "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)", "session.messages.renderEarlier": "Показать предыдущие сообщения", "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...", "session.messages.loadEarlier": "Загрузить предыдущие сообщения", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9ccb61ac76..1914b8e5bd 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -215,6 +215,8 @@ export const dict = { "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.slash.badge.custom": "กำหนดเอง", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ใช้งานอยู่", "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่", "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", @@ -322,20 +324,20 @@ export const dict = { "context.usage.clickToView": "คลิกเพื่อดูบริบท", "context.usage.view": "ดูการใช้บริบท", - "language.en": "อังกฤษ", - "language.zh": "จีนตัวย่อ", - "language.zht": "จีนตัวเต็ม", - "language.ko": "เกาหลี", - "language.de": "เยอรมัน", - "language.es": "สเปน", - "language.fr": "ฝรั่งเศส", - "language.da": "เดนมาร์ก", - "language.ja": "ญี่ปุ่น", - "language.pl": "โปแลนด์", - "language.ru": "รัสเซีย", - "language.ar": "อาหรับ", - "language.no": "นอร์เวย์", - "language.br": "โปรตุเกส (บราซิล)", + "language.en": "English", + "language.zh": "简体中文", + "language.zht": "繁體中文", + "language.ko": "한국어", + "language.de": "Deutsch", + "language.es": "Español", + "language.fr": "Français", + "language.da": "Dansk", + "language.ja": "日本語", + "language.pl": "Polski", + "language.ru": "Русский", + "language.ar": "العربية", + "language.no": "Norsk", + "language.br": "Português (Brasil)", "language.th": "ไทย", "toast.language.title": "ภาษา", @@ -438,6 +440,7 @@ export const dict = { "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด", "session.files.all": "ไฟล์ทั้งหมด", + "session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)", "session.messages.renderEarlier": "แสดงข้อความก่อนหน้า", "session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 2266c109b0..b9d5395730 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.slash.badge.custom": "自定义", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "当前", "prompt.context.includeActiveFile": "包含当前文件", "prompt.context.removeActiveFile": "从上下文移除活动文件", @@ -434,6 +436,7 @@ export const dict = { "session.review.noChanges": "无更改", "session.files.selectToOpen": "选择要打开的文件", "session.files.all": "所有文件", + "session.files.binaryContent": "二进制文件(无法显示内容)", "session.messages.renderEarlier": "显示更早的消息", "session.messages.loadingEarlier": "正在加载更早的消息...", "session.messages.loadEarlier": "加载更早的消息", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 30837e56fb..23d3d80e13 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -211,6 +211,8 @@ export const dict = { "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.slash.badge.custom": "自訂", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "作用中", "prompt.context.includeActiveFile": "包含作用中檔案", "prompt.context.removeActiveFile": "從上下文移除目前檔案", @@ -431,6 +433,7 @@ export const dict = { "session.review.noChanges": "沒有變更", "session.files.selectToOpen": "選取要開啟的檔案", "session.files.all": "所有檔案", + "session.files.binaryContent": "二進位檔案(無法顯示內容)", "session.messages.renderEarlier": "顯示更早的訊息", "session.messages.loadingEarlier": "正在載入更早的訊息...", "session.messages.loadEarlier": "載入更早的訊息", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f049dc3bcc..845a4fc834 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) { >
-
+
+ {header()} } @@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" class="size-6 rounded-md" + data-action="workspace-menu" + data-workspace={base64Encode(props.directory)} aria-label={language.t("common.moreOptions")} /> @@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) { {language.t("common.edit")} { const enabled = layout.sidebar.workspaces(p.worktree)() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b346fa6928..d3e74072a8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2342,6 +2342,7 @@ export default function Page() { const c = state()?.content return c?.mimeType === "image/svg+xml" }) + const isBinary = createMemo(() => state()?.content?.type === "binary") const svgContent = createMemo(() => { if (!isSvg()) return const c = state()?.content @@ -2794,6 +2795,19 @@ export default function Page() {
+ +
+ +
+
+ {path()?.split("/").pop()} +
+
+ {language.t("session.files.binaryContent")} +
+
+
+
{renderCode(contents(), "pb-40")}
{language.t("common.loading")}...
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 5708c238cd..accc8d67c9 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -18,7 +18,7 @@ import { Legal } from "~/component/legal" import { Footer } from "~/component/footer" import { Header } from "~/component/header" import { getLastSeenWorkspaceID } from "../workspace/common" -import { IconGemini, IconZai } from "~/component/icon" +import { IconGemini, IconMiniMax, IconZai } from "~/component/icon" const checkLoggedIn = query(async () => { "use server" @@ -98,14 +98,7 @@ export default function Home() {
- - - +
@@ -118,6 +111,16 @@ export default function Home() {
+
+ + + +
Get started with Zen diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1d90a4c365..72e7f8985d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -37,6 +37,7 @@ export namespace Agent { providerID: z.string(), }) .optional(), + variant: z.string().optional(), prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), @@ -214,6 +215,7 @@ export namespace Agent { native: false, } if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant item.prompt = value.prompt ?? item.prompt item.description = value.description ?? item.description item.temperature = value.temperature ?? item.temperature diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index bbaecfd8c7..34e2269d0c 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -307,7 +307,7 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() - const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { const handled = await handlePluginAuth({ auth: plugin.auth }, provider) if (handled) return @@ -323,7 +323,7 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() // Check if a plugin provides auth for this custom provider - const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) if (customPlugin && customPlugin.auth) { const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) if (handled) return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx new file mode 100644 index 0000000000..1ca109f232 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -0,0 +1,34 @@ +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { createResource, createMemo } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" + +export type DialogSkillProps = { + onSelect: (skill: string) => void +} + +export function DialogSkill(props: DialogSkillProps) { + const dialog = useDialog() + const sdk = useSDK() + + const [skills] = createResource(async () => { + const result = await sdk.client.app.skills() + return result.data ?? [] + }) + + const options = createMemo[]>(() => { + const list = skills() ?? [] + return list.map((skill) => ({ + title: skill.name, + description: skill.description, + value: skill.name, + category: "Skills", + onSelect: () => { + props.onSelect(skill.name) + dialog.clear() + }, + })) + }) + + return +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index bd000e2ab0..5f66dc822a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -345,7 +345,8 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...command.slashes()] for (const serverCommand of sync.data.command) { - const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" + if (serverCommand.source === "skill") continue + const label = serverCommand.source === "mcp" ? ":mcp" : "" results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, 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 caa1303229..8576dd5763 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" +import { DialogSkill } from "../dialog-skill" export type PromptProps = { sessionID?: string @@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) { input.cursorOffset = Bun.stringWidth(content) }, }, + { + title: "Skills", + value: "prompt.skills", + category: "Prompt", + slash: { + name: "skills", + }, + onSelect: () => { + dialog.replace(() => ( + { + input.setText(`/${skill} `) + setStore("prompt", { + input: `/${skill} `, + parts: [], + }) + input.gotoBufferEnd() + }} + /> + )) + }, + }, ] }) 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 693043450c..cbfeb67b2d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" +import type { SkillTool } from "@/tool/skill" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -1636,7 +1640,9 @@ function Bash(props: ToolProps) { > $ {props.input.command} - {limited()} + + {limited()} + {expanded() ? "Click to collapse" : "Click to expand"} @@ -1701,7 +1707,9 @@ function Glob(props: ToolProps) { return ( Glob "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.count} matches) + + ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) + ) } @@ -1737,7 +1745,9 @@ function Grep(props: ToolProps) { return ( Grep "{props.input.pattern}" in {normalizePath(props.input.path)} - ({props.metadata.matches} matches) + + ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) + ) } @@ -1795,7 +1805,7 @@ function Task(props: ToolProps) { return ( - + ) { > - {props.input.description} ({props.metadata.summary?.length} toolcalls) + {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) @@ -1816,22 +1826,17 @@ function Task(props: ToolProps) { - - {keybind.print("session_child_cycle")} - view subagents - + + + {keybind.print("session_child_cycle")} + view subagents + + - - {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task " - {props.input.description}" + + {props.input.subagent_type} Task {props.input.description} @@ -2036,6 +2041,14 @@ function Question(props: ToolProps) { ) } +function Skill(props: ToolProps) { + return ( + + Skill "{props.input.name}" + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { 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 bd1de7d4de..56d8453c93 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -228,7 +228,7 @@ export function DialogSelect(props: DialogSelectProps) { esc - + { batch(() => { diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index 8f986c3379..420c9dde1b 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string { } if (part.type === "tool") { - let result = `\`\`\`\nTool: ${part.tool}\n` + let result = `**Tool: ${part.tool}**\n` if (options.toolDetails && part.state.input) { - result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "completed" && part.state.output) { - result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "error" && part.state.error) { - result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` } - result += `\n\`\`\`\n\n` + result += `\n` return result } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 14dbeb6794..dce7ac8bbc 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -63,6 +63,7 @@ export namespace Command { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", + source: "command", get template() { return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) }, @@ -71,6 +72,7 @@ export namespace Command { [Default.REVIEW]: { name: Default.REVIEW, description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { return PROMPT_REVIEW.replace("${path}", Instance.worktree) }, @@ -85,6 +87,7 @@ export namespace Command { agent: command.agent, model: command.model, description: command.description, + source: "command", get template() { return command.template }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e30795..98970ba392 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -593,6 +593,10 @@ export namespace Config { export const Agent = z .object({ model: z.string().optional(), + variant: z + .string() + .optional() + .describe("Default model variant for this agent (applies only when using the agent's configured model)."), temperature: z.number().optional(), top_p: z.number().optional(), prompt: z.string().optional(), @@ -624,6 +628,7 @@ export namespace Config { const knownKeys = new Set([ "name", "model", + "variant", "prompt", "description", "temperature", diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index dfa6356a27..32465015e9 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -44,7 +44,7 @@ export namespace File { export const Content = z .object({ - type: z.literal("text"), + type: z.enum(["text", "binary"]), content: z.string(), diff: z.string().optional(), patch: z @@ -73,6 +73,174 @@ export namespace File { }) export type Content = z.infer + const binaryExtensions = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + "bat", + "cmd", + "ps1", + "sh", + "bash", + "zsh", + "fish", + ]) + + const imageExtensions = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", + ]) + + function isImageByExtension(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase().slice(1) + return imageExtensions.has(ext) + } + + function getImageMimeType(filepath: string): string { + const ext = path.extname(filepath).toLowerCase().slice(1) + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + return mimeTypes[ext] || "image/" + ext + } + + function isBinaryByExtension(filepath: string): boolean { + const ext = path.extname(filepath).toLowerCase().slice(1) + return binaryExtensions.has(ext) + } + + function isImage(mimeType: string): boolean { + return mimeType.startsWith("image/") + } + async function shouldEncode(file: BunFile): Promise { const type = file.type?.toLowerCase() log.info("shouldEncode", { type }) @@ -83,30 +251,10 @@ export namespace File { const parts = type.split("/", 2) const top = parts[0] - const rest = parts[1] ?? "" - const sub = rest.split(";", 1)[0] const tops = ["image", "audio", "video", "font", "model", "multipart"] if (tops.includes(top)) return true - const bins = [ - "zip", - "gzip", - "bzip", - "compressed", - "binary", - "pdf", - "msword", - "powerpoint", - "excel", - "ogg", - "exe", - "dmg", - "iso", - "rar", - ] - if (bins.some((mark) => sub.includes(mark))) return true - return false } @@ -287,6 +435,22 @@ export namespace File { throw new Error(`Access denied: path escapes project directory`) } + // Fast path: check extension before any filesystem operations + if (isImageByExtension(file)) { + const bunFile = Bun.file(full) + if (await bunFile.exists()) { + const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) + const content = Buffer.from(buffer).toString("base64") + const mimeType = getImageMimeType(file) + return { type: "text", content, mimeType, encoding: "base64" } + } + return { type: "text", content: "" } + } + + if (isBinaryByExtension(file)) { + return { type: "binary", content: "" } + } + const bunFile = Bun.file(full) if (!(await bunFile.exists())) { @@ -294,11 +458,15 @@ export namespace File { } const encode = await shouldEncode(bunFile) + const mimeType = bunFile.type || "application/octet-stream" + + if (encode && !isImage(mimeType)) { + return { type: "binary", content: "", mimeType } + } if (encode) { const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) const content = Buffer.from(buffer).toString("base64") - const mimeType = bunFile.type || "application/octet-stream" return { type: "text", content, mimeType, encoding: "base64" } } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index dd94cc6097..463a9fb362 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -215,7 +215,7 @@ export namespace Ripgrep { const args = [await filepath(), "--files", "--glob=!.git/*"] if (input.follow) args.push("--follow") - if (input.hidden) args.push("--hidden") + if (input.hidden !== false) args.push("--hidden") if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.glob) { for (const g of input.glob) { diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts index 642d7145fe..d6f7cb34bb 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -100,7 +100,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro break } case "reasoning": { - reasoningText = part.text + if (part.text) reasoningText = part.text break } case "tool-call": { @@ -122,7 +122,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro role: "assistant", content: text || null, tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - reasoning_text: reasoningText, + reasoning_text: reasoningOpaque ? reasoningText : undefined, reasoning_opaque: reasoningOpaque, ...metadata, }) diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts index 94641e640e..c85d3f3d17 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts @@ -219,7 +219,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { // text content: const text = choice.message.content if (text != null && text.length > 0) { - content.push({ type: "text", text }) + content.push({ + type: "text", + text, + providerMetadata: choice.message.reasoning_opaque + ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } + : undefined, + }) } // reasoning content (Copilot uses reasoning_text): @@ -243,6 +249,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments!, + providerMetadata: choice.message.reasoning_opaque + ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } + : undefined, }) } } @@ -478,7 +487,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { } if (!isActiveText) { - controller.enqueue({ type: "text-start", id: "txt-0" }) + controller.enqueue({ + type: "text-start", + id: "txt-0", + providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, + }) isActiveText = true } @@ -559,6 +572,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments, + providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, }) toolCall.hasFinished = true } @@ -601,6 +615,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments, + providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, }) toolCall.hasFinished = true } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f5fe419db9..ded416e66d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -179,7 +179,7 @@ export namespace ProviderTransform { cacheControl: { type: "ephemeral" }, }, bedrock: { - cachePoint: { type: "ephemeral" }, + cachePoint: { type: "default" }, }, openaiCompatible: { cache_control: { type: "ephemeral" }, @@ -190,7 +190,8 @@ export namespace ProviderTransform { } for (const msg of unique([...system, ...final])) { - const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 + const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock") + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { const lastContent = msg.content[msg.content.length - 1] @@ -394,31 +395,6 @@ export namespace ProviderTransform { case "@ai-sdk/deepinfra": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra case "@ai-sdk/openai-compatible": - // When using openai-compatible SDK with Claude/Anthropic models, - // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names - // and the OpenAI-compatible API spec uses snake_case - if ( - model.providerID === "anthropic" || - model.api.id.includes("anthropic") || - model.api.id.includes("claude") || - model.id.includes("anthropic") || - model.id.includes("claude") - ) { - return { - high: { - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }, - max: { - thinking: { - type: "enabled", - budget_tokens: 31999, - }, - }, - } - } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/azure": @@ -718,21 +694,9 @@ export namespace ProviderTransform { const modelCap = modelLimit || globalLimit const standardLimit = Math.min(modelCap, globalLimit) - // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens) - // and @ai-sdk/openai-compatible with Claude (budget_tokens) - if ( - npm === "@ai-sdk/anthropic" || - npm === "@ai-sdk/google-vertex/anthropic" || - npm === "@ai-sdk/openai-compatible" - ) { + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { const thinking = options?.["thinking"] - // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible) - const budgetTokens = - typeof thinking?.["budgetTokens"] === "number" - ? thinking["budgetTokens"] - : typeof thinking?.["budget_tokens"] === "number" - ? thinking["budget_tokens"] - : 0 + const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0 const enabled = thinking?.["type"] === "enabled" if (enabled && budgetTokens > 0) { // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b2cd7246a5..020bca1964 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -185,12 +185,15 @@ export namespace Server { }, ) .use(async (c, next) => { - let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - try { - directory = decodeURIComponent(directory) - } catch { - // fallback to original value - } + if (c.req.path === "/log") return next() + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })() return Instance.provide({ directory, init: InstanceBootstrap, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index e87e4e4e44..34b0e57b3a 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -505,17 +505,23 @@ export namespace Session { export function* list() { const project = Instance.project - const rows = Database.use((db) => - db.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all(), - ) - for (const row of rows) { - yield fromRow(row) + for (const item of await Storage.list(["session", project.id])) { + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue + yield session } } export const children = fn(Identifier.schema("session"), async (parentID) => { - const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all()) - return rows.map((row) => fromRow(row)) + const project = Instance.project + const result = [] as Session.Info[] + for (const item of await Storage.list(["session", project.id])) { + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue + if (session.parentID !== parentID) continue + result.push(session) + } + return result }) export const remove = fn(Identifier.schema("session"), async (sessionID) => { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 723439a3fd..65ca1e9bb2 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -75,7 +75,9 @@ export namespace InstructionPrompt { for (const file of FILES) { const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) if (matches.length > 0) { - matches.forEach((p) => paths.add(path.resolve(p))) + matches.forEach((p) => { + paths.add(path.resolve(p)) + }) break } } @@ -103,7 +105,9 @@ export namespace InstructionPrompt { }), ).catch(() => []) : await resolveRelative(instruction) - matches.forEach((p) => paths.add(path.resolve(p))) + matches.forEach((p) => { + paths.add(path.resolve(p)) + }) } } @@ -168,12 +172,14 @@ export namespace InstructionPrompt { const already = loaded(messages) const results: { filepath: string; content: string }[] = [] - let current = path.dirname(path.resolve(filepath)) + const target = path.resolve(filepath) + let current = path.dirname(target) const root = path.resolve(Instance.directory) - while (current.startsWith(root)) { + while (current.startsWith(root) && current !== root) { const found = await find(current) - if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { + + if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { claim(messageID, found) const content = await Bun.file(found) .text() @@ -182,7 +188,6 @@ export namespace InstructionPrompt { results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) } } - if (current === root) break current = path.dirname(current) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index befa46fe4a..4be6e2538f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -233,19 +233,12 @@ export namespace LLM { }, maxRetries: input.retries ?? 0, messages: [ - ...(isCodex - ? [ - { - role: "user", - content: system.join("\n\n"), - } as ModelMessage, - ] - : system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - )), + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), ...input.messages, ], model: wrapLanguageModel({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 826d0842cc..e533ca0283 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -180,6 +180,14 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { + const attachments = value.output.attachments?.map( + (attachment: Omit) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: match.messageID, + sessionID: match.sessionID, + }), + ) await Session.updatePart({ ...match, state: { @@ -192,7 +200,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments: value.output.attachments, + attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f050c43e97..222cff8242 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -185,13 +185,17 @@ export namespace SessionPrompt { text: template, }, ] - const files = ConfigMarkdown.files(template) + const matches = ConfigMarkdown.files(template) const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return + const names = matches + .map((match) => match[1]) + .filter((name) => { + if (seen.has(name)) return false seen.add(name) + return true + }) + const resolved = await Promise.all( + names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -199,33 +203,34 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return + if (!agent) return undefined + return { + type: "agent", + name: agent.name, + } satisfies PromptInput["parts"][number] } if (stats.isDirectory()) { - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - }) - return + } satisfies PromptInput["parts"][number] } - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - }) + } satisfies PromptInput["parts"][number] }), ) + for (const item of resolved) { + if (!item) continue + parts.push(item) + } return parts } @@ -422,6 +427,12 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: assistantMessage.id, + sessionID: assistantMessage.sessionID, + })) await Session.updatePart({ ...part, state: { @@ -430,7 +441,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments: result.attachments, + attachments, time: { ...part.state.time, end: Date.now(), @@ -769,16 +780,13 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] + const attachments: Omit[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -790,9 +798,6 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -825,6 +830,17 @@ export namespace SessionPrompt { async function createUserMessage(input: PromptInput) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + + const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + const variant = + input.variant ?? + (agent.variant && + agent.model && + model.providerID === agent.model.providerID && + model.modelID === agent.model.modelID + ? agent.variant + : undefined) + const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -834,9 +850,9 @@ export namespace SessionPrompt { }, tools: input.tools, agent: agent.name, - model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), + model, system: input.system, - variant: input.variant, + variant, } using _ = defer(() => InstructionPrompt.clear(info.id)) @@ -1030,6 +1046,7 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, + id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1167,7 +1184,18 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat()) + ) + .then((x) => x.flat()) + .then((drafts) => + drafts.map( + (part): MessageV2.Part => ({ + ...part, + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }), + ), + ) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba34eb48f5..b5c3ad0a12 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: ctx.messageID, + sessionID: ctx.sessionID, + })) await Session.updatePart({ id: partID, @@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments: result.attachments, + attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44c..13236d44dd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,7 +6,6 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" -import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { - id: Identifier.ascending("part"), - sessionID: ctx.sessionID, - messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 3d17ea192d..0e78ba665c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: MessageV2.FilePart[] + attachments?: Omit[] }> formatValidationError?(error: z.ZodError): string }> diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index e4c72264cf..24f5dd7c3e 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -220,6 +220,13 @@ export namespace Worktree { return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n") } + async function canonical(input: string) { + const abs = path.resolve(input) + const real = await fs.realpath(abs).catch(() => abs) + const normalized = path.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized + } + async function candidate(root: string, base?: string) { for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() @@ -376,7 +383,7 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = path.resolve(input.directory) + const directory = await canonical(input.directory) const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) @@ -399,7 +406,13 @@ export namespace Worktree { return acc }, []) - const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) + const entry = await (async () => { + for (const item of entries) { + if (!item.path) continue + const key = await canonical(item.path) + if (key === directory) return item + } + })() if (!entry?.path) { throw new RemoveFailedError({ message: "Worktree not found" }) } @@ -425,8 +438,9 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = path.resolve(input.directory) - if (directory === path.resolve(Instance.worktree)) { + const directory = await canonical(input.directory) + const primary = await canonical(Instance.worktree) + if (directory === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } @@ -452,7 +466,13 @@ export namespace Worktree { return acc }, []) - const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) + const entry = await (async () => { + for (const item of entries) { + if (!item.path) continue + const key = await canonical(item.path) + if (key === directory) return item + } + })() if (!entry?.path) { throw new ResetFailedError({ message: "Worktree not found" }) } diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 2cb29e1a89..7a5fa6b8f1 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -119,13 +119,38 @@ describe("transcript", () => { }, } const result = formatPart(part, options) - expect(result).toContain("Tool: bash") + expect(result).toContain("**Tool: bash**") expect(result).toContain("**Input:**") expect(result).toContain('"command": "ls"') expect(result).toContain("**Output:**") expect(result).toContain("file1.txt") }) + test("formats tool output containing triple backticks without breaking markdown", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "echo '```hello```'" }, + output: "```hello```", + title: "Echo backticks", + metadata: {}, + time: { start: 1000, end: 1100 }, + }, + } + const result = formatPart(part, options) + // The tool header should not be inside a code block + expect(result).toStartWith("**Tool: bash**\n") + // Input and output should each be in their own code blocks + expect(result).toContain("**Input:**\n```json") + expect(result).toContain("**Output:**\n```\n```hello```\n```") + }) + test("formats tool part without details when disabled", () => { const part: Part = { id: "part_1", @@ -144,7 +169,7 @@ describe("transcript", () => { }, } const result = formatPart(part, { ...options, toolDetails: false }) - expect(result).toContain("Tool: bash") + expect(result).toContain("**Tool: bash**") expect(result).not.toContain("**Input:**") expect(result).not.toContain("**Output:**") }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1752e22e01..8611d82969 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -255,6 +255,37 @@ test("handles agent configuration", async () => { }) }) +test("treats agent variant as model-scoped setting (not provider option)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "openai/gpt-5.2", + variant: "xhigh", + max_tokens: 123, + }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agent = config.agent?.["test_agent"] + + expect(agent?.variant).toBe("xhigh") + expect(agent?.options).toMatchObject({ + max_tokens: 123, + }) + expect(agent?.options).not.toHaveProperty("variant") + }, + }) +}) + test("handles command configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts new file mode 100644 index 0000000000..ac46f1131b --- /dev/null +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { Ripgrep } from "../../src/file/ripgrep" + +describe("file.ripgrep", () => { + test("defaults to include hidden", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "visible.txt"), "hello") + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") + }, + }) + + const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path })) + const hasVisible = files.includes("visible.txt") + const hasHidden = files.includes(path.join(".opencode", "thing.json")) + expect(hasVisible).toBe(true) + expect(hasHidden).toBe(true) + }) + + test("hidden false excludes hidden", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "visible.txt"), "hello") + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") + }, + }) + + const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false })) + const hasVisible = files.includes("visible.txt") + const hasHidden = files.includes(path.join(".opencode", "thing.json")) + expect(hasVisible).toBe(true) + expect(hasHidden).toBe(false) + }) +}) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts new file mode 100644 index 0000000000..d8f8ea4551 --- /dev/null +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { ProviderAuth } from "../../src/provider/auth" + +describe("plugin.auth-override", () => { + test("user plugin overrides built-in github-copilot auth", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(pluginDir, "custom-copilot-auth.ts"), + [ + "export default async () => ({", + " auth: {", + ' provider: "github-copilot",', + " methods: [", + ' { type: "api", label: "Test Override Auth" },', + " ],", + " loader: async () => ({ access: 'test-token' }),", + " },", + "})", + "", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const methods = await ProviderAuth.methods() + const copilot = methods["github-copilot"] + expect(copilot).toBeDefined() + expect(copilot.length).toBe(1) + expect(copilot[0].label).toBe("Test Override Auth") + }, + }) + }, 30000) // Increased timeout for plugin installation +}) diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts index ffc7469115..9f305123af 100644 --- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts +++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts @@ -354,7 +354,7 @@ describe("tool calls", () => { }) describe("reasoning (copilot-specific)", () => { - test("should include reasoning_text from reasoning part", () => { + test("should omit reasoning_text without reasoning_opaque", () => { const result = convertToCopilotMessages([ { role: "assistant", @@ -370,7 +370,7 @@ describe("reasoning (copilot-specific)", () => { role: "assistant", content: "The answer is 42.", tool_calls: undefined, - reasoning_text: "Let me think about this...", + reasoning_text: undefined, reasoning_opaque: undefined, }, ]) @@ -404,6 +404,33 @@ describe("reasoning (copilot-specific)", () => { ]) }) + test("should include reasoning_opaque from text part providerOptions", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "text", + text: "Done!", + providerOptions: { + copilot: { reasoningOpaque: "opaque-text-456" }, + }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "Done!", + tool_calls: undefined, + reasoning_text: undefined, + reasoning_opaque: "opaque-text-456", + }, + ]) + }) + test("should handle reasoning-only assistant message", () => { const result = convertToCopilotMessages([ { diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts index 0b82c18684..562da4507d 100644 --- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts +++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts @@ -65,6 +65,12 @@ const FIXTURES = { `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`, `data: [DONE]`, ], + + reasoningOpaqueWithToolCallsNoReasoningText: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`, + `data: [DONE]`, + ], } function createMockFetch(chunks: string[]) { @@ -447,6 +453,35 @@ describe("doStream", () => { }) }) + test("should attach reasoning_opaque to tool calls without reasoning_text", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + const reasoningParts = parts.filter( + (p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end", + ) + + expect(reasoningParts).toHaveLength(0) + + const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only") + expect(toolCall).toMatchObject({ + type: "tool-call", + toolCallId: "call_reasoning_only", + toolName: "read_file", + providerMetadata: { + copilot: { + reasoningOpaque: "opaque-xyz", + }, + }, + }) + }) + test("should include response metadata from first chunk", async () => { const mockFetch = createMockFetch(FIXTURES.basicText) const model = createModel(mockFetch) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0973e61585..8e28f1209e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => { expect(result).toBe(OUTPUT_TOKEN_MAX) }) }) - - describe("openai-compatible with thinking options (snake_case)", () => { - test("returns 32k when budget_tokens + 32k <= modelLimit", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => { - const modelLimit = 50000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 30000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(20000) - }) - - test("returns 32k when thinking type is not enabled", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "disabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns 32k when budget_tokens is 0", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 0, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - }) }) describe("ProviderTransform.schema - gemini array items", () => { @@ -1166,7 +1096,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile" expect(result[0].providerOptions?.bedrock).toEqual( expect.objectContaining({ cachePoint: { - type: "ephemeral", + type: "default", }, }), ) @@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) - - test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => { - const model = createMockModel({ - id: "anthropic/claude-sonnet-4-5", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-5-20250929", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }) - expect(result.max).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 31999, - }, - }) - }) - - test("Claude model (by model.id) via openai-compatible uses snake_case", () => { - const model = createMockModel({ - id: "litellm/claude-3-opus", - providerID: "litellm", - api: { - id: "claude-3-opus-20240229", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }) - }) - - test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => { - const model = createMockModel({ - id: "custom/my-model", - providerID: "custom", - api: { - id: "anthropic.claude-sonnet", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high.thinking.budget_tokens).toBe(16000) - }) }) describe("@ai-sdk/azure", () => { diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 67719fa339..4d57e92a25 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => { }, }) }) + + test("doesn't reload AGENTS.md when reading it directly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") + await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const filepath = path.join(tmp.path, "subdir", "AGENTS.md") + const system = await InstructionPrompt.systemPaths() + expect(system.has(filepath)).toBe(false) + + const results = await InstructionPrompt.resolve([], filepath, "test-message-2") + expect(results).toEqual([]) + }, + }) + }) }) diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts new file mode 100644 index 0000000000..16e8a22444 --- /dev/null +++ b/packages/opencode/test/session/prompt-variant.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir } from "../fixture/fixture" + +describe("session.prompt agent variant", () => { + test("applies agent variant only when using agent model", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + variant: "xhigh", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const other = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: "opencode", modelID: "kimi-k2.5-free" }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.variant).toBeUndefined() + + const match = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) + expect(match.info.variant).toBe("xhigh") + + const override = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + variant: "high", + parts: [{ type: "text", text: "hello third" }], + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.variant).toBe("high") + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 0000000000..e778bfe514 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,62 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("SessionPrompt ordering", () => { + test("keeps @file order with read output parts", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "28\n") + await Bun.write(path.join(dir, "b.txt"), "42\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "What numbers are written in files @a.txt and @b.txt ?" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const items = stored.parts + const aPath = path.join(tmp.path, "a.txt") + const bPath = path.join(tmp.path, "b.txt") + const sequence = items.flatMap((part) => { + if (part.type === "text") { + if (part.text.includes(aPath)) return ["input:a"] + if (part.text.includes(bPath)) return ["input:b"] + if (part.text.includes("00001| 28")) return ["output:a"] + if (part.text.includes("00001| 42")) return ["output:b"] + return [] + } + if (part.type === "file") { + if (part.filename === "a.txt") return ["file:a"] + if (part.filename === "b.txt") return ["file:b"] + } + return [] + }) + + expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) + + await Session.remove(session.id) + }, + }) + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5a4afbae43..160ce6a826 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -9,8 +9,14 @@ "build": "tsc" }, "exports": { - ".": "./src/index.ts", - "./tool": "./src/tool.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tool": { + "types": "./dist/tool.d.ts", + "import": "./dist/tool.js" + } }, "files": [ "dist" diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 2d991ff0c3..496bdede2d 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -46,6 +46,20 @@ const VERSION = await (async () => { return `${major}.${minor}.${patch + 1}` })() +const team = [ + "actions-user", + "opencode", + "rekram1-node", + "thdxr", + "kommander", + "jayair", + "fwang", + "adamdotdevin", + "iamdavidhill", + "opencode-agent[bot]", + "R44VC0RP", +] + export const Script = { get channel() { return CHANNEL @@ -59,5 +73,8 @@ export const Script = { get release() { return env.OPENCODE_RELEASE }, + get team() { + return team + }, } console.log(`opencode script`, JSON.stringify(Script, null, 2)) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index ca13e5e93c..8eefe5bfe9 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1554,7 +1554,7 @@ export type FileNode = { } export type FileContent = { - type: "text" + type: "text" | "binary" content: string diff?: string patch?: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2ce9731be5..3dbb94de0b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1378,6 +1378,10 @@ export type PermissionConfig = export type AgentConfig = { model?: string + /** + * Default model variant for this agent (applies only when using the agent's configured model). + */ + variant?: string temperature?: number top_p?: number prompt?: string @@ -2049,7 +2053,7 @@ export type FileNode = { } export type FileContent = { - type: "text" + type: "text" | "binary" content: string diff?: string patch?: { @@ -2143,6 +2147,7 @@ export type Agent = { modelID: string providerID: string } + variant?: string prompt?: string options: { [key: string]: unknown diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 66df1739e6..4608bd60cc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9044,6 +9044,10 @@ "model": { "type": "string" }, + "variant": { + "description": "Default model variant for this agent (applies only when using the agent's configured model).", + "type": "string" + }, "temperature": { "type": "number" }, @@ -10591,7 +10595,7 @@ "properties": { "type": { "type": "string", - "const": "text" + "enum": ["text", "binary"] }, "content": { "type": "string" @@ -10869,6 +10873,9 @@ }, "required": ["modelID", "providerID"] }, + "variant": { + "type": "string" + }, "prompt": { "type": "string" }, diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index d9b3459230..3e5d21d1de 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,7 +9,13 @@ user-select: none; cursor: default; outline: none; + padding: 4px 8px; white-space: nowrap; + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + outline: none; + line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -94,7 +100,6 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; - transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -109,34 +114,31 @@ } &[data-size="small"] { - height: 22px; - padding: 0 8px; + padding: 4px 8px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; } - font-size: var(--font-size-small); - line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { - height: 24px; - line-height: 24px; - padding: 0 6px; + padding: 4px 6px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; + } + + &[aria-haspopup] { + padding: 4px 6px 4px 8px; } - font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ @@ -148,7 +150,6 @@ } &[data-size="large"] { - height: 32px; padding: 6px 12px; &[data-icon] { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 7f974b2f76..b2d2004d3c 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css new file mode 100644 index 0000000000..3c98fcd261 --- /dev/null +++ b/packages/ui/src/components/cycle-label.css @@ -0,0 +1,49 @@ +.cycle-label { + --c-duration: 200ms; + --c-stagger: 30ms; + --c-opacity-start: 0; + --c-opacity-end: 1; + --c-blur-start: 0px; + --c-blur-end: 0px; + --c-skew: 10deg; + + display: inline-flex; + position: relative; + + transform-style: preserve-3d; + perspective: 500px; + transition: width var(--transition-duration) var(--transition-easing); + will-change: width; + overflow: hidden; + + .cycle-char { + display: inline-block; + transform-style: preserve-3d; + min-width: 0.25em; + backface-visibility: hidden; + + transition-property: transform, opacity, filter; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + transition-delay: calc(var(--i, 0) * var(--c-stagger)); + + &.enter { + opacity: var(--c-opacity-end); + filter: blur(var(--c-blur-end)); + transform: translateY(0) rotateX(0) skewX(0); + } + + &.exit { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); + } + + &.pre { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transition: none; + transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); + } + } +} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx new file mode 100644 index 0000000000..dc12bd75c8 --- /dev/null +++ b/packages/ui/src/components/cycle-label.tsx @@ -0,0 +1,135 @@ +import "./cycle-label.css" +import { createEffect, createSignal, JSX, on } from "solid-js" + +export interface CycleLabelProps extends JSX.HTMLAttributes { + value: string + onValueChange?: (value: string) => void + duration?: number | ((value: string) => number) + stagger?: number + opacity?: [number, number] + blur?: [number, number] + skewX?: number + onAnimationStart?: () => void + onAnimationEnd?: () => void +} + +const segmenter = + typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null + +const getChars = (text: string): string[] => + segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function CycleLabel(props: CycleLabelProps) { + const getDuration = (text: string) => { + const d = + props.duration ?? + Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? + 200 + return typeof d === "function" ? d(text) : d + } + const stagger = () => props?.stagger ?? 30 + const opacity = () => props?.opacity ?? [0, 1] + const blur = () => props?.blur ?? [0, 0] + const skewX = () => props?.skewX ?? 10 + + let containerRef: HTMLSpanElement | undefined + let isAnimating = false + const [currentText, setCurrentText] = createSignal(props.value) + + const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { + el.innerHTML = "" + const chars = getChars(text) + chars.forEach((char, i) => { + const span = document.createElement("span") + span.textContent = char === " " ? "\u00A0" : char + span.className = `cycle-char ${state}` + span.style.setProperty("--i", String(i)) + el.appendChild(span) + }) + } + + const animateToText = async (newText: string) => { + if (!containerRef || isAnimating) return + if (newText === currentText()) return + + isAnimating = true + props.onAnimationStart?.() + + const dur = getDuration(newText) + const stag = stagger() + + containerRef.style.width = containerRef.offsetWidth + "px" + + const oldChars = containerRef.querySelectorAll(".cycle-char") + oldChars.forEach((c) => c.classList.replace("enter", "exit")) + + const clone = containerRef.cloneNode(false) as HTMLElement + Object.assign(clone.style, { + position: "absolute", + visibility: "hidden", + width: "auto", + transition: "none", + }) + setChars(clone, newText) + document.body.appendChild(clone) + const nextWidth = clone.offsetWidth + clone.remove() + + const exitTime = oldChars.length * stag + dur + await wait(exitTime * 0.3) + + containerRef.style.width = nextWidth + "px" + + const widthDur = 200 + await wait(widthDur * 0.3) + + setChars(containerRef, newText, "pre") + containerRef.offsetWidth + + Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) + setCurrentText(newText) + props.onValueChange?.(newText) + + const enterTime = getChars(newText).length * stag + dur + await wait(enterTime) + + containerRef.style.width = "" + isAnimating = false + props.onAnimationEnd?.() + } + + createEffect( + on( + () => props.value, + (newValue) => { + if (newValue !== currentText()) { + animateToText(newValue) + } + }, + ), + ) + + const initRef = (el: HTMLSpanElement) => { + containerRef = el + setChars(el, props.value) + } + + return ( + + ) +} diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index cba041613e..18266ac1a1 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,26 +2,29 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; + border: none; border-radius: var(--radius-md); - border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + box-shadow: var(--shadow-xs-border); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - box-shadow: var(--shadow-md); - z-index: 50; + z-index: 100; transform-origin: var(--kb-menu-content-transform-origin); - &:focus, - &:focus-visible { + &:focus-within, + &:focus { outline: none; } - &[data-closed] { - animation: dropdown-menu-close 0.15s ease-out; + animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; } &[data-expanded] { - animation: dropdown-menu-open 0.15s ease-out; + pointer-events: auto; + animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; } } @@ -38,18 +41,22 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; - user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - &[data-highlighted] { - background: var(--surface-raised-base-hover); + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { @@ -61,6 +68,8 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); + outline: none; + border: none; } } @@ -102,24 +111,24 @@ } } -@keyframes dropdown-menu-open { +@keyframes dropdownMenuContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes dropdown-menu-close { +@keyframes dropdownMenuContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd2..97488a42f0 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,13 +80,16 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" + size?: "small" | "normal" | "medium" | "large" | number } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
"; - inherits: false; - initial-value: 0px; -} - -@keyframes scroll { - 0% { - --bottom-fade: 20px; - } - 90% { - --bottom-fade: 20px; - } - 100% { - --bottom-fade: 0; - } -} - [data-component="list"] { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; overflow: hidden; padding: 0 12px; @@ -37,7 +19,9 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -88,7 +72,9 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -131,15 +117,6 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; - mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); - animation: scroll; - animation-timeline: --scroll; - scroll-timeline: --scroll y; - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } [data-slot="list-empty-state"] { display: flex; @@ -215,7 +192,9 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); } &[data-stuck="true"]::after { @@ -251,17 +230,22 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } } + + [name="check"] { + color: var(--icon-strong-base); + } + [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2132897f7c..15854180e4 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" +import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -267,7 +268,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
-
+ 0 || showAdd()} fallback={ @@ -339,7 +340,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 7aad01acea..b8a7ce0b50 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -42,13 +42,13 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { findLast } from "@opencode-ai/util/array" 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" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { MorphChevron } from "./morph-chevron" interface Diagnostic { range: { @@ -415,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp toggleExpanded() }} > - +
m.id === perm.tool!.messageID) + const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css new file mode 100644 index 0000000000..f6edb3f649 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.css @@ -0,0 +1,10 @@ +[data-slot="morph-chevron-svg"] { + width: 16px; + height: 16px; + display: block; + fill: none; + stroke-width: 1.5; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx new file mode 100644 index 0000000000..280aeb7e34 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.tsx @@ -0,0 +1,73 @@ +import { createEffect, createUniqueId, on } from "solid-js" + +export interface MorphChevronProps { + expanded: boolean + class?: string +} + +const COLLAPSED = "M4 6L8 10L12 6" +const EXPANDED = "M4 10L8 6L12 10" + +export function MorphChevron(props: MorphChevronProps) { + const id = createUniqueId() + let path: SVGPathElement | undefined + let expandAnim: SVGAnimateElement | undefined + let collapseAnim: SVGAnimateElement | undefined + + createEffect( + on( + () => props.expanded, + (expanded, prev) => { + if (prev === undefined) { + // Set initial state without animation + path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) + return + } + if (expanded) { + expandAnim?.beginElement() + } else { + collapseAnim?.beginElement() + } + }, + ), + ) + + return ( + + ) +} diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9..d200fe8b24 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,16 +15,35 @@ transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { - outline: none; - } + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - &[data-closed] { - animation: popover-close 0.15s ease-out; + @starting-style { + animation: none; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + } + + [data-origin-top-right] { + transform-origin: top right; + } + + [data-origin-top-left] { + transform-origin: top left; + } + + [data-origin-bottom-right] { + transform-origin: bottom right; + } + + [data-origin-bottom-left] { + transform-origin: bottom left; + } + + &:focus-within { + outline: none; } [data-slot="popover-header"] { @@ -75,24 +94,39 @@ } } -@keyframes popover-open { +@keyframes popoverContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes popover-close { +@keyframes popoverContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); + } +} + +[data-component="model-popover-content"] { + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css new file mode 100644 index 0000000000..26fbc01448 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.css @@ -0,0 +1,9 @@ +[data-component="reasoning-icon"] { + color: var(--icon-strong-base); + + [data-slot="reasoning-icon-percentage"] { + transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); + clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); + opacity: calc(var(--reasoning-icon-percentage) * 0.75); + } +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx new file mode 100644 index 0000000000..7bac49ffd2 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -0,0 +1,46 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface ReasoningIconProps extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ReasoningIcon(props: ReasoningIconProps) { + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) + + const size = () => split.size || 16 + const strokeWidth = () => split.strokeWidth || 1.25 + + return ( + + + + + ) +} diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css new file mode 100644 index 0000000000..ede5fabec4 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.css @@ -0,0 +1,82 @@ +[data-component="scroll-fade"] { + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; + box-sizing: border-box; + color: inherit; + font: inherit; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &[data-direction="horizontal"] { + overflow-x: auto; + overflow-y: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } + + &[data-direction="vertical"] { + overflow-y: auto; + overflow-x: hidden; + + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx new file mode 100644 index 0000000000..97f0339e82 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.tsx @@ -0,0 +1,206 @@ +import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" + +export interface ScrollFadeProps extends JSX.HTMLAttributes { + direction?: "horizontal" | "vertical" + fadeStartSize?: number + fadeEndSize?: number + trackTransformSelector?: string + ref?: (el: HTMLDivElement) => void +} + +export function ScrollFade(props: ScrollFadeProps) { + const [local, others] = splitProps(props, [ + "children", + "direction", + "fadeStartSize", + "fadeEndSize", + "trackTransformSelector", + "class", + "style", + "ref", + ]) + + const direction = () => local.direction ?? "vertical" + const fadeStartSize = () => local.fadeStartSize ?? 20 + const fadeEndSize = () => local.fadeEndSize ?? 20 + + const getTransformOffset = (element: Element): number => { + const style = getComputedStyle(element) + const transform = style.transform + if (!transform || transform === "none") return 0 + + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) + if (!match) return 0 + + const values = match[1].split(",").map((v) => parseFloat(v.trim())) + const isHorizontal = direction() === "horizontal" + + if (transform.startsWith("matrix3d")) { + return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) + } else { + return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) + } + } + + let containerRef: HTMLDivElement | undefined + + const [fadeStart, setFadeStart] = createSignal(0) + const [fadeEnd, setFadeEnd] = createSignal(0) + const [isScrollable, setIsScrollable] = createSignal(false) + + let lastScrollPos = 0 + let lastTransformPos = 0 + let lastScrollSize = 0 + let lastClientSize = 0 + + const updateFade = () => { + if (!containerRef) return + + const isHorizontal = direction() === "horizontal" + const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop + const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight + const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight + + let transformPos = 0 + if (local.trackTransformSelector) { + const transformElement = containerRef.querySelector(local.trackTransformSelector) + if (transformElement) { + transformPos = getTransformOffset(transformElement) + } + } + + const effectiveScrollPos = Math.max(scrollPos, transformPos) + + if ( + effectiveScrollPos === lastScrollPos && + transformPos === lastTransformPos && + scrollSize === lastScrollSize && + clientSize === lastClientSize + ) { + return + } + + lastScrollPos = effectiveScrollPos + lastTransformPos = transformPos + lastScrollSize = scrollSize + lastClientSize = clientSize + + const maxScroll = scrollSize - clientSize + const canScroll = maxScroll > 1 + + setIsScrollable(canScroll) + + if (!canScroll) { + setFadeStart(0) + setFadeEnd(0) + return + } + + const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 + + const startProgress = Math.min(progress / 0.1, 1) + setFadeStart(startProgress * fadeStartSize()) + + const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 + setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) + } + + onMount(() => { + if (!containerRef) return + + updateFade() + + let rafId: number | undefined + let isPolling = false + let pollTimeout: ReturnType | undefined + + const startPolling = () => { + if (isPolling) return + isPolling = true + + const pollScroll = () => { + updateFade() + rafId = requestAnimationFrame(pollScroll) + } + rafId = requestAnimationFrame(pollScroll) + } + + const stopPolling = () => { + if (!isPolling) return + isPolling = false + if (rafId !== undefined) { + cancelAnimationFrame(rafId) + rafId = undefined + } + } + + const schedulePollingStop = () => { + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + pollTimeout = setTimeout(stopPolling, 1000) + } + + const onActivity = () => { + updateFade() + if (local.trackTransformSelector) { + startPolling() + schedulePollingStop() + } + } + + containerRef.addEventListener("scroll", onActivity, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + onActivity() + }) + resizeObserver.observe(containerRef) + + const mutationObserver = new MutationObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + requestAnimationFrame(onActivity) + }) + mutationObserver.observe(containerRef, { + childList: true, + subtree: true, + characterData: true, + }) + + onCleanup(() => { + containerRef?.removeEventListener("scroll", onActivity) + resizeObserver.disconnect() + mutationObserver.disconnect() + stopPolling() + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + }) + }) + + createEffect(() => { + local.children + requestAnimationFrame(updateFade) + }) + + return ( +
{ + containerRef = el + local.ref?.(el) + }} + data-component="scroll-fade" + data-direction={direction()} + data-scrollable={isScrollable() || undefined} + data-fade-start={fadeStart() > 0 || undefined} + data-fade-end={fadeEnd() > 0 || undefined} + class={local.class} + style={{ + ...(typeof local.style === "object" ? local.style : {}), + "--scroll-fade-start": `${fadeStart()}px`, + "--scroll-fade-end": `${fadeEnd()}px`, + }} + {...others} + > + {local.children} +
+ ) +} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx new file mode 100644 index 0000000000..6e5072dc81 --- /dev/null +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -0,0 +1,141 @@ +import { type JSX, onCleanup, splitProps } from "solid-js" +import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" + +const SCROLL_SPEED = 60 +const PAUSE_DURATION = 800 + +type ScrollAnimationState = { + rafId: number | null + startTime: number + running: boolean +} + +const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { + containerEl.offsetHeight + + const extraWidth = containerEl.scrollWidth - containerEl.clientWidth + + if (extraWidth <= 0) { + return null + } + + const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 + const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION + + const state: ScrollAnimationState = { + rafId: null, + startTime: performance.now(), + running: true, + } + + const animate = (currentTime: number) => { + if (!state.running) return + + const elapsed = currentTime - state.startTime + const progress = (elapsed % totalDuration) / totalDuration + + const pausePercent = PAUSE_DURATION / totalDuration + const scrollPercent = scrollDuration / totalDuration + + const pauseEnd1 = pausePercent + const scrollEnd1 = pauseEnd1 + scrollPercent + const pauseEnd2 = scrollEnd1 + pausePercent + const scrollEnd2 = pauseEnd2 + scrollPercent + + let scrollPos = 0 + + if (progress < pauseEnd1) { + scrollPos = 0 + } else if (progress < scrollEnd1) { + const scrollProgress = (progress - pauseEnd1) / scrollPercent + scrollPos = scrollProgress * extraWidth + } else if (progress < pauseEnd2) { + scrollPos = extraWidth + } else if (progress < scrollEnd2) { + const scrollProgress = (progress - pauseEnd2) / scrollPercent + scrollPos = extraWidth * (1 - scrollProgress) + } else { + scrollPos = 0 + } + + containerEl.scrollLeft = scrollPos + state.rafId = requestAnimationFrame(animate) + } + + state.rafId = requestAnimationFrame(animate) + return state +} + +const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { + if (state) { + state.running = false + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId) + } + } + if (containerEl) { + containerEl.scrollLeft = 0 + } +} + +export interface ScrollRevealProps extends Omit { + hoverDelay?: number +} + +export function ScrollReveal(props: ScrollRevealProps) { + const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) + + const hoverDelay = () => local.hoverDelay ?? 300 + + let containerRef: HTMLDivElement | undefined + let hoverTimeout: ReturnType | undefined + let scrollAnimationState: ScrollAnimationState | null = null + + const handleMouseEnter: JSX.EventHandler = () => { + hoverTimeout = setTimeout(() => { + if (!containerRef) return + + containerRef.offsetHeight + + const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 + + if (isScrollable) { + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = startScrollAnimation(containerRef) + } + }, hoverDelay()) + } + + const handleMouseLeave: JSX.EventHandler = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = undefined + } + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = null + } + + onCleanup(() => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + stopScrollAnimation(scrollAnimationState, containerRef) + }) + + return ( + { + containerRef = el + local.ref?.(el) + }} + fadeStartSize={8} + fadeEndSize={8} + direction="horizontal" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...others} + > + {local.children} + + ) +} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b..eaba6fd6d2 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,7 +1,13 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - padding: 0 4px 0 8px; + display: flex; + padding: 4px 8px !important; + align-items: center; + justify-content: space-between; box-shadow: none; + transition-property: background-color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -15,10 +21,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--text-weak); - transition: transform 0.1s ease-in-out; + color: var(--icon-base); } + &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -30,13 +36,13 @@ background-color: var(--icon-strong-active); } } - + &:not([data-expanded]):focus, &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: var(--surface-raised-base-hover); + background-color: transparent; } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -46,10 +52,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 12px; + padding: 6px 6px 6px 10px; box-shadow: none; border-radius: 6px; - min-width: 160px; + field-sizing: content; height: 32px; justify-content: flex-end; gap: 12px; @@ -61,6 +67,7 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); + padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -91,17 +98,26 @@ } [data-component="select-content"] { - min-width: 104px; + min-width: 8rem; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 60; + z-index: 50; + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + + animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } &[data-expanded] { - animation: select-open 0.15s ease-out; + pointer-events: auto; + animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; } [data-slot="select-select-content-list"] { @@ -111,43 +127,38 @@ overflow-x: hidden; display: flex; flex-direction: column; - &:focus { outline: none; } - > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } - [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 2px 8px; + padding: 4px 8px; gap: 12px; - border-radius: 4px; - cursor: default; + border-radius: var(--radius-sm); /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); outline: none; user-select: none; - &[data-highlighted] { - background: var(--surface-raised-base-hover); + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -160,6 +171,11 @@ margin-left: auto; width: 16px; height: 16px; + color: var(--icon-strong-base); + + svg { + color: var(--icon-strong-base); + } } &:focus { outline: none; @@ -171,13 +187,9 @@ } [data-component="select-content"][data-trigger-style="settings"] { - min-width: 160px; + field-sizing: content; border-radius: 8px; - padding: 0; - - [data-slot="select-select-content-list"] { - padding: 4px; - } + padding: 0 0 0 4px; [data-slot="select-select-item"] { /* text-14-regular */ @@ -190,13 +202,24 @@ } } -@keyframes select-open { +@keyframes selectContentShow { from { opacity: 0; - transform: scale(0.95); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); + } +} + +@keyframes selectContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 0386c329ec..fef00500a7 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,8 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" +import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" +import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) + const [isOpen, setIsOpen] = createSignal(false) + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={4} + gutter={8} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit) }} - + + + + + + diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f1c62c0aff..48d6337edb 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -398,6 +398,8 @@ export function SessionTurn( const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) const retry = createMemo(() => { + // session_status is session-scoped; only show retry on the active (last) turn + if (!isLastUserMessage()) return const s = status() if (s.type !== "retry") return return s diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ed0310ef2..2a8171f98c 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); +@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); @@ -48,6 +49,8 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); +@import "../components/morph-chevron.css" layer(components); +@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 8c954f1fe4..82a913c883 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,6 +1,17 @@ :root { interpolate-size: allow-keywords; + /* Transition tokens */ + --transition-duration: 200ms; + --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); + --transition-fast: 150ms; + --transition-slow: 300ms; + + /* Allow height transitions from 0 to auto */ + @supports (interpolate-size: allow-keywords) { + interpolate-size: allow-keywords; + } + [data-popper-positioner] { pointer-events: none; } @@ -129,3 +140,34 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } + +/* Transition utility classes */ +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-opacity { + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-transform { + transition-property: transform; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-shadow { + transition-property: box-shadow; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-interactive { + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index eed1b87fd7..acaaf12bee 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -85,6 +85,7 @@ export default defineConfig({ "network", "enterprise", "troubleshooting", + "windows-wsl", "1-0", { label: "Usage", diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 07110dc1b5..9f84c6af17 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,38 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| 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 | +| Name | Description | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/jamesmurdza/daytona/blob/main/guides/typescript/opencode/README.md) | 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/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index 8b3d3a9c82..bb3b8cb5d0 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -89,6 +89,10 @@ You can also install it with the following commands: #### Windows +:::tip[Recommended: Use WSL] +For the best experience on Windows, we recommend using [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). It provides better performance and full compatibility with OpenCode's features. +::: + - **Using Chocolatey** ```bash diff --git a/packages/web/src/content/docs/troubleshooting.mdx b/packages/web/src/content/docs/troubleshooting.mdx index 7137d88fae..40ac70b9eb 100644 --- a/packages/web/src/content/docs/troubleshooting.mdx +++ b/packages/web/src/content/docs/troubleshooting.mdx @@ -136,6 +136,12 @@ On Windows, OpenCode Desktop requires the Microsoft Edge **WebView2 Runtime**. I --- +### Windows: General performance issues + +If you're experiencing slow performance, file access issues, or terminal problems on Windows, try using [WSL (Windows Subsystem for Linux)](/docs/windows-wsl). WSL provides a Linux environment that works more seamlessly with OpenCode's features. + +--- + ### Notifications not showing OpenCode Desktop only shows system notifications when: diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx index fa3d071090..1013712f3a 100644 --- a/packages/web/src/content/docs/web.mdx +++ b/packages/web/src/content/docs/web.mdx @@ -21,6 +21,10 @@ This starts a local server on `127.0.0.1` with a random available port and autom If `OPENCODE_SERVER_PASSWORD` is not set, the server will be unsecured. This is fine for local use but should be set for network access. ::: +:::tip[Windows Users] +For the best experience, run `opencode web` from [WSL](/docs/windows-wsl) rather than PowerShell. This ensures proper file system access and terminal integration. +::: + --- ## Configuration diff --git a/packages/web/src/content/docs/windows-wsl.mdx b/packages/web/src/content/docs/windows-wsl.mdx new file mode 100644 index 0000000000..ebc35d0d9e --- /dev/null +++ b/packages/web/src/content/docs/windows-wsl.mdx @@ -0,0 +1,113 @@ +--- +title: Windows (WSL) +description: Run OpenCode on Windows using WSL for the best experience. +--- + +import { Steps } from "@astrojs/starlight/components" + +While OpenCode can run directly on Windows, we recommend using [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) for the best experience. WSL provides a Linux environment that works seamlessly with OpenCode's features. + +:::tip[Why WSL?] +WSL offers better file system performance, full terminal support, and compatibility with development tools that OpenCode relies on. +::: + +--- + +## Setup + + + +1. **Install WSL** + + If you haven't already, [install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) using the official Microsoft guide. + +2. **Install OpenCode in WSL** + + Once WSL is set up, open your WSL terminal and install OpenCode using one of the [installation methods](/docs/). + + ```bash + curl -fsSL https://opencode.ai/install | bash + ``` + +3. **Use OpenCode from WSL** + + Navigate to your project directory (access Windows files via `/mnt/c/`, `/mnt/d/`, etc.) and run OpenCode. + + ```bash + cd /mnt/c/Users/YourName/project + opencode + ``` + + + +--- + +## Desktop App + WSL Server + +If you prefer using the OpenCode Desktop app but want to run the server in WSL: + +1. **Start the server in WSL** with `--hostname 0.0.0.0` to allow external connections: + + ```bash + opencode serve --hostname 0.0.0.0 --port 4096 + ``` + +2. **Connect the Desktop app** to `http://localhost:4096` + +:::note +If `localhost` does not work in your setup, connect using the WSL IP address instead (from WSL: `hostname -I`) and use `http://:4096`. +::: + +:::caution +When using `--hostname 0.0.0.0`, set `OPENCODE_SERVER_PASSWORD` to secure the server. + +```bash +OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 +``` + +::: + +--- + +## Web Client + WSL + +For the best web experience on Windows: + +1. **Run `opencode web` in the WSL terminal** rather than PowerShell: + + ```bash + opencode web --hostname 0.0.0.0 + ``` + +2. **Access from your Windows browser** at `http://localhost:` (OpenCode prints the URL) + +Running `opencode web` from WSL ensures proper file system access and terminal integration while still being accessible from your Windows browser. + +--- + +## Accessing Windows Files + +WSL can access all your Windows files through the `/mnt/` directory: + +- `C:` drive → `/mnt/c/` +- `D:` drive → `/mnt/d/` +- And so on... + +Example: + +```bash +cd /mnt/c/Users/YourName/Documents/project +opencode +``` + +:::tip +For the smoothest experience, consider cloning/copying your repo into the WSL filesystem (for example under `~/code/`) and running OpenCode there. +::: + +--- + +## Tips + +- Keep OpenCode running in WSL for projects stored on Windows drives - file access is seamless +- Use VS Code's [WSL extension](https://code.visualstudio.com/docs/remote/wsl) alongside OpenCode for an integrated development workflow +- Your OpenCode config and sessions are stored within the WSL environment at `~/.local/share/opencode/` diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index ddaabbef09..27f4c229c5 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -156,7 +156,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: - GLM 4.7 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Kimi M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Kimi K2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - MiniMax M2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. diff --git a/script/beta.ts b/script/beta.ts index 7a3dfcccf4..53329e4dce 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,87 +1,112 @@ #!/usr/bin/env bun +import { $ } from "bun" + interface PR { number: number title: string + author: { login: string } + labels: Array<{ name: string }> } -interface RunResult { - exitCode: number - stdout: string - stderr: string +interface FailedPR { + number: number + title: string + reason: string +} + +async function commentOnPR(prNumber: number, reason: string) { + const body = `⚠️ **Blocking Beta Release** + +This PR cannot be merged into the beta branch due to: **${reason}** + +Please resolve this issue to include this PR in the next beta release.` + + try { + await $`gh pr comment ${prNumber} --body ${body}` + console.log(` Posted comment on PR #${prNumber}`) + } catch (err) { + console.log(` Failed to post comment on PR #${prNumber}: ${err}`) + } } async function main() { - console.log("Fetching open contributor PRs...") + console.log("Fetching open PRs with beta label...") - const prsResult = await $`gh pr list --label contributor --state open --json number,title --limit 100`.nothrow() - if (prsResult.exitCode !== 0) { - throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`) + const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text() + const prs: PR[] = JSON.parse(stdout) + + console.log(`Found ${prs.length} open PRs with beta label`) + + if (prs.length === 0) { + console.log("No team PRs to merge") + return } - const prs: PR[] = JSON.parse(prsResult.stdout) - console.log(`Found ${prs.length} open contributor PRs`) - console.log("Fetching latest dev branch...") - const fetchDev = await $`git fetch origin dev`.nothrow() - if (fetchDev.exitCode !== 0) { - throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`) - } + await $`git fetch origin dev` console.log("Checking out beta branch...") - const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow() - if (checkoutBeta.exitCode !== 0) { - throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`) - } + await $`git checkout -B beta origin/dev` const applied: number[] = [] - const skipped: Array<{ number: number; reason: string }> = [] + const failed: FailedPR[] = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") - const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) - if (fetch.exitCode !== 0) { - console.log(` Failed to fetch PR head: ${fetch.stderr}`) - skipped.push({ number: pr.number, reason: `Fetch failed: ${fetch.stderr}` }) + try { + await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}` + } catch (err) { + console.log(` Failed to fetch: ${err}`) + failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + await commentOnPR(pr.number, "Fetch failed") continue } console.log(" Merging...") - const merge = await run(["git", "merge", "--no-commit", "--no-ff", `pr/${pr.number}`]) - if (merge.exitCode !== 0) { + try { + await $`git merge --no-commit --no-ff pr/${pr.number}` + } catch { console.log(" Failed to merge (conflicts)") - await $`git merge --abort`.nothrow() - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Has conflicts" }) + try { + await $`git merge --abort` + } catch {} + try { + await $`git checkout -- .` + } catch {} + try { + await $`git clean -fd` + } catch {} + failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + await commentOnPR(pr.number, "Merge conflicts with dev branch") continue } - const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() - if (mergeHead.exitCode !== 0) { + try { + await $`git rev-parse -q --verify MERGE_HEAD`.text() + } catch { console.log(" No changes, skipping") - skipped.push({ number: pr.number, reason: "No changes" }) continue } - const add = await $`git add -A`.nothrow() - if (add.exitCode !== 0) { - console.log(" Failed to stage") - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Failed to stage" }) + try { + await $`git add -A` + } catch { + console.log(" Failed to stage changes") + failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + await commentOnPR(pr.number, "Failed to stage changes") continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` - const commit = await run(["git", "commit", "-m", commitMsg]) - if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: `Commit failed: ${commit.stderr}` }) + try { + await $`git commit -m ${commitMsg}` + } catch (err) { + console.log(` Failed to commit: ${err}`) + failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + await commentOnPR(pr.number, "Failed to commit changes") continue } @@ -92,14 +117,15 @@ async function main() { console.log("\n--- Summary ---") console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) - console.log(`Skipped: ${skipped.length} PRs`) - skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`)) + + if (failed.length > 0) { + console.log(`Failed: ${failed.length} PRs`) + failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) + throw new Error(`${failed.length} PR(s) failed to merge`) + } console.log("\nForce pushing beta branch...") - const push = await $`git push origin beta --force --no-verify`.nothrow() - if (push.exitCode !== 0) { - throw new Error(`Failed to push beta branch: ${push.stderr}`) - } + await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") } @@ -108,31 +134,3 @@ main().catch((err) => { console.error("Error:", err) process.exit(1) }) - -async function run(args: string[], stdin?: Uint8Array): Promise { - const proc = Bun.spawn(args, { - stdin: stdin ?? "inherit", - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } -} - -function $(strings: TemplateStringsArray, ...values: unknown[]) { - const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") - return { - async nothrow() { - const proc = Bun.spawn(cmd.split(" "), { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } - }, - } -} diff --git a/script/changelog.ts b/script/changelog.ts index 0043cd3d62..5fc30a228b 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -3,20 +3,7 @@ import { $ } from "bun" import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" - -export const team = [ - "actions-user", - "opencode", - "rekram1-node", - "thdxr", - "kommander", - "jayair", - "fwang", - "adamdotdevin", - "iamdavidhill", - "opencode-agent[bot]", - "R44VC0RP", -] +import { Script } from "@opencode-ai/script" type Release = { tag_name: string @@ -191,7 +178,7 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited