Compare commits

..

46 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
Brendan Allan
1c2416b6de desktop: don't spawn sidecar if default is localhost server 2026-02-20 12:18:39 +08:00
Rafi Khardalian
d86c10816d docs: clarify tool name collision precedence (#14313)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 22:00:06 -06:00
Aiden Cline
04a634a80d test: merge test files into a single file (#14366) 2026-02-19 20:32:01 -06:00
opencode
1eb6caa3c6 release: v1.2.9 2026-02-20 01:45:20 +00:00
Aiden Cline
1a329ba47d fix: issue from structuredClone addition by using unwrap (#14359) 2026-02-19 19:28:06 -06:00
David Hill
8d781b08ce tweak(ui): adjust session feed spacing 2026-02-20 01:22:12 +00:00
David Hill
8b99ac6513 tweak(ui): tone down reasoning emphasis 2026-02-20 01:17:48 +00:00
David Hill
63a469d0ce tweak(ui): refine session feed spacing 2026-02-20 01:17:48 +00:00
David Hill
ae98be83b3 fix(desktop): restore settings header mask 2026-02-19 18:37:56 -06:00
David Hill
a3181d5fbd tweak(ui): nudge edited files chevron 2026-02-19 18:37:56 -06:00
David Hill
998c8bf3a5 tweak(ui): stabilize collapsible chevron hover 2026-02-19 18:37:56 -06:00
NatChung
d2d7a37bca fix: add missing id/sessionID/messageID to MCP tool attachments (#14345) 2026-02-19 18:37:56 -06:00
Michael Hart
8ad60b1ec2 Use structuredClone instead of remeda's clone (#14351)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
2026-02-19 18:37:56 -06:00
Dax
01d518708a remove unnecessary deep clones from session loop and LLM stream (#14354) 2026-02-19 18:37:55 -06:00
Dax Raad
ae50f24c06 fix(web): correct config import path in Korean enterprise docs 2026-02-19 18:37:55 -06:00
Jay V
d32dd4d7fd docs: update providers layout and Windows sidebar label 2026-02-19 18:37:55 -06:00
Dax Raad
cb5a0de42f core: remove User-Agent header assertion from LLM test to fix failing test 2026-02-19 18:37:55 -06:00
opencode
f2090b26c1 release: v1.2.8 2026-02-19 22:38:42 +00:00
112 changed files with 2292 additions and 1056 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.7",
"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.7",
"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.7",
"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.7",
"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.7",
"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.7",
"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.7",
"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.7",
"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.7",
"version": "1.2.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -315,6 +315,7 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -330,7 +331,6 @@
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"node-pty": "1.2.0-beta.11",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -376,7 +376,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.7",
"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.7",
"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.7",
"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.7",
"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.7",
"version": "1.2.10",
"dependencies": {
"zod": "catalog:",
},
@@ -473,7 +473,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.7",
"version": "1.2.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2222,6 +2222,8 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -3296,8 +3298,6 @@
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
"node-pty": ["node-pty@1.2.0-beta.11", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],

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

@@ -11,7 +11,8 @@
"dev:web": "bun --cwd packages/app dev",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"postinstall": "bun packages/opencode/script/node-pty-helper.ts",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {

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.7",
"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>
)
@@ -418,7 +430,7 @@ export const SettingsGeneral: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div>

View File

@@ -370,7 +370,7 @@ export const SettingsKeybinds: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>

View File

@@ -59,7 +59,7 @@ export const SettingsModels: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">

View File

@@ -177,7 +177,7 @@ export const SettingsPermissions: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>

View File

@@ -132,7 +132,7 @@ export const SettingsProviders: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
</div>

View File

@@ -15,40 +15,6 @@ import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
const FRAME_META = 0
const FRAME_OUTPUT = 1
const FRAME_INPUT = 2
const encoder = new TextEncoder()
const connection = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const frameInput = (id: string, data: string) => {
const channel = encoder.encode(id)
const body = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + body.length)
out[0] = FRAME_INPUT
out[1] = channel.length
out.set(channel, 2)
out.set(body, 2 + channel.length)
return out
}
const frameOutput = (bytes: Uint8Array, decoder: TextDecoder) => {
if (bytes[0] !== FRAME_OUTPUT) return
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return
if (bytes.length < 2 + size) return
return {
connection: decoder.decode(bytes.subarray(2, 2 + size)),
data: decoder.decode(bytes.subarray(2 + size)),
}
}
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
@@ -430,10 +396,8 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const connectionID = connection()
const onData = t.onData((data) => {
if (ws?.readyState !== WebSocket.OPEN) return
ws.send(frameInput(connectionID, data))
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -486,7 +450,6 @@ export const Terminal = (props: TerminalProps) => {
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.searchParams.set("connection", connectionID)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
@@ -508,33 +471,24 @@ export const Terminal = (props: TerminalProps) => {
if (closing) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] === FRAME_META) {
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown; connection?: unknown }
if (typeof meta?.connection === "string" && meta.connection !== connectionID) return
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
return
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
const frame = frameOutput(bytes, decoder)
if (!frame) return
if (frame.connection !== connectionID) return
if (!frame.data) return
output?.push(frame.data)
cursor += frame.data.length
return
}
if (typeof event.data === "string") {
debugTerminal("ignoring unframed websocket output")
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@@ -586,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.7",
"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.7",
"version": "1.2.10",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.7",
"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.7",
"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.7",
"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

@@ -40,7 +40,9 @@ use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -605,6 +607,7 @@ async fn initialize(app: AppHandle) {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
@@ -631,7 +634,7 @@ async fn initialize(app: AppHandle) {
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, password })
Ok(ServerReadyData { url, username,password, is_sidecar: true })
}
.map(move |res| {
let _ = server_ready_tx.send(res);
@@ -641,7 +644,9 @@ async fn initialize(app: AppHandle) {
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
@@ -719,6 +724,7 @@ enum ServerConnection {
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
@@ -730,11 +736,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
{
tracing::info!(%url, "Connected to custom server");
return ServerConnection::Existing { url: url.clone() };
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
}
let local_port = get_sidecar_port();
@@ -755,6 +765,7 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,

View File

@@ -150,7 +150,7 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
if url_is_localhost(&url) {
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
@@ -178,6 +178,10 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")

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

@@ -35,7 +35,9 @@ export type LoadingWindowComplete = null;
export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -23,7 +23,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -31,7 +31,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, type InitStep } from "./bindings"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -452,16 +452,19 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
@@ -485,39 +488,22 @@ render(() => {
)
}, root!)
type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
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.7",
"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.7"
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.7/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.7/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.7/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.7/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.7/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.7",
"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.7",
"version": "1.2.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -100,7 +100,7 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"node-pty": "1.2.0-beta.11",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",

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()
@@ -142,8 +147,6 @@ if (!skipInstall) {
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
}
await $`bun script/node-pty-helper.ts`
for (const item of targets) {
const name = [
pkg.name,
@@ -173,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,
@@ -216,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

@@ -1,45 +0,0 @@
#!/usr/bin/env bun
import fs from "node:fs"
import path from "node:path"
import { createRequire } from "node:module"
const req = createRequire(import.meta.url)
const resolve = () => {
try {
return path.dirname(req.resolve("node-pty/package.json"))
} catch {
return
}
}
export const fixNodePtyHelper = () => {
const root = resolve()
if (!root) return []
const files = [
path.join(root, "prebuilds", "darwin-arm64", "spawn-helper"),
path.join(root, "prebuilds", "darwin-x64", "spawn-helper"),
path.join(root, "build", "Release", "spawn-helper"),
path.join(root, "build", "Debug", "spawn-helper"),
]
return files.flatMap((file) => {
if (!fs.existsSync(file)) return []
const mode = fs.statSync(file).mode
const next = mode | 0o111
if (mode === next) return []
fs.chmodSync(file, next)
return [file]
})
}
if (import.meta.main) {
const changed = fixNodePtyHelper()
if (!changed.length) process.exit(0)
console.log(`updated node-pty spawn-helper permissions (${changed.length})`)
for (const file of changed) {
console.log(`- ${file}`)
}
}

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

@@ -2,8 +2,7 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
@@ -83,7 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
const entry = clone(item)
const entry = structuredClone(unwrap(item))
let trimmed = false
setStore(
produce((draft) => {

View File

@@ -2,8 +2,7 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createStore, produce, unwrap } from "solid-js/store"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { PromptInfo } from "./history"
@@ -53,7 +52,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = clone({ ...entry, timestamp: Date.now() })
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
let trimmed = false
setStore(
produce((draft) => {

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

@@ -1,10 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "node-pty"
import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
@@ -14,27 +15,22 @@ export namespace Pty {
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const FRAME_META = 0
const FRAME_OUTPUT = 1
const FRAME_INPUT = 2
const MAX_CONNECTION = 200
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
type Subscriber = {
id: number
connection: string
token: unknown
}
const sockets = new WeakMap<object, number>()
const owners = new WeakMap<object, string>()
let socketCounter = 0
let connectionCounter = 0
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
@@ -43,74 +39,58 @@ export namespace Pty {
return next
}
const connection = () => {
connectionCounter = (connectionCounter + 1) % Number.MAX_SAFE_INTEGER
return `${Date.now().toString(36)}-${connectionCounter.toString(36)}`
}
const token = (ws: Socket) => {
const data = ws.data
if (data === undefined) return
if (data === null) return
if (typeof data !== "object") return data
const normalizeConnection = (value?: string) => {
const next = typeof value === "string" ? value.trim() : ""
if (!next) return connection()
if (next.length > MAX_CONNECTION) return connection()
if (encoder.encode(next).length > 255) return connection()
return next
}
const id = (data as { connId?: unknown }).connId
if (typeof id === "number" || typeof id === "string") return id
const output = (connection: string, data: string) => {
const channel = encoder.encode(connection)
const chunk = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + chunk.length)
out[0] = FRAME_OUTPUT
out[1] = channel.length
out.set(channel, 2)
out.set(chunk, 2 + channel.length)
return out
}
const href = (data as { href?: unknown }).href
if (typeof href === "string") return href
const input = (message: string | Uint8Array | ArrayBuffer) => {
if (typeof message === "string") {
return { data: message }
const url = (data as { url?: unknown }).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 bytes = message instanceof Uint8Array ? message : new Uint8Array(message)
if (bytes[0] !== FRAME_INPUT) return
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return
if (bytes.length < 2 + size) return
return {
connection: decoder.decode(bytes.subarray(2, 2 + size)),
data: decoder.decode(bytes.subarray(2 + size)),
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
}
// WebSocket control frame: 0x00 + UTF-8 JSON ({ cursor, connection }).
const meta = (cursor: number, connection: string) => {
const json = JSON.stringify({ cursor, connection })
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = FRAME_META
out[0] = 0
out.set(bytes, 1)
return out
}
type Spawn = (file: string, args: string | string[], options: unknown) => IPty
let override: Spawn | undefined
let spawn: Spawn | undefined
const pty = async (): Promise<Spawn> => {
if (override) return override
if (spawn) return spawn
const mod = await import("node-pty")
const next = mod.spawn as Spawn
spawn = next
return next
}
export function setSpawn(input?: Spawn) {
override = input
if (input) return
spawn = undefined
}
const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
return spawn
})
export const Info = z
.object({
@@ -255,8 +235,13 @@ export namespace Pty {
continue
}
if (token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(output(sub.connection, chunk))
ws.send(chunk)
} catch {
session.subscribers.delete(ws)
}
@@ -332,7 +317,7 @@ export namespace Pty {
}
}
export function connect(id: string, ws: Socket, cursor?: number, connectionID?: string) {
export function connect(id: string, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
@@ -352,11 +337,7 @@ export namespace Pty {
}
owners.set(ws, id)
const sub = {
id: socketId,
connection: normalizeConnection(connectionID),
}
session.subscribers.set(ws, sub)
session.subscribers.set(ws, { id: socketId, token: token(ws) })
const cleanup = () => {
session.subscribers.delete(ws)
@@ -380,7 +361,7 @@ export namespace Pty {
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(output(sub.connection, data.slice(i, i + BUFFER_CHUNK)))
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
@@ -390,18 +371,15 @@ export namespace Pty {
}
try {
ws.send(meta(end, sub.connection))
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | Uint8Array | ArrayBuffer) => {
const next = input(message)
if (!next?.data) return
if (next.connection && next.connection !== sub.connection) return
session.process.write(next.data)
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
},
onClose: () => {
log.info("client disconnected from session", { id })

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

@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { upgradeWebSocket } from "../websocket"
export const PtyRoutes = lazy(() =>
new Hono()
@@ -158,12 +158,6 @@ export const PtyRoutes = lazy(() =>
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
const connection = (() => {
const value = c.req.query("connection")
if (!value) return
if (value.length > 200) return
return value
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
@@ -183,20 +177,15 @@ export const PtyRoutes = lazy(() =>
return {
onOpen(_event, ws) {
if (!isSocket(ws)) {
const socket = ws.raw
if (!isSocket(socket)) {
ws.close()
return
}
handler = Pty.connect(id, ws, cursor, connection)
handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
if (
typeof event.data !== "string" &&
!(event.data instanceof ArrayBuffer) &&
!(event.data instanceof Uint8Array)
) {
return
}
if (typeof event.data !== "string") return
handler?.onMessage(event.data)
},
onClose() {

View File

@@ -33,7 +33,7 @@ import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "./websocket"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"

View File

@@ -1 +0,0 @@
export { createBunWebSocket, upgradeWebSocket, websocket } from "hono/bun"

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

@@ -11,7 +11,7 @@ import {
tool,
jsonSchema,
} from "ai"
import { clone, mergeDeep, pipe } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
@@ -80,15 +80,11 @@ export namespace LLM {
)
const header = system[0]
const original = clone(system)
await Plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length === 0) {
system.push(...original)
}
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)

View File

@@ -22,7 +22,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { defer } from "../util/defer"
import { clone } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
@@ -627,11 +626,9 @@ export namespace SessionPrompt {
})
}
const sessionMessages = clone(msgs)
// Ephemerally wrap queued user messages with a reminder to stay on track
if (step > 1 && lastFinished) {
for (const msg of sessionMessages) {
for (const msg of msgs) {
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) continue
@@ -648,7 +645,7 @@ export namespace SessionPrompt {
}
}
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
@@ -664,7 +661,7 @@ export namespace SessionPrompt {
sessionID,
system,
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
? [
{
@@ -909,7 +906,12 @@ export namespace SessionPrompt {
title: "",
metadata,
output: truncated.content,
attachments,
attachments: attachments.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
content: result.content, // directly return content to preserve ordering when outputting to model
}
}

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

@@ -1,73 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const input = (connection: string, data: string) => {
const channel = encoder.encode(connection)
const body = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + body.length)
out[0] = 2
out[1] = channel.length
out.set(channel, 2)
out.set(body, 2 + channel.length)
return out
}
const output = (connection: string, data: unknown) => {
if (typeof data === "string") return data
if (!(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return ""
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
if (bytes[0] !== 1) return ""
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return ""
if (bytes.length < 2 + size) return ""
const id = decoder.decode(bytes.subarray(2, 2 + size))
if (id !== connection) return ""
return decoder.decode(bytes.subarray(2 + size))
}
const spawn = () => {
let pid = 1000
return () => {
const data = new Set<(chunk: string) => void>()
const exit = new Set<(event: { exitCode: number }) => void>()
let closed = false
return {
pid: ++pid,
onData: (cb: (chunk: string) => void) => {
data.add(cb)
},
onExit: (cb: (event: { exitCode: number }) => void) => {
exit.add(cb)
},
resize: () => {},
write: (chunk: string) => {
if (closed) return
for (const cb of data) cb(chunk)
},
kill: () => {
if (closed) return
closed = true
for (const cb of exit) cb({ exitCode: 0 })
},
}
}
}
describe("pty", () => {
beforeEach(() => {
Pty.setSpawn(spawn() as unknown as Parameters<typeof Pty.setSpawn>[0])
})
afterEach(() => {
Pty.setSpawn()
})
test("does not leak output when websocket objects are reused", async () => {
await using dir = await tmpdir({ git: true })
@@ -82,9 +18,9 @@ describe("pty", () => {
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
const text = output("conn-a", data)
if (text) outA.push(text)
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
@@ -92,14 +28,14 @@ describe("pty", () => {
}
// Connect "a" first with ws.
Pty.connect(a.id, ws as any, undefined, "conn-a")
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
const text = output("conn-b", data)
if (text) outB.push(text)
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.connect(b.id, ws as any, undefined, "conn-b")
Pty.connect(b.id, ws as any)
// Clear connect metadata writes.
outA.length = 0
@@ -118,7 +54,7 @@ describe("pty", () => {
})
})
test("does not leak output when websocket objects are recycled before re-connect", async () => {
test("does not leak output when Bun recycles websocket objects before re-connect", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
@@ -131,9 +67,9 @@ describe("pty", () => {
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
const text = output("conn-a", data)
if (text) outA.push(text)
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
@@ -141,14 +77,14 @@ describe("pty", () => {
}
// Connect "a" first.
Pty.connect(a.id, ws as any, undefined, "conn-a")
Pty.connect(a.id, ws as any)
outA.length = 0
// Simulate websocket object reuse for another connection before
// the next onOpen calls Pty.connect.
// Simulate Bun reusing the same websocket object for another
// connection before the next onOpen calls Pty.connect.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
const text = output("conn-b", data)
if (text) outB.push(text)
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.write(a.id, "AAA\n")
@@ -162,7 +98,7 @@ describe("pty", () => {
})
})
test("drops input frames that carry a different connection id", async () => {
test("does not leak output when socket data mutates in-place", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
@@ -170,29 +106,35 @@ describe("pty", () => {
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const out: string[] = []
const outA: string[] = []
const outB: string[] = []
const ctx = { connId: 1 }
const ws = {
readyState: 1,
data: ctx,
send: (data: unknown) => {
const text = output("conn-a", data)
if (text) out.push(text)
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op
},
}
const handler = Pty.connect(a.id, ws as any, undefined, "conn-a")
out.length = 0
Pty.connect(a.id, ws as any)
outA.length = 0
handler?.onMessage(input("conn-b", "BBB\n"))
await Bun.sleep(100)
expect(out.join("")).not.toContain("BBB")
// 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"))
}
handler?.onMessage(input("conn-a", "AAA\n"))
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(out.join("")).toContain("AAA")
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

@@ -307,7 +307,6 @@ describe("session.llm.stream", () => {
expect(url.pathname.startsWith("/v1/")).toBe(true)
expect(url.pathname.endsWith("/chat/completions")).toBe(true)
expect(headers.get("Authorization")).toBe("Bearer test-key")
expect(headers.get("User-Agent") ?? "").toMatch(/^opencode\//)
expect(body.model).toBe(resolved.api.id)
expect(body.temperature).toBe(0.4)

View File

@@ -1,104 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})

View File

@@ -1,56 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})

View File

@@ -1,68 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

View File

@@ -0,0 +1,211 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

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.7",
"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.7",
"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.7",
"version": "1.2.10",
"type": "module",
"license": "MIT",
"scripts": {

View File

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

View File

@@ -26,13 +26,16 @@
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
will-change: opacity;
transform: translateZ(0);
}
[data-slot="collapsible-arrow-icon"] {
display: inline-flex;
color: var(--icon-weaker);
transform: rotate(-90deg);
transform: translateZ(0) rotate(-90deg);
transition: transform 0.15s ease;
will-change: transform;
}
&:hover [data-slot="collapsible-arrow"] {
@@ -74,7 +77,7 @@
}
[data-slot="collapsible-arrow-icon"] {
transform: rotate(0deg);
transform: translateZ(0) rotate(0deg);
}
}

View File

@@ -179,6 +179,7 @@
[data-component="text-part"] {
width: 100%;
margin-top: 24px;
[data-slot="text-part-body"] {
margin-top: 0;
@@ -227,13 +228,18 @@
[data-component="reasoning-part"] {
width: 100%;
color: var(--text-base);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
strong,
b {
color: var(--text-weak);
}
p:has(strong) {
margin-top: 24px;

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 {
@@ -129,6 +141,11 @@
gap: 8px;
flex-shrink: 0;
[data-slot="collapsible-arrow"] {
margin-left: -6px;
transform: translateY(2px);
}
[data-component="diff-changes"][data-variant="bars"] {
transform: translateY(1px);
}

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

@@ -117,6 +117,7 @@
--surface-weak: var(--smoke-light-alpha-3);
--surface-weaker: var(--smoke-light-alpha-4);
--surface-strong: #ffffff;
--surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha);
--surface-raised-stronger-non-alpha: var(--white);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
@@ -375,6 +376,7 @@
--surface-weak: var(--smoke-dark-alpha-4);
--surface-weaker: var(--smoke-dark-alpha-5);
--surface-strong: var(--smoke-dark-alpha-7);
--surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha);
--surface-raised-stronger-non-alpha: var(--smoke-dark-3);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);

Some files were not shown because too many files have changed in this diff Show More