Compare commits

..

28 Commits

Author SHA1 Message Date
Aiden Cline
e6a49ed85c rm messages 2026-02-20 14:02:02 -06:00
Aiden Cline
77cdfcdb64 feat: add api shape field to allow distinction between sdks 2026-02-20 13:59:31 -06:00
Aiden Cline
950df3de19 ci: temporarily disable assigning of issues to rekram1-node (#14486) 2026-02-20 13:56:29 -06:00
Aiden Cline
1d9f05e4f5 cache platform binary in postinstall for faster startup (#14467) 2026-02-20 12:19:17 -06:00
Adam
46361cf35c fix(app): session review re-rendering too aggressively 2026-02-20 11:11:48 -06:00
Adam
c09d3dd5a7 chore: cleanup 2026-02-20 10:54:17 -06:00
Adam
fe89bedfcc wip(app): custom scroll view 2026-02-20 10:54:17 -06:00
Frank
1e48d7fe82 zen: gpt safety_identifier 2026-02-20 11:28:19 -05:00
Adam
2a904ec56f feat(app): show/hide reasoning summaries 2026-02-20 10:05:09 -06:00
Adam
0ce61c817b fix(app): stay pinned with auto-scroll on todos/questions/perms 2026-02-20 10:00:56 -06:00
Aiden Cline
1ffed2fa6c Revert "cache platform binary in postinstall for faster startup" (#14457) 2026-02-20 09:28:49 -06:00
Aiden Cline
c79f1a72d8 cache platform binary in postinstall for faster startup (#14396) 2026-02-20 09:26:13 -06:00
Adam
9c5bbba6ea fix(app): patch tool renders like edit tool 2026-02-20 09:13:17 -06:00
Brendan Allan
ce17f9dd94 desktop: publish betas to separate repo (#14376) 2026-02-20 22:33:21 +08:00
Brendan Allan
92ab4217c2 desktop: bring back -i in sidecar arguments
shell configs like .zshrc don't get loaded without it
2026-02-20 22:03:23 +08:00
opencode-agent[bot]
7867ba441f chore: generate 2026-02-20 13:46:03 +00:00
Ryan Vogel
7419ebc872 feat: add list sessions for all sessions (experimental) (#14038) 2026-02-20 08:45:12 -05:00
Adam
7e681b0bc0 fix(app): large text pasted into prompt-input causes main thread lock 2026-02-20 07:38:22 -06:00
Adam
4e9ef3ecc1 fix(app): terminal issues (#14435) 2026-02-20 07:34:36 -06:00
Adam
7e0e35af3f chore: update agent 2026-02-20 07:29:02 -06:00
Matt Silverlock
2410593023 fix(github): support variant in github action and opencode github run (#14431) 2026-02-20 13:20:54 +00:00
Shoubhit Dash
1de12604cf fix(ui): preserve url slashes for root workspace (#14294) 2026-02-20 07:02:48 -06:00
Shoubhit Dash
ac0b37a7b7 fix(snapshot): respect info exclude in snapshot staging (#13495) 2026-02-20 07:02:25 -06:00
Shoubhit Dash
7e1051af07 fix(ui): show full turn duration in assistant meta (#14378) 2026-02-20 07:01:13 -06:00
Matt Silverlock
93615bef28 fix(cli): missing plugin deps cause TUI to black screen (#14432) 2026-02-20 07:39:15 -05:00
Adam
a04e4e81fb chore: cleanup 2026-02-20 06:30:19 -06:00
opencode
296250f1b7 release: v1.2.10 2026-02-20 11:18:40 +00:00
Brendan Allan
443214871e sdk: build to dist/ instead of dist/src (#14383) 2026-02-20 14:10:38 +08:00
82 changed files with 1918 additions and 646 deletions

View File

@@ -41,6 +41,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
if: inputs.bump || inputs.version
run: bun i -g opencode-ai
@@ -49,14 +56,16 @@ jobs:
run: |
./script/version.ts
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
outputs:
version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }}
tag: ${{ steps.version.outputs.tag }}
repo: ${{ steps.version.outputs.repo }}
build-cli:
needs: version
@@ -69,6 +78,13 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build
id: build
run: |
@@ -76,7 +92,8 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@v4
with:
@@ -189,6 +206,13 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
@@ -196,14 +220,16 @@ jobs:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -280,4 +306,5 @@ jobs:
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ target
opencode-dev
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
model: opencode/gemini-3.1-pro
---
You are a professional translator and localization specialist.

View File

@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -68,7 +73,8 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,3 +4,5 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -25,7 +25,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -75,7 +75,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -109,7 +109,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -136,7 +136,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -160,7 +160,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -184,7 +184,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -217,7 +217,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -246,7 +246,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -262,7 +262,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.9",
"version": "1.2.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -376,7 +376,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -396,7 +396,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.9",
"version": "1.2.10",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -407,7 +407,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -420,7 +420,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -462,7 +462,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"zod": "catalog:",
},
@@ -473,7 +473,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -30,6 +30,10 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
variant:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false
oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false
@@ -71,4 +75,5 @@ runs:
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}

View File

@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".session-scroller").first()
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })

View File

@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)

View File

@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await gotoSession()
const terminals = page.locator(terminalSelector)
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
const opened = await terminals.first().isVisible()
if (!opened) {
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.locator(promptSelector).click()
await page.keyboard.press("Control+Alt+T")
await expect(terminals).toHaveCount(2)
await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await expect(terminals.first().locator("textarea")).toHaveCount(1)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.9",
"version": "1.2.10",
"description": "",
"type": "module",
"exports": {

View File

@@ -89,6 +89,8 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = ""
const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
let content = buffer
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
buffer = ""
if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
const rawText =
rawParts.length === 1 && rawParts[0]?.type === "text"
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
if (shouldReset) {
closePopover()
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
if (part.type === "image") return false
const cursorPosition = getCursorPosition(editorRef)
const currentPrompt = prompt.current()
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
const selection = window.getSelection()
if (!selection) return false
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
editorRef.focus()
const cursor = prompt.cursor() ?? promptLength(prompt.current())
setCursorPosition(editorRef, cursor)
}
if (selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return false
if (part.type === "file" || part.type === "agent") {
const cursorPosition = getCursorPosition(editorRef)
const rawText = prompt
.current()
.map((p) => ("content" in p ? p.content : ""))
.join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
const pill = createPill(part)
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "text") {
const range = selection.getRangeAt(0)
}
if (part.type === "text") {
const fragment = createTextFragment(part.content)
const last = fragment.lastChild
range.deleteContents()
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
handleInput()
closePopover()
return true
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {

View File

@@ -7,6 +7,19 @@ import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
@@ -14,7 +27,7 @@ type PromptAttachmentsInput = {
isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => void
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
}
@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
if (!plainText) return
if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return

View File

@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("createTextFragment avoids break-node explosion for large multiline content", () => {
const content = Array.from({ length: 220 }, () => "line").join("\n")
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(1)
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
expect(container.textContent).toBe(content)
})
test("createTextFragment keeps terminal break in large multiline fallback", () => {
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
const fragment = createTextFragment(content)
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("ab\u200B"))

View File

@@ -1,5 +1,20 @@
const MAX_BREAKS = 200
export function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
let breaks = 0
for (const char of content) {
if (char !== "\n") continue
breaks += 1
if (breaks > MAX_BREAKS) {
const tail = content.endsWith("\n")
const text = tail ? content.slice(0, -1) : content
if (text) fragment.appendChild(document.createTextNode(text))
if (tail) fragment.appendChild(document.createElement("br"))
return fragment
}
}
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {

View File

@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
@@ -268,9 +269,9 @@ export function SessionContextTab() {
})
return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
ref={(el) => {
<ScrollView
class="@container h-full pb-10"
viewportRef={(el) => {
scroll = el
restoreScroll()
}}
@@ -336,6 +337,6 @@ export function SessionContextTab() {
</Accordion>
</div>
</div>
</div>
</ScrollView>
)
}

View File

@@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)

View File

@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })

View File

@@ -22,6 +22,7 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
showReasoningSummaries: boolean
}
updates: {
startup: boolean
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
showReasoningSummaries: false,
},
updates: {
startup: true,
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
),
setShowReasoningSummaries(value: boolean) {
setStore("general", "showReasoningSummaries", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

View File

@@ -610,6 +610,8 @@ export const dict = {
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

View File

@@ -943,15 +943,12 @@ export default function Page() {
if (next === dockHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
const delta = next - dockHeight
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
dockHeight = next
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()

View File

@@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const measure = () => {
if (!root) return
const scroller = document.querySelector(".session-scroller")
const scroller = document.querySelector(".scroll-view__viewport")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
@@ -95,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller")
const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)

View File

@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
)
return (
<Tabs.Content
value={props.tab}
class="mt-3 relative"
ref={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
</Tabs.Content>
)
}

View File

@@ -8,12 +8,14 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -80,6 +82,7 @@ export function MessageTimeline(props: {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
@@ -320,8 +323,8 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<div
ref={props.setScrollRef}
<ScrollView
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
@@ -365,7 +368,7 @@ export function MessageTimeline(props: {
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
@@ -535,6 +538,7 @@ export function MessageTimeline(props: {
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@@ -545,7 +549,7 @@ export function MessageTimeline(props: {
)}
</For>
</div>
</div>
</ScrollView>
</div>
</Show>
)

View File

@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-6",
root: props.classes?.root ?? "pb-6 pr-3",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-3",
container: props.classes?.container ?? "pl-3",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}

View File

@@ -67,11 +67,11 @@ export function TerminalPanel() {
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !opened()) return
if (!activeId || !open()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
setTimeout(() => focusTerminalById(activeId), 0)
},
),
)
@@ -209,21 +209,17 @@ export function TerminalPanel() {
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={all()}>
{(pty) => (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Show>
</div>
<Show when={terminal.active()} keyed>
{(id) => (
<Show when={byId().get(id)}>
{(pty) => (
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
</div>
)}
</Show>
)}
</For>
</Show>
</div>
</div>
<DragOverlay>

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,5 +1,5 @@
import { APIEvent } from "@solidjs/start"
import { DownloadPlatform } from "./types"
import type { APIEvent } from "@solidjs/start"
import type { DownloadPlatform } from "../types"
const assetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
@@ -17,17 +17,20 @@ const downloadNames: Record<string, string> = {
"windows-x64-nsis": "OpenCode Desktop Installer.exe",
} satisfies { [K in DownloadPlatform]?: string }
export async function GET({ params: { platform } }: APIEvent) {
export async function GET({ params: { platform, channel } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
const resp = await fetch(
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
{
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any,
)
const downloadName = downloadNames[platform]

View File

@@ -1,18 +1,18 @@
import "./index.css"
import { Title, Meta } from "@solidjs/meta"
import { A, createAsync, query } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { IconCopy, IconCheck } from "~/component/icon"
import { Meta, Title } from "@solidjs/meta"
import { A } from "@solidjs/router"
import { createSignal, type JSX, onMount, Show } from "solid-js"
import { Faq } from "~/component/faq"
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { IconCheck, IconCopy } from "~/component/icon"
import { Legal } from "~/component/legal"
import { LocaleLinks } from "~/component/locale-links"
import { config } from "~/config"
import { createSignal, onMount, Show, JSX } from "solid-js"
import { DownloadPlatform } from "./types"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LocaleLinks } from "~/component/locale-links"
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
import type { DownloadPlatform } from "./types"
type OS = "macOS" | "Windows" | "Linux" | null
@@ -40,8 +40,8 @@ function getDownloadPlatform(os: OS): DownloadPlatform {
}
}
function getDownloadHref(platform: DownloadPlatform) {
return `/download/${platform}`
function getDownloadHref(platform: DownloadPlatform, channel: "stable" | "beta" = "stable") {
return `/download/${channel}/${platform}`
}
function IconDownload(props: JSX.SvgSVGAttributes<SVGSVGElement>) {

View File

@@ -107,11 +107,14 @@ export async function handler(
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
}),
providerInfo.modifyBody(
{
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
},
authInfo?.workspaceID,
),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")

View File

@@ -18,9 +18,10 @@ export const openaiHelper: ProviderHelper = () => ({
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => {
return body
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),
createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n",
createUsageParser: () => {

View File

@@ -37,7 +37,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.9",
"version": "1.2.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.9",
"version": "1.2.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.9",
"version": "1.2.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -320,7 +320,7 @@ pub fn spawn_command(
};
let mut cmd = Command::new(shell);
cmd.args(["-l", "-c", &line]);
cmd.args(["-il", "-c", &line]);
for (key, value) in envs {
cmd.env(key, value);

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCode Beta",
"identifier": "ai.opencode.desktop.beta",
"bundle": {
"createUpdaterArtifacts": true,
"linux": {
"rpm": {
"compression": {
"type": "none"
}
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
"endpoints": ["https://github.com/anomalyco/opencode-beta/releases/latest/download/latest.json"]
}
}
}

View File

@@ -491,34 +491,19 @@ render(() => {
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
if (serverData.state === "errored") throw serverData.error
return (
<Show
when={serverData.state !== "errored"}
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4">
<Splash class="w-16 h-20 opacity-50" />
<div class="max-w-md px-4 text-center">
<p class="text-sm font-medium text-red-400">Failed to start server</p>
<p class="mt-2 text-xs text-zinc-400 break-words whitespace-pre-wrap">
{String(serverData.error ?? "Unknown error")}
</p>
</div>
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
<Show
when={serverData.state !== "pending" && serverData()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
</div>
}
>
{(data) => props.children(data())}
</Show>
{(data) => props.children(data())}
</Show>
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.9",
"version": "1.2.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.9"
version = "1.2.10"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.9",
"version": "1.2.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -25,6 +25,12 @@ if (envPath) {
const scriptPath = fs.realpathSync(__filename)
const scriptDir = path.dirname(scriptPath)
//
const cached = path.join(scriptDir, ".opencode")
if (fs.existsSync(cached)) {
run(cached)
}
const platformMap = {
darwin: "darwin",
linux: "linux",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.9",
"version": "1.2.10",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env bun
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -12,8 +12,9 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)
import pkg from "../package.json"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
const modelsData = process.env.MODELS_DEV_API_JSON
@@ -26,7 +27,11 @@ await Bun.write(
console.log("Generated models-snapshot.ts")
// Load migrations from migration directories
const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
@@ -171,7 +176,6 @@ for (const item of targets) {
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(pkg.name, "bun") as any,
@@ -214,7 +218,7 @@ if (Script.release) {
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
}
}
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber`
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
}
export { binaries }

View File

@@ -109,8 +109,14 @@ async function main() {
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
console.log(`Platform binary verified at: ${binaryPath}`)
console.log("Wrapper script will handle binary execution")
const target = path.join(__dirname, "bin", ".opencode")
if (fs.existsSync(target)) fs.unlinkSync(target)
try {
fs.linkSync(binaryPath, target)
} catch {
fs.copyFileSync(binaryPath, target)
}
fs.chmodSync(target, 0o755)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)

View File

@@ -450,6 +450,7 @@ export const GithubRunCommand = cmd({
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel()
const variant = process.env["VARIANT"] || undefined
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
@@ -912,6 +913,7 @@ export const GithubRunCommand = cmd({
const result = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
variant,
model: {
providerID,
modelID,
@@ -965,6 +967,7 @@ export const GithubRunCommand = cmd({
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
variant,
model: {
providerID,
modelID,

View File

@@ -6,10 +6,6 @@ import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
import { inArray } from "drizzle-orm"
import { MessageTable, PartTable } from "../../session/session.sql"
import type { MessageV2 } from "../../session/message-v2"
import { and, eq, gte } from "drizzle-orm"
interface SessionStats {
totalSessions: number
@@ -24,7 +20,7 @@ interface SessionStats {
write: number
}
}
toolUsage: Record<string, { calls: number; errors: number }>
toolUsage: Record<string, number>
modelUsage: Record<
string,
{
@@ -38,7 +34,6 @@ interface SessionStats {
}
}
cost: number
toolUsage: Record<string, { calls: number; errors: number }>
}
>
dateRange: {
@@ -67,11 +62,6 @@ export const StatsCommand = cmd({
.option("models", {
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
})
.option("model", {
describe: "filter models to show (can be used multiple times)",
type: "array",
string: true,
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
@@ -82,20 +72,13 @@ export const StatsCommand = cmd({
const stats = await aggregateSessionStats(args.days, args.project)
let modelLimit: number | undefined
let modelFilter: string[] | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
if (args.model && args.model.length > 0) {
modelFilter = args.model as string[]
modelLimit = modelLimit ?? Infinity
}
displayStats(stats, args.tools, modelLimit, modelFilter)
displayStats(stats, args.tools, modelLimit)
})
},
})
@@ -103,7 +86,14 @@ export const StatsCommand = cmd({
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
async function getAllSessions(): Promise<Session.Info[]> {
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
@@ -122,34 +112,17 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return days
})()
let projectID: string | undefined
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
projectID = currentProject.id
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
} else {
projectID = projectFilter
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
}
}
const rows = Database.use((db) => {
const conditions = []
if (cutoffTime > 0) {
conditions.push(gte(SessionTable.time_updated, cutoffTime))
}
if (projectID !== undefined) {
conditions.push(eq(SessionTable.project_id, projectID))
}
const baseQuery = db.select().from(SessionTable)
if (conditions.length > 0) {
return baseQuery.where(and(...conditions)).all()
}
return baseQuery.all()
})
const filteredSessions = rows.map((row) => Session.fromRow(row))
const stats: SessionStats = {
totalSessions: filteredSessions.length,
totalMessages: 0,
@@ -189,58 +162,16 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
const sessionTotalTokens: number[] = []
const BATCH_SIZE = 100
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const sessionIds = batch.map((s) => s.id)
// Bulk fetch messages for this batch of sessions
const messageRows = Database.use((db) =>
db.select().from(MessageTable).where(inArray(MessageTable.session_id, sessionIds)).all(),
)
// Group messages by session_id
const messagesBySession = new Map<string, typeof messageRows>()
const messageIds = messageRows.map((r) => r.id)
for (const row of messageRows) {
const msgs = messagesBySession.get(row.session_id) || []
msgs.push(row)
messagesBySession.set(row.session_id, msgs)
}
// Bulk fetch parts for all these messages
let partRows: (typeof PartTable.$inferSelect)[] = []
if (messageIds.length > 0) {
// Chunk message IDs if there are too many for a single IN clause (SQLite has limits)
const PART_BATCH_SIZE = 500
for (let j = 0; j < messageIds.length; j += PART_BATCH_SIZE) {
const idBatch = messageIds.slice(j, j + PART_BATCH_SIZE)
const parts = Database.use((db) =>
db.select().from(PartTable).where(inArray(PartTable.message_id, idBatch)).all(),
)
partRows.push(...parts)
}
}
// Group parts by message_id
const partsByMessage = new Map<string, MessageV2.Part[]>()
for (const row of partRows) {
const parts = partsByMessage.get(row.message_id) || []
parts.push({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id } as MessageV2.Part)
partsByMessage.set(row.message_id, parts)
}
const batchResults = batch.map((session) => {
const rawMessages = messagesBySession.get(session.id) || []
const messages = rawMessages.map((row) => ({
info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info,
parts: partsByMessage.get(row.id) || [],
}))
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages({ sessionID: session.id })
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, { calls: number; errors: number }> = {}
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
@@ -254,7 +185,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
}
cost: number
toolUsage: Record<string, { calls: number; errors: number }>
}
> = {}
@@ -268,7 +198,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
toolUsage: {},
}
}
sessionModelUsage[modelKey].messages++
@@ -287,22 +216,11 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
const isError =
part.state && part.state.status === "error" && part.state.error !== "Tool execution aborted"
if (!sessionToolUsage[part.tool]) sessionToolUsage[part.tool] = { calls: 0, errors: 0 }
sessionToolUsage[part.tool].calls++
if (isError) sessionToolUsage[part.tool].errors++
if (!sessionModelUsage[modelKey].toolUsage[part.tool]) {
sessionModelUsage[modelKey].toolUsage[part.tool] = { calls: 0, errors: 0 }
}
sessionModelUsage[modelKey].toolUsage[part.tool].calls++
if (isError) sessionModelUsage[modelKey].toolUsage[part.tool].errors++
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
@@ -324,6 +242,8 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
@@ -338,9 +258,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
if (!stats.toolUsage[tool]) stats.toolUsage[tool] = { calls: 0, errors: 0 }
stats.toolUsage[tool].calls += count.calls
stats.toolUsage[tool].errors += count.errors
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
@@ -349,7 +267,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
toolUsage: {},
}
}
stats.modelUsage[model].messages += usage.messages
@@ -358,14 +275,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
for (const [tool, toolUsage] of Object.entries(usage.toolUsage)) {
if (!stats.modelUsage[model].toolUsage[tool]) {
stats.modelUsage[model].toolUsage[tool] = { calls: 0, errors: 0 }
}
stats.modelUsage[model].toolUsage[tool].calls += toolUsage.calls
stats.modelUsage[model].toolUsage[tool].errors += toolUsage.errors
}
}
}
}
@@ -397,7 +306,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number, modelFilter?: string[]) {
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -437,73 +346,43 @@ export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit
console.log()
// Model Usage section
if ((modelLimit !== undefined || modelFilter !== undefined) && Object.keys(stats.modelUsage).length > 0) {
let sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
if (modelFilter && modelFilter.length > 0) {
sortedModels = sortedModels.filter(([model]) => modelFilter.some((filter) => model.includes(filter)))
}
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ MODEL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const modelsToDisplay =
modelLimit === Infinity || modelLimit === undefined ? sortedModels : sortedModels.slice(0, modelLimit)
if (modelsToDisplay.length > 0) {
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ MODEL USAGE │")
for (const [model, usage] of modelsToDisplay) {
console.log(`${model.padEnd(54)}`)
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read)))
console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write)))
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
console.log("├────────────────────────────────────────────────────────┤")
for (const [model, usage] of modelsToDisplay) {
console.log(`${model.padEnd(54)}`)
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read)))
console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write)))
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
if (Object.keys(usage.toolUsage).length > 0) {
console.log(`│ │`)
console.log(`│ Tool Call Rate Error Rate │`)
const totalModelTools = Object.values(usage.toolUsage).reduce((sum, t) => sum + t.calls, 0)
const sortedTools = Object.entries(usage.toolUsage).sort((a, b) => b[1].calls - a[1].calls)
for (const [tool, toolStats] of sortedTools) {
const callRate = ((toolStats.calls / totalModelTools) * 100).toFixed(1) + "%"
const errorRate = toolStats.calls > 0 ? ((toolStats.errors / toolStats.calls) * 100).toFixed(1) + "%" : "0%"
const toolName = tool.length > 22 ? tool.substring(0, 20) + ".." : tool
const paddedTool = toolName.padEnd(24)
const callStr = callRate.padStart(13)
const errStr = errorRate.padStart(15)
console.log(`${paddedTool}${callStr}${errStr}`)
}
}
console.log("├────────────────────────────────────────────────────────┤")
}
// Remove last separator and add bottom border
process.stdout.write("\x1B[1A") // Move up one line
console.log("└────────────────────────────────────────────────────────┘")
}
// Remove last separator and add bottom border
process.stdout.write("\x1B[1A") // Move up one line
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b.calls - a.calls)
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...toolsToDisplay.map(([, toolStats]) => toolStats.calls))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b.calls, 0)
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, toolStats] of toolsToDisplay) {
const count = toolStats.calls
for (const [tool, count] of toolsToDisplay) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1)

View File

@@ -292,7 +292,9 @@ export namespace Config {
...(proxied() ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch(() => {})
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
}
async function isWritable(dir: string) {

View File

@@ -41,8 +41,10 @@ export namespace Plugin {
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input)
hooks.push(init)
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
let plugins = config.plugin ?? []
@@ -59,37 +61,40 @@ export namespace Plugin {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
plugin = await BunProc.install(pkg, version).catch((err) => {
if (!builtin) throw err
const message = err instanceof Error ? err.message : String(err)
log.error("failed to install builtin plugin", {
pkg,
version,
error: message,
})
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
const mod = await import(plugin)
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
const init = await fn(input)
hooks.push(init)
}
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
}
return {

View File

@@ -65,7 +65,13 @@ export namespace ModelsDev {
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
provider: z
.object({
npm: z.string().optional(),
api: z.string().optional(),
shape: z.enum(["responses", "completions"]).optional(),
})
.optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer<typeof Model>

View File

@@ -109,7 +109,7 @@ export namespace Provider {
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomModelLoader = (sdk: any, model: Model, options?: Record<string, any>) => Promise<any>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: CustomModelLoader
@@ -153,8 +153,9 @@ export namespace Provider {
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
if (model.api.shape === "completions") return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {},
}
@@ -162,9 +163,12 @@ export namespace Provider {
"github-copilot": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
const shape = model.api.shape
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (shape === "responses") return sdk.responses(model.api.id)
if (shape === "completions") return sdk.chat(model.api.id)
return shouldUseCopilotResponsesApi(model.api.id) ? sdk.responses(model.api.id) : sdk.chat(model.api.id)
},
options: {},
}
@@ -172,9 +176,12 @@ export namespace Provider {
"github-copilot-enterprise": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
const shape = model.api.shape
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (shape === "responses") return sdk.responses(model.api.id)
if (shape === "completions") return sdk.chat(model.api.id)
return shouldUseCopilotResponsesApi(model.api.id) ? sdk.responses(model.api.id) : sdk.chat(model.api.id)
},
options: {},
}
@@ -182,12 +189,12 @@ export namespace Provider {
azure: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
}
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (sdk.responses === undefined || sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (model.api.shape === "completions") return sdk.chat(model.api.id)
if (model.api.shape === "responses") return sdk.responses(model.api.id)
if (options?.["useCompletionUrls"]) return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {},
}
@@ -196,12 +203,12 @@ export namespace Provider {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
}
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (sdk.responses === undefined || sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (model.api.shape === "completions") return sdk.chat(model.api.id)
if (model.api.shape === "responses") return sdk.responses(model.api.id)
if (options?.["useCompletionUrls"]) return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
@@ -269,7 +276,8 @@ export namespace Provider {
return {
autoload: true,
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
let modelID = model.api.id
// Skip region prefixing if model already has a cross-region inference profile prefix
// Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
@@ -406,8 +414,8 @@ export namespace Provider {
return fetch(input, { ...init, headers })
},
},
async getModel(sdk: any, modelID: string) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -423,8 +431,8 @@ export namespace Provider {
project,
location,
},
async getModel(sdk: any, modelID) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -448,8 +456,8 @@ export namespace Provider {
return {
autoload: !!envServiceKey,
options: envServiceKey ? { deploymentId, resourceGroup } : {},
async getModel(sdk: any, modelID: string) {
return sdk(modelID)
async getModel(sdk: any, model: Model) {
return sdk(model.api.id)
},
}
},
@@ -494,8 +502,8 @@ export namespace Provider {
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
async getModel(sdk: ReturnType<typeof createGitLab>, model: Model) {
return sdk.agenticChat(model.api.id, {
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
@@ -524,8 +532,8 @@ export namespace Provider {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
async getModel(sdk: any, model: Model) {
return sdk.languageModel(model.api.id)
},
}
},
@@ -560,9 +568,9 @@ export namespace Provider {
return {
autoload: true,
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(_sdk: any, model: Model, _options?: Record<string, any>) {
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
return aigateway(unified(modelID))
return aigateway(unified(model.api.id))
},
options: {},
}
@@ -598,6 +606,7 @@ export namespace Provider {
id: z.string(),
url: z.string(),
npm: z.string(),
shape: z.enum(["responses", "completions"]).optional(),
}),
name: z.string(),
family: z.string().optional(),
@@ -686,6 +695,7 @@ export namespace Provider {
id: model.id,
url: model.provider?.api ?? provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
shape: model.provider?.shape,
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -836,6 +846,7 @@ export namespace Provider {
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
shape: model.provider?.shape ?? existingModel?.api.shape,
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
@@ -1177,7 +1188,7 @@ export namespace Provider {
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
? await s.modelLoaders[model.providerID](sdk, model, provider.options)
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language

View File

@@ -41,13 +41,38 @@ export namespace Pty {
const token = (ws: Socket) => {
const data = ws.data
if (!data || typeof data !== "object") return
if (data === undefined) return
if (data === null) return
if (typeof data !== "object") return data
const events = (data as { events?: unknown }).events
if (events && typeof events === "object") return events
const id = (data as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const href = (data as { href?: unknown }).href
if (typeof href === "string") return href
const url = (data as { url?: unknown }).url
if (url && typeof url === "object") return url
if (typeof url === "string") return url
if (url && typeof url === "object") {
const href = (url as { href?: unknown }).href
if (typeof href === "string") return href
return url
}
const events = (data as { events?: unknown }).events
if (typeof events === "number" || typeof events === "string") return events
if (events && typeof events === "object") {
const id = (events as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const id2 = (events as { connection?: unknown }).connection
if (typeof id2 === "number" || typeof id2 === "string") return id2
const id3 = (events as { id?: unknown }).id
if (typeof id3 === "number" || typeof id3 === "string") return id3
return events
}
return data
}
@@ -210,7 +235,7 @@ export namespace Pty {
continue
}
if (sub.token !== undefined && token(ws) !== sub.token) {
if (token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}

View File

@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(true)
},
)
.get(
"/session",
describeRoute({
summary: "List sessions",
description:
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
operationId: "experimental.session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.GlobalInfo.array()),
},
},
},
},
}),
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
cursor: z.coerce
.number()
.optional()
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
}),
),
async (c) => {
const query = c.req.valid("query")
const limit = query.limit ?? 100
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
directory: query.directory,
roots: query.roots,
start: query.start,
cursor: query.cursor,
search: query.search,
limit: limit + 1,
archived: query.archived,
})) {
sessions.push(session)
}
const hasMore = sessions.length > limit
const list = hasMore ? sessions.slice(0, limit) : sessions
if (hasMore && list.length > 0) {
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
}
return c.json(list)
},
)
.get(
"/resource",
describeRoute({

View File

@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
@@ -154,6 +156,24 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ProjectInfo = z
.object({
id: z.string(),
name: z.string().optional(),
worktree: z.string(),
})
.meta({
ref: "ProjectSummary",
})
export type ProjectInfo = z.output<typeof ProjectInfo>
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
}).meta({
ref: "GlobalSession",
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -544,6 +564,75 @@ export namespace Session {
}
}
export function* listGlobal(input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
}) {
const conditions: SQL[] = []
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
}
if (input?.start) {
conditions.push(gte(SessionTable.time_updated, input.start))
}
if (input?.cursor) {
conditions.push(lt(SessionTable.time_updated, input.cursor))
}
if (input?.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
if (!input?.archived) {
conditions.push(isNull(SessionTable.time_archived))
}
const limit = input?.limit ?? 100
const rows = Database.use((db) => {
const query =
conditions.length > 0
? db
.select()
.from(SessionTable)
.where(and(...conditions))
: db.select().from(SessionTable)
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
})
const ids = [...new Set(rows.map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()
if (ids.length > 0) {
const items = Database.use((db) =>
db
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
.from(ProjectTable)
.where(inArray(ProjectTable.id, ids))
.all(),
)
for (const item of items) {
projects.set(item.id, {
id: item.id,
name: item.name ?? undefined,
worktree: item.worktree,
})
}
}
for (const row of rows) {
const project = projects.get(row.project_id) ?? null
yield { ...fromRow(row), project }
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>

View File

@@ -66,7 +66,7 @@ export namespace Snapshot {
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
log.info("initialized")
}
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
@@ -84,7 +84,7 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
@@ -162,7 +162,7 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
@@ -253,4 +253,38 @@ export namespace Snapshot {
const project = Instance.project
return path.join(Global.Path.data, "snapshot", project.id)
}
async function add(git: string) {
await syncExclude(git)
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
}
async function syncExclude(git: string) {
const file = await excludes()
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Bun.write(target, "")
return
}
const text = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(target, text)
}
async function excludes() {
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
.quiet()
.cwd(Instance.worktree)
.nothrow()
.text()
if (!file.trim()) return
const exists = await fs
.stat(file.trim())
.then(() => true)
.catch(() => false)
if (!exists) return
return file.trim()
}
}

View File

@@ -97,4 +97,48 @@ describe("pty", () => {
},
})
})
test("does not leak output when socket data mutates in-place", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const outA: string[] = []
const outB: string[] = []
const ctx = { connId: 1 }
const ws = {
readyState: 1,
data: ctx,
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op
},
}
Pty.connect(a.id, ws as any)
outA.length = 0
// Simulate the runtime mutating per-connection data without
// swapping the reference (ws.data stays the same object).
ctx.connId = 2
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("Session.listGlobal", () => {
test("lists sessions across projects with project metadata", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "first-session" }),
})
const secondSession = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "second-session" }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
expect(ids).toContain(secondSession.id)
const firstProject = Project.get(firstSession.projectID)
const secondProject = Project.get(secondSession.projectID)
const firstItem = sessions.find((session) => session.id === firstSession.id)
const secondItem = sessions.find((session) => session.id === secondSession.id)
expect(firstItem?.project?.id).toBe(firstProject?.id)
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
expect(secondItem?.project?.id).toBe(secondProject?.id)
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
})
test("excludes archived sessions by default", async () => {
await using tmp = await tmpdir({ git: true })
const archived = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "archived-session" }),
})
await Instance.provide({
directory: tmp.path,
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
})
test("supports cursor pagination", async () => {
await using tmp = await tmpdir({ git: true })
const first = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-two" }),
})
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
})
})

View File

@@ -508,6 +508,68 @@ test("gitignore changes", async () => {
})
})
test("git info exclude changes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const file = `${tmp.path}/.git/info/exclude`
const text = await Bun.file(file).text()
await Bun.write(file, `${text.trimEnd()}\nignored.txt\n`)
await Bun.write(`${tmp.path}/ignored.txt`, "ignored content")
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
expect(patch.files).not.toContain(`${tmp.path}/ignored.txt`)
const after = await Snapshot.track()
const diffs = await Snapshot.diffFull(before!, after!)
expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
},
})
})
test("git info exclude keeps global excludes", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const global = `${tmp.path}/global.ignore`
const config = `${tmp.path}/global.gitconfig`
await Bun.write(global, "global.tmp\n")
await Bun.write(config, `[core]\n\texcludesFile = ${global}\n`)
const prev = process.env.GIT_CONFIG_GLOBAL
process.env.GIT_CONFIG_GLOBAL = config
try {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const file = `${tmp.path}/.git/info/exclude`
const text = await Bun.file(file).text()
await Bun.write(file, `${text.trimEnd()}\ninfo.tmp\n`)
await Bun.write(`${tmp.path}/global.tmp`, "global content")
await Bun.write(`${tmp.path}/info.tmp`, "info content")
await Bun.write(`${tmp.path}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.path}/normal.txt`)
expect(patch.files).not.toContain(`${tmp.path}/global.tmp`)
expect(patch.files).not.toContain(`${tmp.path}/info.tmp`)
} finally {
if (prev) process.env.GIT_CONFIG_GLOBAL = prev
else delete process.env.GIT_CONFIG_GLOBAL
}
},
})
})
test("concurrent file operations during patch", async () => {
await using tmp = await bootstrap()
await Instance.provide({

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {
@@ -13,15 +13,15 @@
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./v2": {
"types": "./dist/src/v2/index.d.ts",
"types": "./dist/v2/index.d.ts",
"default": "./src/v2/index.ts"
},
"./v2/client": {
"types": "./dist/src/v2/client.d.ts",
"types": "./dist/v2/client.d.ts",
"default": "./src/v2/client.ts"
},
"./v2/gen/client": {
"types": "./dist/src/v2/gen/client/index.d.ts",
"types": "./dist/v2/gen/client/index.d.ts",
"default": "./src/v2/gen/client/index.ts"
},
"./v2/server": "./src/v2/server.ts"

View File

@@ -25,6 +25,7 @@ import type {
EventTuiSessionSelect,
EventTuiToastShow,
ExperimentalResourceListResponses,
ExperimentalSessionListResponses,
FileListResponses,
FilePartInput,
FilePartSource,
@@ -898,6 +899,48 @@ export class Worktree extends HeyApiClient {
}
}
export class Session extends HeyApiClient {
/**
* List sessions
*
* Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "cursor" },
{ in: "query", key: "search" },
{ in: "query", key: "limit" },
{ in: "query", key: "archived" },
],
},
],
)
return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
url: "/experimental/session",
...options,
...params,
})
}
}
export class Resource extends HeyApiClient {
/**
* Get MCP resources
@@ -920,13 +963,18 @@ export class Resource extends HeyApiClient {
}
export class Experimental extends HeyApiClient {
private _session?: Session
get session(): Session {
return (this._session ??= new Session({ client: this.client }))
}
private _resource?: Resource
get resource(): Resource {
return (this._resource ??= new Resource({ client: this.client }))
}
}
export class Session extends HeyApiClient {
export class Session2 extends HeyApiClient {
/**
* List sessions
*
@@ -3231,9 +3279,9 @@ export class OpencodeClient extends HeyApiClient {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _session?: Session
get session(): Session {
return (this._session ??= new Session({ client: this.client }))
private _session?: Session2
get session(): Session2 {
return (this._session ??= new Session2({ client: this.client }))
}
private _part?: Part

View File

@@ -2044,6 +2044,45 @@ export type WorktreeResetInput = {
directory: string
}
export type ProjectSummary = {
id: string
name?: string
worktree: string
}
export type GlobalSession = {
id: string
slug: string
projectID: string
directory: string
parentID?: string
summary?: {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
}
share?: {
url: string
}
title: string
version: string
time: {
created: number
updated: number
compacting?: number
archived?: number
}
permission?: PermissionRuleset
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
project: ProjectSummary | null
}
export type McpResource = {
name: string
uri: string
@@ -2870,6 +2909,51 @@ export type WorktreeResetResponses = {
export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
export type ExperimentalSessionListData = {
body?: never
path?: never
query?: {
/**
* Filter sessions by project directory
*/
directory?: string
/**
* Only return root sessions (no parentID)
*/
roots?: boolean
/**
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
*/
start?: number
/**
* Return sessions updated before this timestamp (milliseconds since epoch)
*/
cursor?: number
/**
* Filter sessions by title (case-insensitive)
*/
search?: string
/**
* Maximum number of sessions to return
*/
limit?: number
/**
* Include archived sessions (default false)
*/
archived?: boolean
}
url: "/experimental/session"
}
export type ExperimentalSessionListResponses = {
/**
* List of sessions
*/
200: Array<GlobalSession>
}
export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]
export type ExperimentalResourceListData = {
body?: never
path?: never

View File

@@ -7,7 +7,8 @@
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"composite": true
"composite": true,
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -1202,6 +1202,92 @@
]
}
},
"/experimental/session": {
"get": {
"operationId": "experimental.session.list",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
},
"description": "Filter sessions by project directory"
},
{
"in": "query",
"name": "roots",
"schema": {
"type": "boolean"
},
"description": "Only return root sessions (no parentID)"
},
{
"in": "query",
"name": "start",
"schema": {
"type": "number"
},
"description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)"
},
{
"in": "query",
"name": "cursor",
"schema": {
"type": "number"
},
"description": "Return sessions updated before this timestamp (milliseconds since epoch)"
},
{
"in": "query",
"name": "search",
"schema": {
"type": "string"
},
"description": "Filter sessions by title (case-insensitive)"
},
{
"in": "query",
"name": "limit",
"schema": {
"type": "number"
},
"description": "Maximum number of sessions to return"
},
{
"in": "query",
"name": "archived",
"schema": {
"type": "boolean"
},
"description": "Include archived sessions (default false)"
}
],
"summary": "List sessions",
"description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
"responses": {
"200": {
"description": "List of sessions",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GlobalSession"
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.session.list({\n ...\n})"
}
]
}
},
"/experimental/resource": {
"get": {
"operationId": "experimental.resource.list",
@@ -10499,6 +10585,129 @@
},
"required": ["directory"]
},
"ProjectSummary": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"worktree": {
"type": "string"
}
},
"required": ["id", "worktree"]
},
"GlobalSession": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^ses.*"
},
"slug": {
"type": "string"
},
"projectID": {
"type": "string"
},
"directory": {
"type": "string"
},
"parentID": {
"type": "string",
"pattern": "^ses.*"
},
"summary": {
"type": "object",
"properties": {
"additions": {
"type": "number"
},
"deletions": {
"type": "number"
},
"files": {
"type": "number"
},
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
}
}
},
"required": ["additions", "deletions", "files"]
},
"share": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
},
"required": ["url"]
},
"title": {
"type": "string"
},
"version": {
"type": "string"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
},
"updated": {
"type": "number"
},
"compacting": {
"type": "number"
},
"archived": {
"type": "number"
}
},
"required": ["created", "updated"]
},
"permission": {
"$ref": "#/components/schemas/PermissionRuleset"
},
"revert": {
"type": "object",
"properties": {
"messageID": {
"type": "string"
},
"partID": {
"type": "string"
},
"snapshot": {
"type": "string"
},
"diff": {
"type": "string"
}
},
"required": ["messageID"]
},
"project": {
"anyOf": [
{
"$ref": "#/components/schemas/ProjectSummary"
},
{
"type": "null"
}
]
}
},
"required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"]
},
"McpResource": {
"type": "object",
"properties": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -96,6 +96,7 @@ export interface MessageProps {
parts: PartType[]
showAssistantCopyPartID?: string | null
interrupted?: boolean
showReasoningSummaries?: boolean
}
export interface MessagePartProps {
@@ -104,6 +105,7 @@ export interface MessagePartProps {
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
export type PartComponent = Component<MessagePartProps>
@@ -149,6 +151,8 @@ function createThrottledValue(getValue: () => string) {
function relativizeProjectPaths(text: string, directory?: string) {
if (!text) return ""
if (!directory) return text
if (directory === "/") return text
if (directory === "\\") return text
return text.split(directory).join("")
}
@@ -261,21 +265,23 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
function renderable(part: PartType) {
function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
return !!PART_MAPPING[part.type]
}
export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
turnDurationMs?: number
working?: boolean
showReasoningSummaries?: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -296,7 +302,7 @@ export function AssistantParts(props: {
const parts = props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
.filter(renderable)
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({ message, part })),
)
@@ -365,6 +371,7 @@ export function AssistantParts(props: {
part={entry().part}
message={entry().message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
)}
</Show>
@@ -475,6 +482,7 @@ export function Message(props: MessageProps) {
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
showReasoningSummaries={props.showReasoningSummaries}
/>
)}
</Match>
@@ -486,6 +494,7 @@ export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
}) {
const grouped = createMemo(() => {
const keys: string[] = []
@@ -514,7 +523,7 @@ export function AssistantMessageDisplay(props: {
}
parts.forEach((part, index) => {
if (!renderable(part)) return
if (!renderable(part, props.showReasoningSummaries ?? true)) return
if (isContextGroupTool(part)) {
if (start < 0) start = index
@@ -849,6 +858,7 @@ export function Part(props: MessagePartProps) {
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
</Show>
)
@@ -1060,8 +1070,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
if (props.message.role !== "assistant") return ""
const message = props.message as AssistantMessage
const completed = message.time.completed
if (typeof completed !== "number") return ""
const ms = completed - message.time.created
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - message.time.created
: -1
if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000)
if (total < 60) return `${total}s`
@@ -1593,6 +1607,12 @@ ToolRegistry.register({
const i18n = useI18n()
const diffComponent = useDiffComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
const list = files()
if (list.length !== 1) return
return list[0]
})
const [expanded, setExpanded] = createSignal<string[]>([])
let seeded = false
@@ -1611,100 +1631,147 @@ ToolRegistry.register({
})
return (
<div data-component="apply-patch-tool">
<BasicTool
{...props}
icon="code-lines"
defer
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
<Show
when={single()}
fallback={
<div data-component="apply-patch-tool">
<BasicTool
{...props}
icon="code-lines"
defer
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
})
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
})
return (
<Accordion.Item value={file.filePath} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.relativePath, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
return (
<Accordion.Item value={file.filePath} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.relativePath, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={file.type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={file.type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</BasicTool>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</BasicTool>
</div>
}
>
{(file) => (
<BasicTool
{...props}
icon="code-lines"
defer
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.tool.patch")}>
<TextShimmer text={i18n.t("ui.tool.patch")} />
</Show>
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>
</Show>
</div>
<Show when={!pending() && file().relativePath.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(file().relativePath)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending()}>
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} />
</Show>
</div>
</div>
}
>
<div data-component="edit-content">
<Dynamic
component={diffComponent}
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
</div>
</BasicTool>
)}
</Show>
)
},
})

View File

@@ -0,0 +1,61 @@
.scroll-view {
position: relative;
overflow: hidden;
}
.scroll-view__viewport {
height: 100%;
width: 100%;
overflow-y: auto;
scrollbar-width: none;
outline: none;
}
.scroll-view__viewport::-webkit-scrollbar {
display: none;
}
.scroll-view__thumb {
position: absolute;
right: 0;
top: 0;
width: 16px;
transition: opacity 200ms ease;
cursor: default;
user-select: none;
opacity: 0;
}
.scroll-view__thumb::after {
content: "";
position: absolute;
right: 4px;
top: 0;
bottom: 0;
width: 6px;
border-radius: 9999px;
background-color: var(--border-weak-base);
backdrop-filter: blur(4px);
transition: background-color 150ms ease;
}
.scroll-view__thumb:hover::after,
.scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.dark .scroll-view__thumb::after,
[data-theme="dark"] .scroll-view__thumb::after {
background-color: var(--border-weak-base);
}
.dark .scroll-view__thumb:hover::after,
[data-theme="dark"] .scroll-view__thumb:hover::after,
.dark .scroll-view__thumb[data-dragging="true"]::after,
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.scroll-view__thumb[data-visible="true"] {
opacity: 1;
}

View File

@@ -0,0 +1,217 @@
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
}
export function ScrollView(props: ScrollViewProps) {
const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
merged,
["class", "children", "viewportRef", "orientation", "style"],
[
"onScroll",
"onWheel",
"onTouchStart",
"onTouchMove",
"onTouchEnd",
"onTouchCancel",
"onPointerDown",
"onClick",
"onKeyDown",
],
)
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
const [thumbHeight, setThumbHeight] = createSignal(0)
const [thumbTop, setThumbTop] = createSignal(0)
const [showThumb, setShowThumb] = createSignal(false)
const updateThumb = () => {
if (!viewportRef) return
const { scrollTop, scrollHeight, clientHeight } = viewportRef
if (scrollHeight <= clientHeight || scrollHeight === 0) {
setShowThumb(false)
return
}
setShowThumb(true)
const trackPadding = 8
const trackHeight = clientHeight - trackPadding * 2
const minThumbHeight = 32
// Calculate raw thumb height based on ratio
let height = (clientHeight / scrollHeight) * trackHeight
height = Math.max(height, minThumbHeight)
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
setThumbTop(boundedTop)
}
onMount(() => {
if (local.viewportRef) {
local.viewportRef(viewportRef)
}
const observer = new ResizeObserver(() => {
updateThumb()
})
observer.observe(viewportRef)
// Also observe the first child if possible to catch content changes
if (viewportRef.firstElementChild) {
observer.observe(viewportRef.firstElementChild)
}
onCleanup(() => {
observer.disconnect()
})
updateThumb()
})
let startY = 0
let startScrollTop = 0
const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
startY = e.clientY
startScrollTop = viewportRef.scrollTop
thumbRef.setPointerCapture(e.pointerId)
const onPointerMove = (e: PointerEvent) => {
const deltaY = e.clientY - startY
const { scrollHeight, clientHeight } = viewportRef
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = clientHeight - thumbHeight()
if (maxThumbTop > 0) {
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
viewportRef.scrollTop = startScrollTop + scrollDelta
}
}
const onPointerUp = (e: PointerEvent) => {
setIsDragging(false)
thumbRef.releasePointerCapture(e.pointerId)
thumbRef.removeEventListener("pointermove", onPointerMove)
thumbRef.removeEventListener("pointerup", onPointerUp)
}
thumbRef.addEventListener("pointermove", onPointerMove)
thumbRef.addEventListener("pointerup", onPointerUp)
}
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
// but native usually handles this perfectly. Let's explicitly ensure it behaves well.
const onKeyDown = (e: KeyboardEvent) => {
// If user is focused on an input inside the scroll view, don't hijack keys
if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
return
}
const scrollAmount = viewportRef.clientHeight * 0.8
const lineAmount = 40
switch (e.key) {
case "PageDown":
e.preventDefault()
viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
break
case "PageUp":
e.preventDefault()
viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
break
case "Home":
e.preventDefault()
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "End":
e.preventDefault()
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
case "ArrowUp":
e.preventDefault()
viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
break
case "ArrowDown":
e.preventDefault()
viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
break
}
}
return (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
{...rest}
>
{/* Viewport */}
<div
ref={viewportRef}
class="scroll-view__viewport"
onScroll={(e) => {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={events.onWheel as any}
onTouchStart={events.onTouchStart as any}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={events.onPointerDown as any}
onClick={events.onClick as any}
tabIndex={0}
role="region"
aria-label="scrollable content"
onKeyDown={(e) => {
onKeyDown(e)
if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
}}
>
{local.children}
</div>
{/* Thumb Overlay */}
<Show when={showThumb()}>
<div
ref={thumbRef}
onPointerDown={onThumbPointerDown}
class="scroll-view__thumb"
data-visible={isHovered() || isDragging()}
data-dragging={isDragging()}
style={{
height: `${thumbHeight()}px`,
transform: `translateY(${thumbTop()}px)`,
"z-index": 100, // ensure it displays over content
}}
/>
</Show>
</div>
)
}

View File

@@ -12,6 +12,7 @@
[data-slot="session-review-container"] {
flex: 1 1 auto;
padding-right: 4px;
}
[data-slot="session-review-header"] {
@@ -40,7 +41,6 @@
display: flex;
align-items: center;
column-gap: 12px;
padding-right: 1px;
}
[data-slot="session-review-actions"] [data-component="radio-group"] {

View File

@@ -7,6 +7,7 @@ import { Icon } from "./icon"
import { LineComment, LineCommentEditor } from "./line-comment"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -188,8 +189,10 @@ export const SessionReview = (props: SessionReviewProps) => {
const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((d) => d.file))
const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const)))
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => props.diffs.length > 0
const hasDiffs = () => files().length > 0
const handleChange = (open: string[]) => {
props.onOpenChange?.(open)
@@ -198,7 +201,7 @@ export const SessionReview = (props: SessionReviewProps) => {
}
const handleExpandOrCollapseAll = () => {
const next = open().length > 0 ? [] : props.diffs.map((d) => d.file)
const next = open().length > 0 ? [] : files()
handleChange(next)
}
@@ -274,13 +277,13 @@ export const SessionReview = (props: SessionReviewProps) => {
})
return (
<div
<ScrollView
data-component="session-review"
ref={(el) => {
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
onScroll={props.onScroll}
onScroll={props.onScroll as any}
classList={{
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
@@ -321,51 +324,54 @@ export const SessionReview = (props: SessionReviewProps) => {
<div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}>
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => {
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
const expanded = createMemo(() => open().includes(diff.file))
const diff = createMemo(() => diffs().get(file))
const item = () => diff()!
const expanded = createMemo(() => open().includes(file))
const [force, setForce] = createSignal(false)
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const changedLines = () => diff.additions + diff.deletions
const beforeText = () => (typeof item().before === "string" ? item().before : "")
const afterText = () => (typeof item().after === "string" ? item().after : "")
const changedLines = () => item().additions + item().deletions
const tooLarge = createMemo(() => {
if (!expanded()) return false
if (force()) return false
if (isImageFile(diff.file)) return false
if (isImageFile(file)) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const isImage = () => isImageFile(diff.file)
const isAudio = () => isAudioFile(diff.file)
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const isImage = () => isImageFile(file)
const isAudio = () => isAudioFile(file)
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc())
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc())
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const selectedLines = createMemo(() => {
const current = selection()
if (!current || current.file !== diff.file) return null
if (!current || current.file !== file) return null
return current.range
})
const draftRange = createMemo(() => {
const current = commenting()
if (!current || current.file !== diff.file) return null
if (!current || current.file !== file) return null
return current.range
})
@@ -416,6 +422,21 @@ export const SessionReview = (props: SessionReviewProps) => {
requestAnimationFrame(updateAnchors)
}
createEffect(() => {
if (!isImage()) return
const src = diffImageSrc()
setImageSrc(src)
setImageStatus("idle")
})
createEffect(() => {
if (!isAudio()) return
const src = diffAudioSrc()
setAudioSrc(src)
setAudioStatus("idle")
setAudioMime(undefined)
})
createEffect(() => {
comments()
scheduleAnchors()
@@ -429,7 +450,7 @@ export const SessionReview = (props: SessionReviewProps) => {
})
createEffect(() => {
if (!open().includes(diff.file)) return
if (!open().includes(file)) return
if (!isImage()) return
if (imageSrc()) return
if (imageStatus() !== "idle") return
@@ -439,7 +460,7 @@ export const SessionReview = (props: SessionReviewProps) => {
if (!reader) return
setImageStatus("loading")
reader(diff.file)
reader(file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
@@ -455,7 +476,7 @@ export const SessionReview = (props: SessionReviewProps) => {
})
createEffect(() => {
if (!open().includes(diff.file)) return
if (!open().includes(file)) return
if (!isAudio()) return
if (audioSrc()) return
if (audioStatus() !== "idle") return
@@ -464,7 +485,7 @@ export const SessionReview = (props: SessionReviewProps) => {
if (!reader) return
setAudioStatus("loading")
reader(diff.file)
reader(file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
@@ -488,7 +509,7 @@ export const SessionReview = (props: SessionReviewProps) => {
return
}
setSelection({ file: diff.file, range })
setSelection({ file, range })
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
@@ -499,8 +520,8 @@ export const SessionReview = (props: SessionReviewProps) => {
return
}
setSelection({ file: diff.file, range })
setCommenting({ file: diff.file, range })
setSelection({ file, range })
setCommenting({ file, range })
}
const openComment = (comment: SessionReviewComment) => {
@@ -516,22 +537,22 @@ export const SessionReview = (props: SessionReviewProps) => {
return (
<Accordion.Item
value={diff.file}
id={diffId(diff.file)}
data-file={diff.file}
value={file}
id={diffId(file)}
data-file={file}
data-slot="session-review-accordion-item"
data-selected={props.focusedFile === diff.file ? "" : undefined}
data-selected={props.focusedFile === file ? "" : undefined}
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<FileIcon node={{ path: file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
<Show when={file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
<span data-slot="session-review-filename">{getFilename(file)}</span>
<Show when={props.onViewFile}>
<Tooltip value="Open file" placement="top" gutter={4}>
<button
@@ -540,7 +561,7 @@ export const SessionReview = (props: SessionReviewProps) => {
aria-label="Open file"
onClick={(e) => {
e.stopPropagation()
props.onViewFile?.(diff.file)
props.onViewFile?.(file)
}}
>
<Icon name="open-file" size="small" />
@@ -556,7 +577,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={diff} />
<DiffChanges changes={item()} />
</div>
</Match>
<Match when={isDeleted()}>
@@ -570,7 +591,7 @@ export const SessionReview = (props: SessionReviewProps) => {
</span>
</Match>
<Match when={true}>
<DiffChanges changes={diff} />
<DiffChanges changes={item()} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
@@ -585,7 +606,7 @@ export const SessionReview = (props: SessionReviewProps) => {
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(diff.file, el)
anchors.set(file, el)
scheduleAnchors()
}}
>
@@ -593,7 +614,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Switch>
<Match when={isImage() && imageSrc()}>
<div data-slot="session-review-image-container">
<img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
<img data-slot="session-review-image" src={imageSrc()} alt={file} />
</div>
</Match>
<Match when={isImage() && isDeleted()}>
@@ -633,7 +654,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Match when={!isImage()}>
<Dynamic
component={diffComponent}
preloadedDiff={diff.preloaded}
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
@@ -645,12 +666,12 @@ export const SessionReview = (props: SessionReviewProps) => {
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
name: diff.file!,
contents: typeof diff.before === "string" ? diff.before : "",
name: file,
contents: typeof item().before === "string" ? item().before : "",
}}
after={{
name: diff.file!,
contents: typeof diff.after === "string" ? diff.after : "",
name: file,
contents: typeof item().after === "string" ? item().after : "",
}}
/>
</Match>
@@ -688,10 +709,10 @@ export const SessionReview = (props: SessionReviewProps) => {
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
props.onLineComment?.({
file: diff.file,
file,
selection: range(),
comment,
preview: selectionPreview(diff, range()),
preview: selectionPreview(item(), range()),
})
setCommenting(null)
}}
@@ -709,6 +730,6 @@ export const SessionReview = (props: SessionReviewProps) => {
</Accordion>
</Show>
</div>
</div>
</ScrollView>
)
}

View File

@@ -41,6 +41,8 @@
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
@@ -52,6 +54,16 @@
width: 16px;
height: 16px;
}
[data-slot="session-turn-thinking-heading"] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
}
}
.error-card {

View File

@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message } from "./message-part"
import { AssistantParts, Message, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -83,15 +83,55 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
const hidden = new Set(["todowrite", "todoread"])
function visible(part: PartType) {
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
if (hidden.has(part.tool)) return
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
return "visible" as const
}
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
if (part.type === "reasoning") {
if (showReasoningSummaries) return "visible" as const
return
}
if (PART_MAPPING[part.type]) return "visible" as const
return
}
function clean(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function heading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = clean(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = clean(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = clean(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = clean(strong[1])
if (value) return value
}
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return false
}
export function SessionTurn(
@@ -99,6 +139,7 @@ export function SessionTurn(
sessionID: string
messageID: string
lastUserMessageID?: string
showReasoningSummaries?: boolean
onUserInteracted?: () => void
classes?: {
root?: string
@@ -242,17 +283,57 @@ export function SessionTurn(
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const assistantCopyPartID = createMemo(() => {
if (working()) return null
return showAssistantCopyPartID() ?? null
})
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
const end = assistantMessages().reduce<number | undefined>((max, item) => {
const completed = item.time.completed
if (typeof completed !== "number") return max
if (max === undefined) return completed
return Math.max(max, completed)
}, undefined)
if (typeof end !== "number") return undefined
if (end < start) return undefined
return end - start
})
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter(visible).length
return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
}, 0),
)
const assistantTailVisible = createMemo(() =>
assistantMessages()
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
.flatMap((part) => {
if (partState(part, showReasoningSummaries()) !== "visible") return []
if (part.type === "text") return ["text" as const]
return ["other" as const]
})
.at(-1),
)
const reasoningHeading = createMemo(() =>
assistantMessages()
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
.filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
.map((part) => heading(part.text))
.filter((text): text is string => !!text)
.at(-1),
)
const showThinking = createMemo(() => {
if (!working() || !!error()) return false
if (showReasoningSummaries()) return assistantVisible() === 0
if (assistantTailVisible() === "text") return false
return true
})
const autoScroll = createAutoScroll({
working,
@@ -280,20 +361,25 @@ export function SessionTurn(
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={parts()} interrupted={interrupted()} />
</div>
<Show when={working() && assistantVisible() === 0 && !error()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
turnDurationMs={turnDurationMs()}
working={working()}
showReasoningSummaries={showReasoningSummaries()}
/>
</div>
</Show>
<Show when={showThinking()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries() && reasoningHeading()}>
{(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
</Show>
</div>
</Show>
<Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">

View File

@@ -37,10 +37,11 @@ function target(container: HTMLElement): Target | undefined {
const review = container.closest("[data-component='session-review']")
if (review instanceof HTMLElement) {
const root = scrollRoot(container) ?? review
const content = review.querySelector("[data-slot='session-review-container']")
return {
key: review,
root: review,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}

View File

@@ -44,6 +44,7 @@
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
@import "../components/scroll-view.css" layer(components);
@import "../components/session-review.css" layer(components);
@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);

View File

@@ -8,34 +8,6 @@
}
}
@utility session-scroller {
&::-webkit-scrollbar {
width: 10px;
height: 10px;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-weak-base);
border-radius: 5px;
border: 3px solid transparent;
background-clip: padding-box;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--border-weak-base);
}
& {
scrollbar-width: thin;
scrollbar-color: var(--border-weak-base) transparent;
}
}
@utility badge-mask {
-webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.9",
"version": "1.2.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.9",
"version": "1.2.10",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -57,13 +57,16 @@ await $`bun install`
await import(`../packages/sdk/js/script/build.ts`)
if (Script.release) {
await $`git commit -am "release: v${Script.version}"`
await $`git tag v${Script.version}`
await $`git fetch origin`
await $`git cherry-pick HEAD..origin/dev`.nothrow()
await $`git push origin HEAD --tags --no-verify --force-with-lease`
await new Promise((resolve) => setTimeout(resolve, 5_000))
await $`gh release edit v${Script.version} --draft=false`
if (!Script.preview) {
await $`git commit -am "release: v${Script.version}"`
await $`git tag v${Script.version}`
await $`git fetch origin`
await $`git cherry-pick HEAD..origin/dev`.nothrow()
await $`git push origin HEAD --tags --no-verify --force-with-lease`
await new Promise((resolve) => setTimeout(resolve, 5_000))
}
await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}`
}
console.log("\n=== cli ===\n")

View File

@@ -17,8 +17,16 @@ if (!Script.preview) {
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`)
} else if (Script.channel === "beta") {
await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}`
const release =
await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()
output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`)
}
output.push(`repo=${process.env.GH_REPO}`)
if (process.env.GITHUB_OUTPUT) {
await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n"))
}

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.9",
"version": "1.2.10",
"publisher": "sst-dev",
"repository": {
"type": "git",