diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 431581f596..8d4c9038a7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index 7886cf5f39..0c2e176d8b 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -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. diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index ed80f49d54..8ad0212ad0 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -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'") diff --git a/.opencode/tool/github-triage.txt b/.opencode/tool/github-triage.txt index 4369ed2351..1a2d69bdb5 100644 --- a/.opencode/tool/github-triage.txt +++ b/.opencode/tool/github-triage.txt @@ -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.) diff --git a/README.ar.md b/README.ar.md index f24e598d5e..aeb2f04b72 100644 --- a/README.ar.md +++ b/README.ar.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

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

+ + + + + OpenCode logo + + +

+

ওপেন সোর্স এআই কোডিং এজেন্ট।

+

+ Discord + npm + Build status +

+ +

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

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### ইনস্টলেশন (Installation) + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package managers +npm i -g opencode-ai@latest # or bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) +brew install opencode # macOS and Linux (official brew formula, updated less) +sudo pacman -S opencode # Arch Linux (Stable) +paru -S opencode-bin # Arch Linux (Latest from AUR) +mise use -g opencode # Any OS +nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch +``` + +> [!TIP] +> ইনস্টল করার আগে ০.১.x এর চেয়ে পুরোনো ভার্সনগুলো মুছে ফেলুন। + +### ডেস্কটপ অ্যাপ (BETA) + +OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন। + +| প্ল্যাটফর্ম | ডাউনলোড | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, or AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### ইনস্টলেশন ডিরেক্টরি (Installation Directory) + +ইনস্টল স্ক্রিপ্টটি ইনস্টলেশন পাতের জন্য নিম্নলিখিত অগ্রাধিকার ক্রম মেনে চলে: + +1. `$OPENCODE_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি +2. `$XDG_BIN_DIR` - XDG বেস ডিরেক্টরি স্পেসিফিকেশন সমর্থিত পাথ +3. `$HOME/bin` - সাধারণ ব্যবহারকারী বাইনারি ডিরেক্টরি (যদি বিদ্যমান থাকে বা তৈরি করা যায়) +4. `$HOME/.opencode/bin` - ডিফল্ট ফলব্যাক + +```bash +# উদাহরণ +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### এজেন্টস (Agents) + +OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়েছে যা আপনি `Tab` কি(key) দিয়ে পরিবর্তন করতে পারবেন। + +- **build** - ডিফল্ট, ডেভেলপমেন্টের কাজের জন্য সম্পূর্ণ অ্যাক্সেসযুক্ত এজেন্ট +- **plan** - বিশ্লেষণ এবং কোড এক্সপ্লোরেশনের জন্য রিড-ওনলি এজেন্ট + - ডিফল্টভাবে ফাইল এডিট করতে দেয় না + - ব্যাশ কমান্ড চালানোর আগে অনুমতি চায় + - অপরিচিত কোডবেস এক্সপ্লোর করা বা পরিবর্তনের পরিকল্পনা করার জন্য আদর্শ + +এছাড়াও জটিল অনুসন্ধান এবং মাল্টিস্টেপ টাস্কের জন্য একটি **general** সাবএজেন্ট অন্তর্ভুক্ত রয়েছে। +এটি অভ্যন্তরীণভাবে ব্যবহৃত হয় এবং মেসেজে `@general` লিখে ব্যবহার করা যেতে পারে। + +এজেন্টদের সম্পর্কে আরও জানুন: [docs](https://opencode.ai/docs/agents)। + +### ডকুমেন্টেশন (Documentation) + +কিভাবে OpenCode কনফিগার করবেন সে সম্পর্কে আরও তথ্যের জন্য, [**আমাদের ডকস দেখুন**](https://opencode.ai/docs)। + +### অবদান (Contributing) + +আপনি যদি OpenCode এ অবদান রাখতে চান, অনুগ্রহ করে একটি পুল রিকোয়েস্ট সাবমিট করার আগে আমাদের [কন্ট্রিবিউটিং ডকস](./CONTRIBUTING.md) পড়ে নিন। + +### OpenCode এর উপর বিল্ডিং (Building on OpenCode) + +আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই। + +### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ) + +#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা? + +ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো: + +- ১০০% ওপেন সোর্স +- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ। +- আউট-অফ-দ্য-বক্স LSP সাপোর্ট +- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি। +- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি। + +--- + +**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.br.md b/README.br.md index 4802c4996f..6044dad6c0 100644 --- a/README.br.md +++ b/README.br.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.bs.md b/README.bs.md index 9ad6852018..ef54a83695 100644 --- a/README.bs.md +++ b/README.bs.md @@ -33,7 +33,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.da.md b/README.da.md index 4b1302dbc3..3ffbbe8202 100644 --- a/README.da.md +++ b/README.da.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.de.md b/README.de.md index 16116dc72f..64c6628b45 100644 --- a/README.de.md +++ b/README.de.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.es.md b/README.es.md index 5c18ff4aca..875c8b0832 100644 --- a/README.es.md +++ b/README.es.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.fr.md b/README.fr.md index 0382164bed..254d38577b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.it.md b/README.it.md index c966ccec49..b1f75c2d2c 100644 --- a/README.it.md +++ b/README.it.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ja.md b/README.ja.md index 11109e7eb4..31d11dcf1a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ko.md b/README.ko.md index 23fea76b1e..5f9de2cf3f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.md b/README.md index 99b4b2c50f..620415c961 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.no.md b/README.no.md index 9b9e90dc38..125e181257 100644 --- a/README.no.md +++ b/README.no.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.pl.md b/README.pl.md index fced98dfc3..61ef5870c1 100644 --- a/README.pl.md +++ b/README.pl.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.ru.md b/README.ru.md index a7c590c16b..fed1101c0e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.th.md b/README.th.md index 0999167f23..01d68dd8dc 100644 --- a/README.th.md +++ b/README.th.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.tr.md b/README.tr.md index 67f84e4ddb..bfb18e1b43 100644 --- a/README.tr.md +++ b/README.tr.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.uk.md b/README.uk.md index 77e859a45d..ed20fbf236 100644 --- a/README.uk.md +++ b/README.uk.md @@ -33,7 +33,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.zh.md b/README.zh.md index 113d476b2e..c6d1c1d11f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/README.zht.md b/README.zht.md index b518104443..0dd44a9f0f 100644 --- a/README.zht.md +++ b/README.zht.md @@ -32,7 +32,8 @@ Português (Brasil) | ไทย | Türkçe | - Українська + Українська | + বাংলা

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) diff --git a/bun.lock b/bun.lock index 2240f30558..04da112cf7 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -75,7 +75,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -109,7 +109,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -136,7 +136,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -160,7 +160,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -184,7 +184,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -217,7 +217,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -246,7 +246,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -262,7 +262,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.9", + "version": "1.2.10", "bin": { "opencode": "./bin/opencode", }, @@ -376,7 +376,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -396,7 +396,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.9", + "version": "1.2.10", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -407,7 +407,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -420,7 +420,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -462,7 +462,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "zod": "catalog:", }, @@ -473,7 +473,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/github/action.yml b/github/action.yml index 8652bb8c15..3d983a1609 100644 --- a/github/action.yml +++ b/github/action.yml @@ -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 }} diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index d42c0fcebb..a7ccba6175 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -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 }) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index be0bc05717..5fad2c06b5 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -20,11 +20,8 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' -export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]' export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' -export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' -export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 93eaee5cb0..68d9929499 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -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) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 9fbcf79f5e..c2a8522eb0 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -9,7 +9,6 @@ import { settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, - settingsSoundsAgentEnabledSelector, settingsSoundsErrorsSelector, settingsSoundsPermissionsSelector, settingsThemeSelector, @@ -336,21 +335,19 @@ test("changing sound agent selection persists in localStorage", async ({ page, g expect(stored?.sounds?.agent).not.toBe("staplebops-01") }) -test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => { +test("selecting none disables agent sound", async ({ page, gotoSession }) => { await gotoSession() const dialog = await openSettings(page) const select = dialog.locator(settingsSoundsAgentSelector) - const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector) const trigger = select.locator('[data-slot="select-select-trigger"]') await expect(select).toBeVisible() - await expect(switchContainer).toBeVisible() await expect(trigger).toBeEnabled() - await switchContainer.locator('[data-slot="switch-control"]').click() - await page.waitForTimeout(100) - - await expect(trigger).toBeDisabled() + await trigger.click() + const items = page.locator('[data-slot="select-select-item"]') + await expect(items.first()).toBeVisible() + await items.first().click() const stored = await page.evaluate((key) => { const raw = localStorage.getItem(key) diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts index 87934b66e3..18991bf763 100644 --- a/packages/app/e2e/terminal/terminal-init.spec.ts +++ b/packages/app/e2e/terminal/terminal-init.spec.ts @@ -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) }) diff --git a/packages/app/package.json b/packages/app/package.json index 385205a0c1..b9397b0f40 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.9", + "version": "1.2.10", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b1c608ffcc..adfd592f8d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -89,6 +89,8 @@ const EXAMPLES = [ "prompt.example.25", ] as const +const NON_EMPTY_TEXT = /[^\s\u200B]/ + export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() @@ -636,7 +638,9 @@ export const PromptInput: Component = (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 = (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 = (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 = (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 = (props) => { handleInput() closePopover() + return true } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 9ea2e62a65..a9e4e49651 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -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 } @@ -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 diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts index 15e759f44a..3088522a59 100644 --- a/packages/app/src/components/prompt-input/editor-dom.test.ts +++ b/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -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")) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts index 4850a26ece..8575140d7d 100644 --- a/packages/app/src/components/prompt-input/editor-dom.ts +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -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) { diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 162e016c6f..1ea97c395c 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -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 ( -
{ + { scroll = el restoreScroll() }} @@ -336,6 +337,6 @@ export function SessionContextTab() {
- + ) } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index ae8fc200f2..825d1dab6c 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -452,7 +452,10 @@ export function SessionHeader() { variant: "ghost", class: "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active", - classList: { "rounded-r-none": share.shareUrl() !== undefined }, + classList: { + "rounded-r-none": share.shareUrl() !== undefined, + "border-r-0": share.shareUrl() !== undefined, + }, style: { scale: 1 }, }} trigger={{language.t("session.share.action.share")}} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index df71fd77e8..cf993840dc 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -20,12 +20,17 @@ let demoSoundState = { // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. -const playDemoSound = (src: string) => { +const stopDemoSound = () => { if (demoSoundState.cleanup) { demoSoundState.cleanup() } - clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined +} + +const playDemoSound = (src: string | undefined) => { + stopDemoSound() + if (!src) return demoSoundState.timeout = setTimeout(() => { demoSoundState.cleanup = playSound(src) @@ -132,11 +137,17 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const soundOptions = [...SOUND_OPTIONS] + const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] - const soundSelectProps = (current: () => string, set: (id: string) => void) => ({ + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ options: soundOptions, - current: soundOptions.find((o) => o.id === current()), + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, value: (o: (typeof soundOptions)[number]) => o.id, label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { @@ -145,6 +156,12 @@ export const SettingsGeneral: Component = () => { }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) set(option.id) playDemoSound(option.src) }, @@ -250,6 +267,18 @@ export const SettingsGeneral: Component = () => { )} + + +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
) @@ -307,66 +336,45 @@ export const SettingsGeneral: Component = () => { title={language.t("settings.general.sounds.agent.title")} description={language.t("settings.general.sounds.agent.description")} > -
-
- settings.sounds.setAgentEnabled(checked)} - /> -
- settings.sounds.agentEnabled(), + () => settings.sounds.agent(), + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + )} + /> -
-
- settings.sounds.setPermissionsEnabled(checked)} - /> -
- settings.sounds.permissionsEnabled(), + () => settings.sounds.permissions(), + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + )} + /> -
-
- settings.sounds.setErrorsEnabled(checked)} - /> -
- settings.sounds.errorsEnabled(), + () => settings.sounds.errors(), + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + )} + />
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index bd7ab24475..ce811463fc 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => { disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close() + if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fbcd0a8518..d279a7f321 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -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), diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 69a3a86cb2..e860a7e5d5 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -565,6 +565,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "بلا", "sound.option.alert01": "تنبيه 01", "sound.option.alert02": "تنبيه 02", "sound.option.alert03": "تنبيه 03", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 1c37317a37..e96a0195df 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -571,6 +571,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Nenhum", "sound.option.alert01": "Alerta 01", "sound.option.alert02": "Alerta 02", "sound.option.alert03": "Alerta 03", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 59bab1eb8b..1852afcd14 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -639,6 +639,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Nijedan", "sound.option.alert01": "Upozorenje 01", "sound.option.alert02": "Upozorenje 02", "sound.option.alert03": "Upozorenje 03", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index ce33ceec31..c5d2dc25f1 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -635,6 +635,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Ingen", "sound.option.alert01": "Alarm 01", "sound.option.alert02": "Alarm 02", "sound.option.alert03": "Alarm 03", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index cf3416be2d..34a80ee4c5 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -580,6 +580,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Keine", "sound.option.alert01": "Alarm 01", "sound.option.alert02": "Alarm 02", "sound.option.alert03": "Alarm 03", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8837dcbad0..7ba82066c7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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.", @@ -640,6 +642,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "None", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", "sound.option.alert03": "Alert 03", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index d741bb138b..28988bba1e 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -643,6 +643,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Ninguno", "sound.option.alert01": "Alerta 01", "sound.option.alert02": "Alerta 02", "sound.option.alert03": "Alerta 03", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 686539df4d..643c5e8211 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -579,6 +579,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Aucun", "sound.option.alert01": "Alerte 01", "sound.option.alert02": "Alerte 02", "sound.option.alert03": "Alerte 03", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 288351c8be..5f6e924025 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -569,6 +569,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "なし", "sound.option.alert01": "アラート 01", "sound.option.alert02": "アラート 02", "sound.option.alert03": "アラート 03", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 72a46ca7e6..d5a0b090b9 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -570,6 +570,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "없음", "sound.option.alert01": "알림 01", "sound.option.alert02": "알림 02", "sound.option.alert03": "알림 03", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c099fe61f9..10a8c1042f 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -642,6 +642,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Ingen", "sound.option.alert01": "Varsel 01", "sound.option.alert02": "Varsel 02", "sound.option.alert03": "Varsel 03", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 67c9dda2ac..9038fd1ad2 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -570,6 +570,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Brak", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", "sound.option.alert03": "Alert 03", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 57ef82fd66..69fee5c89a 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -640,6 +640,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "Нет", "sound.option.alert01": "Alert 01", "sound.option.alert02": "Alert 02", "sound.option.alert03": "Alert 03", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e67db04651..d66c8f6075 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -634,6 +634,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "ไม่มี", "sound.option.alert01": "เสียงเตือน 01", "sound.option.alert02": "เสียงเตือน 02", "sound.option.alert03": "เสียงเตือน 03", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 42740fa771..46daeb701f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -633,6 +633,7 @@ export const dict = { "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "无", "sound.option.alert01": "警报 01", "sound.option.alert02": "警报 02", "sound.option.alert03": "警报 03", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index f47fdede8f..bbb00727b7 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -629,6 +629,7 @@ export const dict = { "font.option.sourceCodePro": "Source Code Pro", "font.option.ubuntuMono": "Ubuntu Mono", "font.option.geistMono": "Geist Mono", + "sound.option.none": "無", "sound.option.alert01": "警報 01", "sound.option.alert02": "警報 02", "sound.option.alert03": "警報 03", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1a922d7257..a3f4b7164b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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() diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 1ccac937c3..fd2ced3dc8 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -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) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index ebc1f59227..032756cabd 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -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 ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- -
- -
-
{path()?.split("/").pop()}
-
{language.t("session.files.binaryContent")}
+ + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll as any} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + />
-
- - {renderCode(contents(), "pb-40")} - -
{language.t("common.loading")}...
-
- {(err) =>
{err()}
}
- + + +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ +
+ +
+
{path()?.split("/").pop()}
+
{language.t("session.files.binaryContent")}
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+ + ) } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 567ef5fc87..b13ccb474a 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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: {
-
{ 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: { )}
-
+ ) diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3a9f63949a..9349e99376 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -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} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 33421c3869..27ea4e6f31 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -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() {
- - {(pty) => ( -
- - terminal.clone(pty.id)} /> - -
+ + {(id) => ( + + {(pty) => ( +
+ terminal.clone(id)} /> +
+ )} +
)} -
+
diff --git a/packages/console/app/package.json b/packages/console/app/package.json index acea3fd6a4..904aeadd8e 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/download/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts similarity index 70% rename from packages/console/app/src/routes/download/[platform].ts rename to packages/console/app/src/routes/download/[channel]/[platform].ts index 2c30a80362..9a52842639 100644 --- a/packages/console/app/src/routes/download/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -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 = { "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", @@ -17,17 +17,20 @@ const downloadNames: Record = { "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] diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index e5e4e97502..0278d8622b 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -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) { diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index 97f95278a1..a4b64889ca 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -36,7 +36,7 @@ const getModelsInfo = query(async (workspaceID: string) => { "use server" return withActor(async () => { return { - all: Object.entries(ZenData.list().models) + all: Object.entries(ZenData.list("full").models) .filter(([id, _model]) => !["claude-3-5-haiku"].includes(id)) .filter(([id, _model]) => !id.startsWith("alpha-")) .sort(([idA, modelA], [idB, modelB]) => { diff --git a/packages/console/app/src/routes/zen/lite/v1/chat/completions.ts b/packages/console/app/src/routes/zen/lite/v1/chat/completions.ts new file mode 100644 index 0000000000..9a57e893fb --- /dev/null +++ b/packages/console/app/src/routes/zen/lite/v1/chat/completions.ts @@ -0,0 +1,12 @@ +import type { APIEvent } from "@solidjs/start/server" +import { handler } from "~/routes/zen/util/handler" + +export function POST(input: APIEvent) { + return handler(input, { + format: "oa-compat", + modelList: "lite", + parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], + parseModel: (url: string, body: any) => body.model, + parseIsStream: (url: string, body: any) => !!body.stream, + }) +} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index a8e275ba9a..5f2b51c21e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -44,6 +44,7 @@ export async function handler( input: APIEvent, opts: { format: ZenData.Format + modelList: "lite" | "full" parseApiKey: (headers: Headers) => string | undefined parseModel: (url: string, body: any) => string parseIsStream: (url: string, body: any) => boolean @@ -77,7 +78,7 @@ export async function handler( request: requestId, client: ocClient, }) - const zenData = ZenData.list() + const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId, projectId) const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) @@ -107,11 +108,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) + "...") diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index db2dfa5215..596b38cc5a 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -18,9 +18,10 @@ export const openaiHelper: ProviderHelper = () => ({ modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { headers.set("authorization", `Bearer ${apiKey}`) }, - modifyBody: (body: Record) => { - return body - }, + modifyBody: (body: Record, workspaceID?: string) => ({ + ...body, + ...(workspaceID ? { safety_identifier: workspaceID } : {}), + }), createBinaryStreamDecoder: () => undefined, streamSeparator: "\n\n", createUsageParser: () => { diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index 5f8b631cf0..1f9492845f 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -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, apiKey: string) => void - modifyBody: (body: Record) => Record + modifyBody: (body: Record, workspaceID?: string) => Record createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined streamSeparator: string createUsageParser: () => { diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index 6554591293..e9e05197e2 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler" export function POST(input: APIEvent) { return handler(input, { format: "oa-compat", + modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, parseIsStream: (url: string, body: any) => !!body.stream, diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 54d223f95a..9c09315a6e 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler" export function POST(input: APIEvent) { return handler(input, { format: "anthropic", + modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, parseModel: (url: string, body: any) => body.model, parseIsStream: (url: string, body: any) => !!body.stream, diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index ee2b3ab541..f9c14ededd 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -17,7 +17,7 @@ export async function OPTIONS(input: APIEvent) { } export async function GET(input: APIEvent) { - const zenData = ZenData.list() + const zenData = ZenData.list("full") const disabledModels = await authenticate() return new Response( diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index b20378e379..a4edd5861a 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler" export function POST(input: APIEvent) { return handler(input, { format: "google", + modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", parseIsStream: (url: string, body: any) => diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index a82a667cc7..cae625cf6f 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -4,6 +4,7 @@ import { handler } from "~/routes/zen/util/handler" export function POST(input: APIEvent) { return handler(input, { format: "openai", + modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], parseModel: (url: string, body: any) => body.model, parseIsStream: (url: string, body: any) => !!body.stream, diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f5b0b3965b..a99f1ec323 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.9", + "version": "1.2.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 6011cac376..e868b176e8 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -73,6 +73,7 @@ export namespace ZenData { const ModelsSchema = z.object({ models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])), + liteModels: z.record(z.string(), ModelSchema), providers: z.record(z.string(), ProviderSchema), providerFamilies: z.record(z.string(), ProviderFamilySchema), }) @@ -81,7 +82,7 @@ export namespace ZenData { return input }) - export const list = fn(z.void(), () => { + export const list = fn(z.enum(["lite", "full"]), (modelList) => { const json = JSON.parse( Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value + @@ -114,9 +115,9 @@ export namespace ZenData { Resource.ZEN_MODELS29.value + Resource.ZEN_MODELS30.value, ) - const { models, providers, providerFamilies } = ModelsSchema.parse(json) + const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json) return { - models, + models: modelList === "lite" ? liteModels : models, providers: Object.fromEntries( Object.entries(providers).map(([id, provider]) => [ id, diff --git a/packages/console/function/package.json b/packages/console/function/package.json index d054585834..386ee19df2 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.9", + "version": "1.2.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index bcadba0005..7a08244bb6 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.9", + "version": "1.2.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2eb532807e..dc25cb0203 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 130958bf7c..acab0fa703 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -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); diff --git a/packages/desktop/src-tauri/tauri.beta.conf.json b/packages/desktop/src-tauri/tauri.beta.conf.json new file mode 100644 index 0000000000..5207c73fc1 --- /dev/null +++ b/packages/desktop/src-tauri/tauri.beta.conf.json @@ -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"] + } + } +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 4a28e1b49d..983fe39456 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -491,34 +491,19 @@ render(() => { // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) { const [serverData] = createResource(() => commands.awaitInitialization(new Channel() as any)) + if (serverData.state === "errored") throw serverData.error return ( - -
-

Failed to start server

-

- {String(serverData.error ?? "Unknown error")} -

-
+
+
} > - - -
-
- } - > - {(data) => props.children(data())} -
+ {(data) => props.children(data())} ) } diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 04d42b55f6..fae66ab31a 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.9", + "version": "1.2.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d4c308fc50..a112d793fd 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.2.9" +version = "1.2.10" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.10/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b8b3f45f22..c67be67096 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.2.9", + "version": "1.2.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index d73bbce267..a7674ce2f8 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -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", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f9e66d61d5..f891273737 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.9", + "version": "1.2.10", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index ddb4769912..34e80d71a0 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -1,10 +1,10 @@ #!/usr/bin/env bun -import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" -import path from "path" -import fs from "fs" import { $ } from "bun" +import fs from "fs" +import path from "path" import { fileURLToPath } from "url" +import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -12,8 +12,9 @@ const dir = path.resolve(__dirname, "..") process.chdir(dir) -import pkg from "../package.json" import { Script } from "@opencode-ai/script" +import pkg from "../package.json" + const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" // Fetch and generate models.dev snapshot const modelsData = process.env.MODELS_DEV_API_JSON @@ -26,7 +27,11 @@ await Bun.write( console.log("Generated models-snapshot.ts") // Load migrations from migration directories -const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true })) +const migrationDirs = ( + await fs.promises.readdir(path.join(dir, "migration"), { + withFileTypes: true, + }) +) .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name)) .map((entry) => entry.name) .sort() @@ -171,7 +176,6 @@ for (const item of targets) { compile: { autoloadBunfig: false, autoloadDotenv: false, - //@ts-ignore (bun types aren't up to date) autoloadTsconfig: true, autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, @@ -214,7 +218,7 @@ if (Script.release) { await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`) } } - await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber` + await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}` } export { binaries } diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index e8b5e995cc..98f23e16fb 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -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) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 9e28ea16cf..672e73d49a 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -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, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3118847199..aad0fd76c4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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) { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 24dc695d63..e65d21bfd6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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() - for (const [_name, fn] of Object.entries(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() + for (const [_name, fn] of Object.entries(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 { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 2dda403e14..33083485b5 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -41,13 +41,38 @@ export namespace Pty { const token = (ws: Socket) => { const data = ws.data - if (!data || typeof data !== "object") return + if (data === undefined) return + if (data === null) return + if (typeof data !== "object") return data - const events = (data as { events?: unknown }).events - if (events && typeof events === "object") return events + const id = (data as { connId?: unknown }).connId + if (typeof id === "number" || typeof id === "string") return id + + const href = (data as { href?: unknown }).href + if (typeof href === "string") return href const url = (data as { url?: unknown }).url - if (url && typeof url === "object") return url + if (typeof url === "string") return url + if (url && typeof url === "object") { + const href = (url as { href?: unknown }).href + if (typeof href === "string") return href + return url + } + + const events = (data as { events?: unknown }).events + if (typeof events === "number" || typeof events === "string") return events + if (events && typeof events === "object") { + const id = (events as { connId?: unknown }).connId + if (typeof id === "number" || typeof id === "string") return id + + const id2 = (events as { connection?: unknown }).connection + if (typeof id2 === "number" || typeof id2 === "string") return id2 + + const id3 = (events as { id?: unknown }).id + if (typeof id3 === "number" || typeof id3 === "string") return id3 + + return events + } return data } @@ -210,7 +235,7 @@ export namespace Pty { continue } - if (sub.token !== undefined && token(ws) !== sub.token) { + if (token(ws) !== sub.token) { session.subscribers.delete(ws) continue } diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 3c28331bd5..8d156c03d8 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -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({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b07a049c80..8454a9c3e9 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -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 + export const ProjectInfo = z + .object({ + id: z.string(), + name: z.string().optional(), + worktree: z.string(), + }) + .meta({ + ref: "ProjectSummary", + }) + export type ProjectInfo = z.output + + export const GlobalInfo = Info.extend({ + project: ProjectInfo.nullable(), + }).meta({ + ref: "GlobalSession", + }) + export type GlobalInfo = z.output + 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() + + 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) => diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a1c2b57812..83cc467e42 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -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 { 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() + } } diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 1b89a63742..07e86ea97b 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -97,4 +97,48 @@ describe("pty", () => { }, }) }) + + test("does not leak output when socket data mutates in-place", async () => { + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const a = await Pty.create({ command: "cat", title: "a" }) + try { + const outA: string[] = [] + const outB: string[] = [] + + const ctx = { connId: 1 } + const ws = { + readyState: 1, + data: ctx, + send: (data: unknown) => { + outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op + }, + } + + Pty.connect(a.id, ws as any) + outA.length = 0 + + // Simulate the runtime mutating per-connection data without + // swapping the reference (ws.data stays the same object). + ctx.connId = 2 + ws.send = (data: unknown) => { + outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + } + + Pty.write(a.id, "AAA\n") + await Bun.sleep(100) + + expect(outB.join("")).not.toContain("AAA") + } finally { + await Pty.remove(a.id) + } + }, + }) + }) }) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts new file mode 100644 index 0000000000..05d6de04b1 --- /dev/null +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -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) + }) +}) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b54cb8b8a6..9a0622c4a5 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -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({ diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 64c34e3f59..623a117929 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 0f71b281d0..4fe0794d0c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index af79c44a17..b4848e6054 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }, + options?: Options, + ) { + 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({ + 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 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e1..4050ef1573 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + } + 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 +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + export type ExperimentalResourceListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 85a1af9d70..2741c2362e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 2675833f4c..d000cb4799 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4e70f7a810..3519996085 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.2.9", + "version": "1.2.10", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 3415c034cf..07a718141a 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -490,8 +490,10 @@ } [data-component="edit-content"] { + border-radius: inherit; border-top: 1px solid var(--border-weaker-base); max-height: 420px; + overflow-x: hidden; overflow-y: auto; scrollbar-width: none; @@ -500,15 +502,24 @@ &::-webkit-scrollbar { display: none; } + + [data-component="diff"] { + border-radius: inherit; + overflow: hidden; + } } [data-component="write-content"] { + border-radius: inherit; border-top: 1px solid var(--border-weaker-base); max-height: 240px; + overflow-x: hidden; overflow-y: auto; [data-component="code"] { - padding-bottom: 0px !important; + padding-bottom: 0 !important; + border-radius: inherit; + overflow: hidden; } /* Hide scrollbar */ @@ -1285,6 +1296,8 @@ } [data-component="apply-patch-file-diff"] { + border-radius: inherit; + overflow-x: hidden; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; @@ -1292,6 +1305,11 @@ &::-webkit-scrollbar { display: none; } + + [data-component="diff"] { + border-radius: inherit; + overflow: hidden; + } } [data-component="tool-loaded-file"] { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4b223bf35a..828ddbe87d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -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 @@ -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(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} /> )} @@ -475,6 +482,7 @@ export function Message(props: MessageProps) { message={assistantMessage() as AssistantMessage} parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} + showReasoningSummaries={props.showReasoningSummaries} /> )} @@ -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} /> ) @@ -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([]) let seeded = false @@ -1611,100 +1631,147 @@ ToolRegistry.register({ }) return ( -
- - 0}> - setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + + - - {(file) => { - const active = createMemo(() => expanded().includes(file.filePath)) - const [visible, setVisible] = createSignal(false) + 0}> + setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(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 ( - - - -
-
- -
- - {`\u202A${getDirectory(file.relativePath)}\u202C`} - - {getFilename(file.relativePath)} + return ( + + + +
+
+ +
+ + {`\u202A${getDirectory(file.relativePath)}\u202C`} + + {getFilename(file.relativePath)} +
+
+
+ + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + +
-
-
- - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - - - -
-
- - - - -
- -
-
-
- - ) - }} - - - - -
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+
+
+
+
+ } + > + {(file) => ( + +
+
+ + + + + + + {getFilename(file().relativePath)} + +
+ +
+ {getDirectory(file().relativePath)} +
+
+
+
+ + + +
+
+ } + > +
+ +
+ + )} +
) }, }) diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css new file mode 100644 index 0000000000..f81ae29766 --- /dev/null +++ b/packages/ui/src/components/scroll-view.css @@ -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; +} diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx new file mode 100644 index 0000000000..acc54c8c3e --- /dev/null +++ b/packages/ui/src/components/scroll-view.tsx @@ -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 ( +
setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + {...rest} + > + {/* Viewport */} +
{ + 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} +
+ + {/* Thumb Overlay */} + +
+ +
+ ) +} diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index ec1698d298..b9a2180cb8 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -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"] { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fd85fb4851..7f737032e7 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -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(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 ( -
{ + 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) => {
- - {(diff) => { + + {(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(diffImageSrc) + const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before)) + const [imageSrc, setImageSrc] = createSignal(diffImageSrc()) const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle") - const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) - const [audioSrc, setAudioSrc] = createSignal(diffAudioSrc) + const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before)) + const [audioSrc, setAudioSrc] = createSignal(diffAudioSrc()) const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") const [audioMime, setAudioMime] = createSignal(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 (
- +
- - {`\u202A${getDirectory(diff.file)}\u202C`} + + {`\u202A${getDirectory(file)}\u202C`} - {getFilename(diff.file)} + {getFilename(file)}
@@ -570,7 +591,7 @@ export const SessionReview = (props: SessionReviewProps) => { - + @@ -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) => {
- {diff.file} + {file}
@@ -633,7 +654,7 @@ export const SessionReview = (props: SessionReviewProps) => { { 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 : "", }} /> @@ -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) => {
-
+ ) } diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bf1258d2e5..9639e6635a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -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 { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 2aed8279ec..8e8a3f3875 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -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(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 && part.text?.trim()) 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(/]*>([\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((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(
- -
- -
-
0}>
+ +
+ + + {(text) => {text()}} + +
+
0 && !working()}>
diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 42c3a4ca3c..31862cc493 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -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, } } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index efe00e5f16..c0af0ac9b4 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -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); diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css index be305b4cbc..4318b9ec1d 100644 --- a/packages/ui/src/styles/tailwind/utilities.css +++ b/packages/ui/src/styles/tailwind/utilities.css @@ -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); diff --git a/packages/util/package.json b/packages/util/package.json index a1417edd55..4bcbb0305d 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.9", + "version": "1.2.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index ba9ec45ba1..110c6ca235 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.9", + "version": "1.2.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/script/publish.ts b/script/publish.ts index 1294f8d793..8aa921daa8 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -57,13 +57,16 @@ await $`bun install` await import(`../packages/sdk/js/script/build.ts`) if (Script.release) { - await $`git commit -am "release: v${Script.version}"` - await $`git tag v${Script.version}` - await $`git fetch origin` - await $`git cherry-pick HEAD..origin/dev`.nothrow() - await $`git push origin HEAD --tags --no-verify --force-with-lease` - await new Promise((resolve) => setTimeout(resolve, 5_000)) - await $`gh release edit v${Script.version} --draft=false` + if (!Script.preview) { + await $`git commit -am "release: v${Script.version}"` + await $`git tag v${Script.version}` + await $`git fetch origin` + await $`git cherry-pick HEAD..origin/dev`.nothrow() + await $`git push origin HEAD --tags --no-verify --force-with-lease` + await new Promise((resolve) => setTimeout(resolve, 5_000)) + } + + await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}` } console.log("\n=== cli ===\n") diff --git a/script/version.ts b/script/version.ts index e011f44539..71619f4618 100755 --- a/script/version.ts +++ b/script/version.ts @@ -17,8 +17,16 @@ if (!Script.preview) { const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) +} else if (Script.channel === "beta") { + await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}` + const release = + await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json() + output.push(`release=${release.databaseId}`) + output.push(`tag=${release.tagName}`) } +output.push(`repo=${process.env.GH_REPO}`) + if (process.env.GITHUB_OUTPUT) { await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n")) } diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1cfd625ac0..2e2807923e 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.2.9", + "version": "1.2.10", "publisher": "sst-dev", "repository": { "type": "git",