diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/artifacts/glm52-rise-video/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 561cb5d5196..2f31450ed0f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.16", + "version": "1.17.18", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -157,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -193,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -220,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -242,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -266,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -286,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.16", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -380,7 +380,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -434,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -448,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "effect": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -492,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -508,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -539,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -558,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.16", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -689,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -765,7 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "cross-spawn": "catalog:", }, @@ -780,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,7 +795,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -839,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -852,7 +852,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -885,7 +885,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -904,7 +904,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -945,7 +945,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -972,7 +972,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1023,7 +1023,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/infra/stats.ts b/infra/stats.ts index b5b0e1c600e..10d37119f0d 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - memory: "0.5 GB", + // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena + // stats queries (~$5/pass) every ~5 minutes instead of hourly. + memory: "2 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index f69591f608a..397c53f248e 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => { "opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), + ) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId: sessionID }]), @@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => { await expectSessionTitle(page, title) const panel = page.locator("#review-panel") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -102,9 +109,11 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() await filter.fill("nested") const result = panel.getByRole("option", { name: /nested\.ts/ }) await expect(result).toBeVisible() diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 00000000000..54139cc3711 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/package.json b/packages/app/package.json index b044ef5631e..510e2015dbe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.16", + "version": "1.17.18", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825a..bbb71e87224 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" @@ -1333,6 +1335,7 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { @@ -1704,22 +1707,19 @@ export const PromptInput: Component = (props) => { > } + variant="ghost-muted" + size="large" style={buttons()} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} aria-label={language.t("prompt.menu.addImagesAndFiles")} /> - + {language.t("prompt.menu.imagesAndFiles")} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 9a65fa101dd..ad9fd31db81 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { Prompt } from "@/context/prompt" +import type { ModelSelection } from "@/context/local" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -33,6 +34,10 @@ const prompt = { current: () => promptValue, cursor: () => 0, dirty: () => true, + model: { + current: () => undefined, + set: () => undefined, + }, reset: () => undefined, set: () => undefined, context: { @@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => { }) }) + test("uses an injected model selection", async () => { + params = { id: "session-1" } + const model = { + current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), + variant: { current: () => "draft-variant" }, + } as unknown as ModelSelection + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + model, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(optimistic[0]).toMatchObject({ + message: { + model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, + }, + }) + }) + test("seeds new sessions before optimistic prompts are added", async () => { const submit = createPromptSubmit({ prompt, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947c..c9f560d2692 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, type Accessor } from "solid-js" +import { batch, startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" @@ -191,6 +191,7 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + model?: ModelSelection } export function createPromptSubmit(input: PromptSubmitInput) { @@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - const currentModel = local.model.current() + const modelSelection = input.model ?? local.model + const currentModel = modelSelection.current() const currentAgent = local.agent.current() - const variant = local.model.variant.current() + const variant = modelSelection.variant.current() if (!currentModel || !currentAgent) { showToast({ title: language.t("prompt.toast.modelAgentRequired.title"), @@ -374,13 +376,20 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + await startTransition(() => { + if (!session) return + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + local.session.promote(sessionDirectory, session.id, { + agent: currentAgent.name, + model: { providerID: currentModel.provider.id, modelID: currentModel.id }, + variant: variant ?? null, + }) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + const draftID = search.draftId + if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + }) } } if (!session) { diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index fd2d1dd1a30..c1e0ccde059 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -12,7 +12,7 @@ import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" -import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" +import { getSessionContext } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { useSettings } from "@/context/settings" @@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) @@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 5b11bf14339..37cc0d5b5d2 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message } from "@opencode-ai/sdk/v2/client" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" const assistant = ( id: string, @@ -38,10 +38,10 @@ const user = (id: string) => { } describe("getSessionContext", () => { - test("computes usage from latest assistant with tokens", () => { + test("computes token totals and usage from latest assistant with tokens", () => { const messages = [ user("u1"), - assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5), assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), ] const providers = [ @@ -60,6 +60,8 @@ describe("getSessionContext", () => { const ctx = getSessionContext(messages, providers) expect(ctx?.message.id).toBe("a2") + expect(ctx?.total).toBe(500) + expect(ctx?.input).toBe(300) expect(ctx?.usage).toBe(50) expect(ctx?.providerLabel).toBe("OpenAI") expect(ctx?.modelLabel).toBe("GPT-4.1") @@ -94,15 +96,4 @@ describe("getSessionContext", () => { expect(ctx).toBeUndefined() }) - - test("computes stored session token totals", () => { - expect( - getSessionTokenTotal({ - input: 10, - output: 20, - reasoning: 30, - cache: { read: 40, write: 50 }, - }), - ).toBe(150) - }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 84cc984abfa..30dc9e958b9 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" type Provider = { id: string @@ -21,6 +21,7 @@ type Context = { modelLabel: string limit: number | undefined input: number + total: number usage: number | null } @@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | modelLabel: model?.name ?? message.modelID, limit, input: message.tokens.input, + total, usage: limit ? Math.round((total / limit) * 100) : null, } } @@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } - -export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) { - if (!tokens) return undefined - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write -} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 8757c693678..25ad5ab230c 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -135,7 +135,6 @@ export function SessionContextTab() { ) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -204,14 +203,15 @@ export function SessionContextTab() { { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, { label: "context.stats.cacheTokens", - value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, + value: () => + `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx index f0cdf41ac1f..301783a0dfa 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: { { + // Switch on mousedown to shave the press-release delay off tab switches. + if (e.button !== 0) return + if (store.editing) return + focus() + }} + onClick={(e) => { + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (e.detail > 0) return + focus() + }} closeButton={ span { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8998fc06c89..c5faf85bff3 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,7 +1,8 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" +import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve" +import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types" import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" @@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } +const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => { + let current = tokens[key] + for (let i = 0; i < 8 && current; i++) { + const match = /^var\(--([^)]+)\)$/.exec(current.trim()) + if (!match) { + const hex = current.trim() + if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7) + return hex + } + current = tokens[match[1]] + } +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => { if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background + const background = settings.general.newLayoutDesigns() + ? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ?? + fallback.background) + : (resolved["background-stronger"] ?? fallback.background) const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2a818d565da..994d69a60fb 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" +import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" @@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() const activeSession = session() if (route.type === "session" && activeSession) { - tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") + const sessionTab = { + type: "session" as const, + server: route.server ?? server.key, + sessionId: activeSession.id, + } + const model = tabs.stateValue(sessionTab, "prompt")?.model.current() + tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) return } const activeTab = currentTab() if (activeTab?.type === "draft") { - tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") + const model = tabs.stateValue(activeTab, "prompt")?.model.current() + tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) return } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 415da747e0b..a12fd832fd1 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - const [saved, setSaved] = persisted( + const [saved, setSaved, , savedReady] = persisted( { ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), migrate, @@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { + ready: savedReady, reset() { setStore({ draft: undefined, promoting: undefined }) }, - promote(dir: string, session: string) { - const next = clone(snapshot()) + promote(dir: string, session: string, state?: State) { + const next = clone(state ?? snapshot()) if (!next) return const key = handoffKey(serverSDK().scope, dir, session) handoff.set(key, next) @@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return result }, }) + +export type ModelSelection = ReturnType["model"] diff --git a/packages/app/src/context/prompt-state.test.ts b/packages/app/src/context/prompt-state.test.ts new file mode 100644 index 00000000000..24afe5424e7 --- /dev/null +++ b/packages/app/src/context/prompt-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createPromptState, DEFAULT_PROMPT } from "./prompt-state" + +describe("prompt state initialization", () => { + test("initializes prompt text, cursor, and model together", () => { + createRoot((dispose) => { + const model = { providerID: "anthropic", modelID: "claude", variant: "high" } + const prompt = createPromptState({ prompt: "hello", model }) + + expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + expect(prompt.cursor()).toBe(5) + expect(prompt.model.current()).toEqual(model) + expect(prompt.model.current()).not.toBe(model) + dispose() + }) + }) + + test("uses the default prompt without initial values", () => { + createRoot((dispose) => { + const prompt = createPromptState() + + expect(prompt.current()).toEqual(DEFAULT_PROMPT) + expect(prompt.cursor()).toBeUndefined() + expect(prompt.model.current()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts new file mode 100644 index 00000000000..b6a68be4e76 --- /dev/null +++ b/packages/app/src/context/prompt-state.ts @@ -0,0 +1,265 @@ +import { checksum } from "@opencode-ai/core/util/encode" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { batch, createMemo, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" +import type { ServerScope } from "@/utils/server-scope" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type PromptModel = { + providerID: string + modelID: string + variant?: string | null +} + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem +export type PromptScope = { draftID: string } | { dir: string; id?: string } + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +type PromptStore = { + prompt: Prompt + cursor?: number + model?: PromptModel + context: { + items: (ContextItem & { key: string })[] + } +} + +type InitialPrompt = { + prompt?: string + model?: PromptModel +} + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) return `${key}:c=${item.commentID}` + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions(setStore: SetStoreFunction) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + +function promptTarget(serverScope: ServerScope, scope: PromptScope) { + if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) +} + +function promptStore(initial?: InitialPrompt): PromptStore { + const text = initial?.prompt + return { + prompt: + text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], + cursor: text === undefined ? undefined : text.length, + model: initial?.model ? { ...initial.model } : undefined, + context: { + items: [], + }, + } +} + +function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { + const actions = createPromptActions(setStore) + const value = { + current: () => store.prompt, + cursor: createMemo(() => store.cursor), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + model: { + current: () => store.model, + set: (model: PromptModel | undefined) => setStore("model", model), + }, + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + capture: () => value, + } + return value +} + +function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt) { + const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) + return { ready, ...createPromptStateValue(store, setStore) } +} + +export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { + return createPersistedPrompt(promptTarget(serverScope, scope), initial) +} + +export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { + return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial) +} + +export type PromptSession = ReturnType + +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + +export function createPromptState(initial?: InitialPrompt) { + const [store, setStore] = createStore(promptStore(initial)) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + return { + ready, + ...createPromptStateValue(store, setStore), + } +} diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index c3614935162..0ad8e70f3cd 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,186 +1,49 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { FileSelection } from "@/context/file" -import { Persist, persisted } from "@/utils/persist" +import { createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { requireServerKey } from "@/utils/session-route" +import { ServerConnection } from "./server" import { useServerSDK } from "./server-sdk" -import type { ServerScope } from "@/utils/server-scope" +import { useSettings } from "./settings" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { ServerConnection } from "./server" -import { requireServerKey } from "@/utils/session-route" -import { useSettings } from "./settings" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { + createPromptReady, + createPromptSession, + type ContextItem, + type FileContextItem, + type Prompt, + type PromptModel, + type PromptScope, + type PromptSession, +} from "./prompt-state" -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: FileSelection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export interface AgentPart extends PartBase { - type: "agent" - name: string -} - -export interface ImageAttachmentPart { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - dataUrl: string -} - -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart -export type Prompt = ContentPart[] - -export type FileContextItem = { - type: "file" - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type ContextItem = FileContextItem - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -function isSelectionEqual(a?: FileSelection, b?: FileSelection) { - if (!a && !b) return true - if (!a || !b) return false - return ( - a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar - ) -} - -function isPartEqual(partA: ContentPart, partB: ContentPart) { - switch (partA.type) { - case "text": - return partB.type === "text" && partA.content === partB.content - case "file": - return ( - partB.type === "file" && - partA.path === partB.path && - partA.mime === partB.mime && - partA.filename === partB.filename && - isSelectionEqual(partA.selection, partB.selection) - ) - case "agent": - return partB.type === "agent" && partA.name === partB.name - case "image": - return partB.type === "image" && partA.id === partB.id - } -} - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - if (!isPartEqual(promptA[i], promptB[i])) return false - } - return true -} - -function cloneSelection(selection?: FileSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} - -function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) { - return `${key}:c=${item.commentID}` - } - - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` -} - -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() -} - -function createPromptActions( - setStore: SetStoreFunction<{ - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } - }>, -) { - return { - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, - } -} +export { + createPromptReady, + createPromptSession, + createPromptState, + DEFAULT_PROMPT, + isPromptEqual, +} from "./prompt-state" +export type { + AgentPart, + ContentPart, + ContextItem, + FileAttachmentPart, + FileContextItem, + ImageAttachmentPart, + Prompt, + PromptModel, + PromptScope, + PromptSession, + TextPart, +} from "./prompt-state" const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 -type PromptSession = ReturnType - -type PromptStore = { - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } -} - -type Scope = { draftID: string } | { dir: string; id?: string } - -export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) { +export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) { if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) if (!scope.id) return return ( @@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect ) } -function scopeKey(scope: Scope) { +function scopeKey(scope: PromptScope) { if ("draftID" in scope) return `draft:${scope.draftID}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` } @@ -199,91 +62,6 @@ type PromptCacheEntry = { dispose: VoidFunction } -function promptTarget(serverScope: ServerScope, scope: Scope) { - if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) -} - -export function createPromptSession(serverScope: ServerScope, scope: Scope) { - const [store, setStore, _, ready] = persisted( - promptTarget(serverScope, scope), - createStore(promptStore()), - ) - - return { ready, ...createPromptStateValue(store, setStore) } -} - -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - -function promptStore(): PromptStore { - return { - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - context: { - items: [], - }, - } -} - -function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { - const actions = createPromptActions(setStore) - - const value = { - current: () => store.prompt, - cursor: createMemo(() => store.cursor), - dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), - context: { - items: createMemo(() => store.context.items), - add(item: ContextItem) { - const key = contextItemKey(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - removeComment(path: string, commentID: string) { - setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), - ) - }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { - setStore("context", "items", (items) => - items.map((item) => { - if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } - }), - ) - }, - replaceComments(items: FileContextItem[]) { - setStore("context", "items", (current) => [ - ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), - ]) - }, - }, - set: actions.set, - reset: actions.reset, - capture: () => value, - } - return value -} - -export function createPromptState() { - const [store, setStore] = createStore(promptStore()) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) - return { - ready, - ...createPromptStateValue(store, setStore), - } -} - export const createTabPromptState = ( tabs: ReturnType, tab: Tab, @@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + for (const entry of cache.values()) entry.dispose() cache.clear() } @@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const owner = getOwner() const serverKey = () => params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) - const scope = () => + const scope = (): PromptScope => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: Scope) => { + const load = (scope: PromptScope) => { const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) { - return createTabPromptState(tabs, current, serverSDK().scope, scope) - } + if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) const key = scopeKey(scope) const existing = cache.get(key) @@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: Scope) => (scope ? load(scope) : session()) + const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) return { ready, - capture: (scope?: Scope) => pick(scope).capture(), + capture: (scope?: PromptScope) => pick(scope).capture(), current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), + model: { + current: () => session().model.current(), + set: (model: PromptModel | undefined) => session().model.set(model), + }, context: { items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), @@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( session().context.updateComment(path, commentID, next), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, - set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), - reset: (scope?: Scope) => pick(scope).reset(), + set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition), + reset: (scope?: PromptScope) => pick(scope).reset(), } }, }) diff --git a/packages/app/src/context/tab-memory.ts b/packages/app/src/context/tab-memory.ts index de8050f3797..70bb987c7bb 100644 --- a/packages/app/src/context/tab-memory.ts +++ b/packages/app/src/context/tab-memory.ts @@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) { } return { + get(key: string, name: string) { + return entries.get(key)?.get(name)?.value as T | undefined + }, ensure(key: string, name: string, init: () => T) { const state = entries.get(key) ?? new Map() if (!entries.has(key)) entries.set(key, state) diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b4bff4caff2..b56ea2dfcf9 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -22,6 +22,8 @@ describe("tab memory", () => { }) expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) + expect(memory.get("tab", "prompt")).toBe(first) + expect(memory.get("missing", "prompt")).toBeUndefined() expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) memory.remove("tab") diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 47ed77e781e..cac6500350a 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { createDraftPromptSession, type PromptModel } from "./prompt-state" export type SessionTab = { type: "session" @@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, - newDraft(draft: Omit, prompt?: string) { + newDraft(draft: Omit, prompt?: string, model?: PromptModel) { const draftID = uuid() + const tab = { type: "draft" as const, draftID, ...draft } + memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) void startTransition(() => { setStore( produce((tabs) => { - tabs.push({ type: "draft", draftID, ...draft }) + tabs.push(tab) }), ) - navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + navigate(draftHref(draftID)) }) }, updateDraft(draftID: string, draft: Partial>) { @@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ state(tab: Tab, name: string, init: () => T) { return memory.ensure(tabKey(tab), name, init) }, + stateValue(tab: Tab, name: string) { + return memory.get(tabKey(tab), name) + }, } return { ...actions, store, info, ready, recentReady } diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index f557dee835b..4bc8eb2ecb4 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,10 +26,13 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" +import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" import { useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" +import { useLocal } from "@/context/local" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -48,13 +51,16 @@ export default function NewSessionPage() { const comments = useComments() const language = useLanguage() const settings = useSettings() + const command = useCommand() const providers = useProviders(() => sdk().directory) const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() const isDesktop = createMediaQuery("(min-width: 768px)") const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const local = useLocal() + const model = createPromptModelSelection({ agent: local.agent.current }) - useComposerCommands() + useComposerCommands({ model }) let inputRef: HTMLDivElement | undefined @@ -62,6 +68,7 @@ export default function NewSessionPage() { sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, + model, }) const projectControls = createPromptProjectControls() const projectController = createPromptProjectController({ @@ -69,6 +76,16 @@ export default function NewSessionPage() { onDone: () => inputRef?.focus(), }) + command.register("new-session", () => [ + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: () => inputRef?.focus(), + }, + ]) + const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cbf47dc8498..66afcfa9bf2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { clampSessionPanelWidth, SESSION_PANEL_WIDTH_MIN, @@ -483,7 +483,7 @@ export default function Page() { if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen())) const desktopV2PanelLayout = createMemo(() => sessionPanelLayout({ review: desktopV2ReviewOpen(), @@ -557,6 +557,17 @@ export default function Page() { ), ) + let restoredModelSession: string | undefined + createEffect(() => { + const id = params.id + if (!id || !prompt.ready() || !local.session.ready()) return + if (restoredModelSession !== id) { + restoredModelSession = id + if (restorePromptModel(local, prompt)) return + } + syncPromptModel(local, prompt) + }) + createEffect( on( () => ({ dir: sdk().directory, id: params.id }), @@ -1304,7 +1315,7 @@ export default function Page() { const reviewPanelV2Rendered = createMemo((prev) => prev || !store.deferRender, false) const reviewPanelV2 = () => ( -
+
diff --git a/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app/src/pages/session/composer/prompt-model-selection.ts new file mode 100644 index 00000000000..3f5bb0f477e --- /dev/null +++ b/packages/app/src/pages/session/composer/prompt-model-selection.ts @@ -0,0 +1,133 @@ +import { batch, createMemo, startTransition } from "solid-js" +import { useModels } from "@/context/models" +import type { ModelKey, ModelSelection } from "@/context/local" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useProviders } from "@/hooks/use-providers" + +export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) { + const sdk = useSDK() + const sync = useSync() + const models = useModels() + const prompt = usePrompt() + const providers = useProviders(() => sdk().directory) + const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) + + const valid = (model: ModelKey) => { + const provider = providers.all().get(model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) + } + + const configured = () => { + const value = sync().data.config.model + if (!value) return + const [providerID, modelID] = value.split("/") + const model = { providerID, modelID } + if (valid(model)) return model + } + + const recent = () => models.recent.list().find(valid) + const fallback = () => { + const defaults = providers.default() + return providers.connected().flatMap((provider) => { + const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id + return modelID ? [{ providerID: provider.id, modelID }] : [] + })[0] + } + + const current = () => { + const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find( + (item): item is ModelKey => !!item && valid(item), + ) + if (!key) return + return models.find(key) + } + const recentModels = createMemo(() => + models.recent + .list() + .map(models.find) + .filter((item): item is NonNullable => !!item), + ) + + const selection = { + ready: models.ready, + current, + recent: recentModels, + list: models.list, + cycle(direction: 1 | -1) { + const items = recentModels() + const item = current() + if (!item) return + const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id) + if (index === -1) return + const next = items[(index + direction + items.length) % items.length] + if (next) selection.set({ providerID: next.provider.id, modelID: next.id }) + }, + set(item: ModelKey | undefined, options?: { recent?: boolean }) { + startTransition(() => + batch(() => { + prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined) + if (!item) return + models.setVisibility(item, true) + if (options?.recent) models.recent.push(item) + }), + ) + }, + visible: models.visible, + setVisibility: models.setVisibility, + variant: { + configured() { + const item = input.agent() + const model = current() + if (!item || !model) return + return getConfiguredAgentVariant({ + agent: { model: item.model, variant: item.variant }, + model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, + }) + }, + selected() { + return prompt.model.current()?.variant + }, + current() { + const resolved = resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved + }, + list() { + return Object.keys(current()?.variants ?? {}) + }, + set(value: string | undefined) { + startTransition(() => + batch(() => { + const model = current() + if (!model) return + prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null }) + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value) + }), + ) + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + this.set( + cycleModelVariant({ + variants, + selected: this.selected(), + configured: this.configured(), + }), + ) + }, + }, + } satisfies ModelSelection + + return selection +} diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index c7f2304531a..8d5d96719c0 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" @@ -22,6 +22,7 @@ export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor queryOptions: Pick + model?: ModelSelection }) { const layout = useLayout() const local = useLocal() @@ -44,7 +45,7 @@ export function createPromptInputController(input: { select: local.agent.set, }, model: { - selection: local.model, + selection: input.model ?? local.model, paid: providers.paid().length > 0, loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, }, diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 2ab293b8fb3..201627b3691 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { resetSessionModel, syncSessionModel } from "./session-model-helpers" +import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" const message = (input?: { agent?: string; model?: UserMessage["model"] }) => ({ @@ -50,3 +50,102 @@ describe("resetSessionModel", () => { expect(calls).toEqual(["reset"]) }) }) + +describe("syncPromptModel", () => { + test("stores the effective session model in prompt state", () => { + const calls: unknown[] = [] + + syncPromptModel( + { + model: { + current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }), + set() {}, + variant: { current: () => "high", set() {} }, + }, + }, + { + model: { + current: () => undefined, + set: (model) => calls.push(model), + }, + }, + ) + + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }]) + }) + + test("does not rewrite an unchanged prompt model", () => { + const calls: unknown[] = [] + const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } + + syncPromptModel( + { + model: { + current: () => ({ id: model.modelID, provider: { id: model.providerID } }), + set() {}, + variant: { current: () => model.variant, set() {} }, + }, + }, + { + model: { + current: () => model, + set: (value) => calls.push(value), + }, + }, + ) + + expect(calls).toEqual([]) + }) +}) + +describe("restorePromptModel", () => { + test("restores the persisted prompt model into session selection", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }), + set() {}, + }, + }, + ) + + expect(restored).toBe(true) + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"]) + }) + + test("does nothing without a persisted prompt model", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => undefined, + set() {}, + }, + }, + ) + + expect(restored).toBe(false) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts index c9e2e1dbd2a..f2aadff27d8 100644 --- a/packages/app/src/pages/session/session-model-helpers.ts +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -7,6 +7,24 @@ type Local = { } } +type ModelSelection = { + model: { + current(): { id: string; provider: { id: string } } | undefined + set(model: { providerID: string; modelID: string }): void + variant: { + current(): string | undefined + set(variant: string | undefined): void + } + } +} + +type PromptState = { + model: { + current(): { providerID: string; modelID: string; variant?: string | null } | undefined + set(model: { providerID: string; modelID: string; variant?: string | null }): void + } +} + export const resetSessionModel = (local: Local) => { local.session.reset() } @@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => { export const syncSessionModel = (local: Local, msg: UserMessage) => { local.session.restore(msg) } + +export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = local.model.current() + if (!model) return + const next = { + providerID: model.provider.id, + modelID: model.id, + variant: local.model.variant.current(), + } + const current = prompt.model.current() + if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant) + return + prompt.model.set(next) +} + +export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = prompt.model.current() + if (!model) return false + const current = local.model.current() + if ( + current?.provider.id === model.providerID && + current.id === model.modelID && + local.model.variant.current() === (model.variant ?? undefined) + ) + return true + local.model.set({ providerID: model.providerID, modelID: model.modelID }) + local.model.variant.set(model.variant ?? undefined) + return true +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 5e5f89e3e22..e009177682a 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -255,8 +255,10 @@ export function SessionSidePanel(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!open()} inert={!open()} - class="relative min-w-0 flex overflow-hidden bg-background-base" + class="relative min-w-0 flex overflow-hidden" classList={{ + "bg-v2-background-bg-base": settings.general.newLayoutDesigns(), + "bg-background-base": !settings.general.newLayoutDesigns(), "h-full shrink-0": !props.stacked, "h-full min-h-0": props.stacked, "pointer-events-none": !open(), @@ -275,8 +277,20 @@ export function SessionSidePanel(props: { }} > -
-
+
+
-
+
-
+
{(title) => (
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index d6cc7be972d..a7390b6d61c 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1243,12 +1243,13 @@ export function MessageTimeline(props: { const initialRow = timelineRowByKey().get(props.rowKey)! const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) - const asyncFile = () => { + const tool = () => { const value = row() - if (value._tag !== "AssistantPart" || value.group.type !== "part") return false + if (value._tag !== "AssistantPart" || value.group.type !== "part") return const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + if (part?.type === "tool") return part } + const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "") const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined @@ -1278,6 +1279,8 @@ export function MessageTimeline(props: { width: "100%", height: `${item().size}px`, overflow: "clip", + // Rounded virtual measurements can otherwise clip a framed row's outer paint. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px", }} >
{ }) } -export const useComposerCommands = () => { +export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const command = useCommand() const dialog = useDialog() const language = useLanguage() @@ -22,6 +22,7 @@ export const useComposerCommands = () => { const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) + const model = input.model ?? local.model const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) @@ -43,7 +44,7 @@ export const useComposerCommands = () => { } const { DialogSelectModel } = await import("@/components/dialog-select-model") owner.run(() => { - void dialog.show(() => , restoreComposer) + void dialog.show(() => , restoreComposer) }) } @@ -61,7 +62,7 @@ export const useComposerCommands = () => { title: language.t("command.model.variant.cycle"), description: language.t("command.model.variant.cycle.description"), keybind: "shift+mod+d", - onSelect: () => local.model.variant.cycle(), + onSelect: () => model.variant.cycle(), }), agentCommand({ id: "agent.cycle", diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 597ebd3f7ed..9cbdd40df1f 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -46,6 +46,7 @@ export function SessionFileBrowserTab(props: { const resultsID = `session-file-browser-results-${createUniqueId()}` const [filter, setFilter] = createSignal("") const [explicitHighlight, setExplicitHighlight] = createSignal() + const sidebarOpened = () => props.placeholder || props.state.sidebarOpened() const query = createMemo(() => filter().trim()) const search = createQuery(() => { const value = query() @@ -98,15 +99,15 @@ export function SessionFileBrowserTab(props: { toolbar toolbarStart={ <> - - + + {title()} } sidebar={ {title()}} filter={filter()} onFilterChange={setFilter} diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 671b798f611..a2daae4866b 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage { } } -const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"] export function draftPersistedKeys() { return DRAFT_PERSISTED_KEYS diff --git a/packages/cli/package.json b/packages/cli/package.json index d3b9981d1af..dd660aa2eec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 49bd6edac51..263daa6a533 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.16", + "version": "1.17.18", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/codemode/sst-env.d.ts b/packages/codemode/sst-env.d.ts new file mode 100644 index 00000000000..64441936d7a --- /dev/null +++ b/packages/codemode/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/app/package.json b/packages/console/app/package.json index aa55ac720f1..35d31f93429 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 0e9992a2897..cebc78a127a 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -4,6 +4,7 @@ type Usage = { input_tokens?: number input_tokens_details?: { cached_tokens?: number + cache_write_tokens?: number } output_tokens?: number output_tokens_details?: { @@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const outputTokens = usage.output_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined + const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined return { inputTokens: inputTokens - (cacheReadTokens ?? 0), outputTokens, reasoningTokens, cacheReadTokens, - cacheWrite5mTokens: undefined, + cacheWrite5mTokens: cacheWriteTokens, cacheWrite1hTokens: undefined, } }, diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts index d39c9fa8617..7be3aacffdd 100644 --- a/packages/console/app/test/providerUsage.test.ts +++ b/packages/console/app/test/providerUsage.test.ts @@ -65,4 +65,20 @@ describe("provider usage extraction", () => { output_tokens: 7, }) }) + + test("parses OpenAI stream cache write usage", () => { + const usageParser = providers.openai.createUsageParser() + usageParser.parse( + 'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}', + ) + + expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ + inputTokens: 6, + outputTokens: 2, + reasoningTokens: undefined, + cacheReadTokens: 4, + cacheWrite5mTokens: 3, + cacheWrite1hTokens: undefined, + }) + }) }) diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c009a35a80b..ffc7a756d24 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 69904bea906..47ae1efd7a8 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.16", + "version": "1.17.18", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 31952653942..d0f16d771f8 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.16", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index ef29aa40646..159a59722b5 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 9fd15d5ea9f..faac8186902 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e12ad568b98..ece963cbc21 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index e6dde2a1f86..6aebc6f41d5 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 100571826df..117aab177fe 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 80aa77e4901..ea959c4e4f0 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index eee6e3dcd54..282076fa652 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.16", + "version": "1.17.18", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 13c7e04eb14..534ec4bc0ff 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 41cecee3c55..91103fd4d09 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 09c9b36b9ce..89574054c37 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.18", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6a9af10040b..95ffac7ea51 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -207,7 +207,11 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - const worker = new Worker(file) + const worker = new Worker(file, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) const client = Rpc.client(worker) const reload = () => { client.call("reload", undefined).catch(() => {}) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 4e571103a5b..f3f32a0b0b9 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -99,7 +99,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): : undefined const prices = remote.billing?.token_prices // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. - const usdPerMillion = prices ? 10_000 / prices.batch_size : 0 + const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 const model: CopilotModel = { id: key, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c979fc44659..c0a222d649b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -207,6 +207,13 @@ function custom(dep: CustomDep): Record { }, options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT }, }), + meta: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + }), xai: () => Effect.succeed({ autoload: false, @@ -1064,11 +1071,17 @@ export type ConfigProvidersResult = Types.DeepMutable { - if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined - if (typeof value === "bigint") return value.toString() - return value - }), + JSON.stringify( + { + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))), + }, + (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }, + ), ) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c14587ba8a4..20c73d02167 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -884,6 +884,18 @@ export function variants(model: Provider.Model): Record [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + } // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai const efforts = openaiReasoningEfforts(model.api.id, model.release_date) return Object.fromEntries( @@ -1128,11 +1140,20 @@ export function options(input: { if ( input.providerOptions?.setCacheKey !== false && - (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/xai" || input.providerOptions?.setCacheKey) + (input.model.providerID === "openai" || + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/xai" || + input.providerOptions?.setCacheKey) ) { result["promptCacheKey"] = input.sessionID } + if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { + result["reasoningEffort"] = "high" + result["reasoningSummary"] = "auto" + result["include"] = INCLUDE_ENCRYPTED_REASONING + } + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { if (input.model.capabilities.reasoning) { result["thinkingConfig"] = { diff --git a/packages/opencode/src/session/prompt/meta.txt b/packages/opencode/src/session/prompt/meta.txt new file mode 100644 index 00000000000..8f0c926c03b --- /dev/null +++ b/packages/opencode/src/session/prompt/meta.txt @@ -0,0 +1,76 @@ +You are OpenCode, the best coding agent on the planet. +You are based on a large language model trained by Meta MSL named Muse Spark. +When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name. +You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. +If the user asks for help or wants to give feedback inform them of the following: +- ctrl+p to list available actions +- To give feedback, users should report the issue at + https://github.com/anomalyco/opencode +When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs +# Tone and style +- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. +- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. +# Professional objectivity +Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. +# Task Management +You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. +These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. +It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. +Examples: + +user: Run the build and fix any type errors +assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: +- Run the build +- Fix any type errors +I'm now going to run the build using Bash. +Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. +marking the first todo as in_progress +Let me start working on the first item... +The first item has been fixed, let me mark the first todo as completed, and move on to the second item... +.. +.. + +In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. + +user: Help me write a new feature that allows users to track their usage metrics and export them to various formats +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. +Adding the following todos to the todo list: +1. Research existing metrics tracking in the codebase +2. Design the metrics collection system +3. Implement core metrics tracking functionality +4. Create export functionality for different formats +Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. +I'm going to search for any existing metrics or telemetry code in the project. +I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... +[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] + +# Doing tasks +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: +- Use the TodoWrite tool to plan the task if required +- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. +# Tool usage policy +- When doing file search, prefer to use the Task tool in order to reduce context usage. +- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. +- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. +- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. +- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. +- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. + +user: Where are errors from the client handled? +assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] + + +user: What is the codebase structure? +assistant: [Uses the Task tool] + +IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. +# Code References +When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +user: Where are errors from the client handled? +assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. + diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 6e5b83ec31f..b94e7942658 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -9,6 +9,7 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_GPT from "./prompt/gpt.txt" import PROMPT_KIMI from "./prompt/kimi.txt" +import PROMPT_META from "./prompt/meta.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" @@ -24,6 +25,7 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" export function provider(model: Provider.Model) { + if (model.api.id.includes("muse-spark")) return [PROMPT_META] if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) return [PROMPT_BEAST] if (model.api.id.includes("gpt")) { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904bf..2b296980869 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -16,6 +16,12 @@ describe("tui thread", () => { expect(source).not.toContain('import("./app")') }) + test("forwards the CLI environment to the TUI worker", async () => { + const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() + + expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/) + }) + async function check(project?: string) { await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 35bfaa6cb41..e6d89fe01cc 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,6 +187,60 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) +test("uses zero cost when Copilot reports a zero billing batch size", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "mercury-alpha", + name: "Mercury Alpha", + version: "mercury-alpha-2026-07-09", + billing: { + token_prices: { + batch_size: 0, + default: { + input_price: 0, + output_price: 0, + cache_price: 0, + }, + }, + }, + capabilities: { + family: "mercury", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"] + + expect(model.cost).toEqual({ + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }) + expect(JSON.stringify(model)).not.toContain("null") +}) + test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => { globalThis.fetch = mock(() => Promise.resolve( diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 18ec8f9fbee..c27877c1f7b 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1426,6 +1426,32 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("public provider info omits invalid models", () => { + const provider = Provider.fromModelsDevProvider({ + id: "test", + name: "Test", + env: [], + models: { + valid: { + id: "valid", + name: "Valid", + cost: { input: 1, output: 1 }, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + provider.models.invalid = { + ...provider.models.valid, + id: ModelV2.ID.make("invalid"), + cost: { ...provider.models.valid.cost, input: Number.NaN }, + } + + const result = Provider.toPublicInfo(provider) + + expect(result.models.valid).toBeDefined() + expect(result.models.invalid).toBeUndefined() +}) + it.instance("model variants are generated for reasoning models", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 1484bfd631a..d9d47be3df9 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,10 +1,11 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" +import type { Provider } from "../../src/provider/provider" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" import { testEffect } from "../lib/effect" @@ -83,6 +84,12 @@ const it = testEffect( ) describe("session.system", () => { + test("selects the Meta prompt for Muse Spark model IDs", () => { + expect(SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0]).toContain( + "Meta Muse Spark", + ) + }) + it.effect("skills output is sorted by name and stable across calls", () => Effect.gen(function* () { const prompt = yield* SystemPrompt.Service diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2a09f499607..d93419770e7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f2a5708b2c0..81e184968f1 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 676241d1c3b..9872b789c3e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1734449d1d6..cf3be61f365 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index c42fd20d3b2..b116ebb43f2 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -4,7 +4,7 @@ height: 100%; min-height: 0; overflow: hidden; - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2"] [data-slot="session-review-v2-body"] { @@ -31,7 +31,7 @@ min-height: 0; overflow: hidden; border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { diff --git a/packages/slack/package.json b/packages/slack/package.json index ebef44433f8..11bb354f0d3 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 811123b5e0b..f1def44cecb 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 47e55ec3066..67333665308 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 50c356afa56..96a48d37103 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -113,32 +113,32 @@ const poll: ( return yield* poll(client, queryExecutionId, attempt + 1) }) -const results: ( - client: AwsAthenaClient, - queryExecutionId: string, - nextToken?: string, -) => Effect.Effect = Effect.fn("Athena.results")(function* ( - client: AwsAthenaClient, - queryExecutionId: string, - nextToken?: string, -) { - const result = yield* Effect.tryPromise({ - try: () => - client.send( - new GetQueryResultsCommand({ - QueryExecutionId: queryExecutionId, - NextToken: nextToken, - MaxResults: ATHENA_PAGE_SIZE, - }), - ), - catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), +const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect = + Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) { + // Accumulate pages iteratively; recursive spreads copied every previously + // fetched row per page and blew up memory on large result sets. + const rows: AthenaData[] = [] + let nextToken: string | undefined + while (true) { + const result = yield* Effect.tryPromise({ + try: () => + client.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + MaxResults: ATHENA_PAGE_SIZE, + }), + ), + catch: (cause) => + new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), + }) + const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] + // The first page starts with the header row. + for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) + if (!result.NextToken) return rows + nextToken = result.NextToken + } }) - const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] - const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row)) - - if (!result.NextToken) return rows - return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))] -}) function rowData(columns: string[], row: Row): AthenaData { return Object.fromEntries( diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index c1f0f7045c1..a4d44b874d8 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index cd853f05b5f..8523c2fd684 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.16", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index f521ec861ad..26ffadbcf6d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.16", + "version": "1.17.18", "type": "module", "license": "MIT", "repository": { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index fe450da85a5..cd26b0cd506 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -679,3 +679,22 @@ [data-component="tabs-drag-preview"] > * { position: relative; } + +body[data-new-layout] #review-panel [data-component="tabs"], +body[data-new-layout] #terminal-panel [data-component="tabs"], +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"], +body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--v2-background-bg-base); + + [data-slot="tabs-list"] { + background-color: var(--v2-background-bg-base); + + > .sticky { + background-color: var(--v2-background-bg-base); + + &::before { + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + } + } +} diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index d8b53395751..a90a101a081 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -112,8 +112,8 @@ color: var(--v2-text-text-base); } -[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } @@ -122,6 +122,10 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { opacity: 0.5; cursor: not-allowed; @@ -133,8 +137,8 @@ color: var(--v2-icon-icon-muted); } -[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 0428ca19cb7..31be02ae282 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -6,6 +6,7 @@ [data-component="line-comment-v2"] { box-sizing: border-box; + font-family: var(--v2-font-family-sans); font-variant-numeric: tabular-nums; min-width: 0; width: 100%; diff --git a/packages/ui/src/v2/components/select-v2.css b/packages/ui/src/v2/components/select-v2.css index 38562b2ea6a..c22a9d901ba 100644 --- a/packages/ui/src/v2/components/select-v2.css +++ b/packages/ui/src/v2/components/select-v2.css @@ -65,6 +65,7 @@ box-shadow: var(--v2-elevation-button-neutral); flex: none; align-self: stretch; + user-select: none; transition: background 85ms ease-out, outline-color 85ms ease-out, diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 8582bbf678c..70abf78606a 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -192,6 +192,7 @@ color: var(--v2-text-text-muted); font-size: 12px; font-weight: 500; + user-select: none; } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] { diff --git a/packages/web/package.json b/packages/web/package.json index c65c9c12eaf..2782f6a3976 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.17.16", + "version": "1.17.18", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 28dbe0feb6d..52ba1595cc1 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.17.16", + "version": "1.17.18", "publisher": "sst-dev", "repository": { "type": "git",