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/deploy.yml b/.github/workflows/deploy.yml index 25466a63e0..c08d7edf3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,6 +21,15 @@ jobs: with: node-version: "24" + # Workaround for Pulumi version conflict: + # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag + # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065). + # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict. + # Removing the system language plugin forces SST to use its bundled compatible version. + # TODO: Remove when sst supports Pulumi >3.210.0 + - name: Fix Pulumi version conflict + run: sudo rm -f /usr/local/bin/pulumi-language-nodejs + - run: bun sst deploy --stage=${{ github.ref_name }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index cc16d81844..5446f9212f 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -6,13 +6,7 @@ permissions: on: workflow_dispatch: push: - paths: - - "bun.lock" - - "package.json" - - "packages/*/package.json" - - "flake.lock" - - ".github/workflows/nix-hashes.yml" - pull_request: + branches: [dev] paths: - "bun.lock" - "package.json" diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md index 8260029195..d8a420b173 100644 --- a/.opencode/command/commit.md +++ b/.opencode/command/commit.md @@ -1,6 +1,6 @@ --- description: git commit and push -model: opencode/glm-4.7 +model: opencode/kimi-k2.5 subtask: true --- diff --git a/AGENTS.md b/AGENTS.md index 8cfe768da3..3df71f6419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity +- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream ### Avoid let statements 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 b48e4cb0da..6fe8f61d3d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=", - "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=", - "aarch64-darwin": "sha256-jGr2udrVeseioMWpIzpjYFfS1CN8GvNFwo6o92Aa5Oc=", - "x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM=" + "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 80179144a8..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": "إزالة الملف النشط من السياق", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index c874a4376b..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", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 555990a9c4..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", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e56081c908..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", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4254860ac9..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", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index e928f03cec..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", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 31000cd17a..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", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 80efc5c2aa..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": "コンテキストからアクティブなファイルを削除", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 014092d07f..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": "컨텍스트에서 활성 파일 제거", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 400ce37d35..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", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 5a05809829..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", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 4277368f5d..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": "Удалить активный файл из контекста", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e2eabd7ad8..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": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 118e03ce47..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": "从上下文移除活动文件", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 45a789df4c..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": "從上下文移除目前檔案", 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/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/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ada84e487b..209469bad8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1799,9 +1799,19 @@ function Task(props: ToolProps) { const keybind = useKeybind() const { navigate } = useRoute() const local = useLocal() + const sync = useSync() - const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) - const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) + const tools = createMemo(() => { + const sessionID = props.metadata.sessionId + const msgs = sync.data.message[sessionID ?? ""] ?? [] + return msgs.flatMap((msg) => + (sync.data.part[msg.id] ?? []) + .filter((part): part is ToolPart => part.type === "tool") + .map((part) => ({ tool: part.tool, state: part.state })), + ) + }) + + const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending")) return ( @@ -1817,13 +1827,17 @@ function Task(props: ToolProps) { > - {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) + {props.input.description} ({tools().length} toolcalls) - - └ {Locale.titlecase(current()!.tool)}{" "} - {current()!.state.status === "completed" ? current()!.state.title : ""} - + {(item) => { + const title = item().state.status === "completed" ? (item().state as any).title : "" + return ( + + └ {Locale.titlecase(item().tool)} {title} + + ) + }} 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/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 1d832d77a2..f6dd0d122f 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 87cf3a0820..556fad01f5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -332,7 +332,9 @@ export namespace Session { export async function* list() { const project = Instance.project for (const item of await Storage.list(["session", project.id])) { - yield Storage.read(item) + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue + yield session } } @@ -340,7 +342,8 @@ export namespace Session { 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) + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue if (session.parentID !== parentID) continue result.push(session) } 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 2707105618..24b4a4f9fb 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,6 +172,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: { @@ -184,7 +192,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments: value.output.attachments, + attachments, }, }) @@ -360,6 +368,7 @@ export namespace SessionProcessor { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, }) + SessionStatus.set(input.sessionID, { type: "idle" }) } if (snapshot) { const patch = await Snapshot.patch(snapshot) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 94eabdef7f..98dce97ba9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -62,7 +62,7 @@ export namespace SessionPrompt { abort: AbortController callbacks: { resolve(input: MessageV2.WithParts): void - reject(): void + reject(reason?: any): void }[] } > = {} @@ -72,7 +72,7 @@ export namespace SessionPrompt { for (const item of Object.values(current)) { item.abort.abort() for (const callback of item.callbacks) { - callback.reject() + callback.reject(new DOMException("Aborted", "AbortError")) } } }, @@ -187,13 +187,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) @@ -201,33 +205,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 } @@ -246,10 +251,13 @@ export namespace SessionPrompt { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] - if (!match) return + if (!match) { + SessionStatus.set(sessionID, { type: "idle" }) + return + } match.abort.abort() for (const item of match.callbacks) { - item.reject() + item.reject(new DOMException("Aborted", "AbortError")) } delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) @@ -424,6 +432,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: { @@ -432,7 +446,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments: result.attachments, + attachments, time: { ...part.state.time, end: Date.now(), @@ -771,16 +785,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}`, @@ -792,9 +803,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}`, @@ -827,6 +835,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", @@ -836,9 +855,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)) @@ -1032,6 +1051,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, @@ -1169,7 +1189,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/task.ts b/packages/opencode/src/tool/task.ts index ad4268b7b0..ac28d9f322 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -130,7 +130,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.metadata({ title: params.description, metadata: { - summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)), sessionId: session.id, model, }, 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 0f2e2f4a06..b0dfd57dd2 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -219,6 +219,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() @@ -374,7 +381,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" }) @@ -397,7 +404,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" }) } @@ -423,8 +436,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" }) } @@ -450,7 +464,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/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/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index de58f4f85e..ef6271ed5d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -292,7 +292,7 @@ test("unicode filenames", async () => { }) }) -test("unicode filenames modification and restore", async () => { +test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() await Instance.provide({ directory: tmp.path, 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/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e992b27bd5..0556e1ad94 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1371,6 +1371,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 @@ -2136,6 +2140,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 f383ffbba8..95bca32303 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9017,6 +9017,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" }, @@ -10842,6 +10846,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/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/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/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..4355c5879c 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,15 +117,27 @@ 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}`)) - 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}`) + 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("\nChecking if beta branch has changes...") + await $`git fetch origin beta` + + const localTree = await $`git rev-parse beta^{tree}`.text() + const remoteTree = await $`git rev-parse origin/beta^{tree}`.text() + + if (localTree.trim() === remoteTree.trim()) { + console.log("Beta branch has identical contents, no push needed") + return + } + + console.log("Force pushing beta branch...") + await $`git push origin beta --force --no-verify` + console.log("Successfully synced beta branch") } @@ -108,31 +145,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