Merge remote-tracking branch 'origin/sqlite2' into sqlite2

This commit is contained in:
Dax Raad 2026-02-01 20:26:24 -05:00
commit 5f552534c7
121 changed files with 3044 additions and 685 deletions

View file

@ -10,6 +10,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: write contents: write
pull-requests: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4

View file

@ -28,40 +28,98 @@ jobs:
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true" const dryRun = context.payload.inputs?.dryRun === "true"
const stalePrs = []
core.info(`Dry run mode: ${dryRun}`) core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
const prs = await github.paginate(github.rest.pulls.list, { const query = `
owner, query($owner: String!, $repo: String!, $cursor: String) {
repo, repository(owner: $owner, name: $repo) {
state: "open", pullRequests(first: 100, states: OPEN, after: $cursor) {
per_page: 100, pageInfo {
sort: "updated", hasNextPage
direction: "asc", endCursor
}) }
nodes {
number
title
author {
login
}
createdAt
commits(last: 1) {
nodes {
commit {
committedDate
}
}
}
comments(last: 1) {
nodes {
createdAt
}
}
reviews(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
`
for (const pr of prs) { const allPrs = []
const lastUpdated = new Date(pr.updated_at) let cursor = null
if (lastUpdated > cutoff) { let hasNextPage = true
core.info(`PR ${pr.number} is fresh`)
continue while (hasNextPage) {
const result = await github.graphql(query, {
owner,
repo,
cursor,
})
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
}
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
if (!lastActivity || lastActivity > cutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
} }
stalePrs.push(pr) core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
} return true
})
if (!stalePrs.length) { if (!stalePrs.length) {
core.info("No stale pull requests found.") core.info("No stale pull requests found.")
return return
} }
core.info(`Found ${stalePrs.length} stale pull requests`)
for (const pr of stalePrs) { for (const pr of stalePrs) {
const issue_number = pr.number const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) { if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`) core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
continue continue
} }
@ -79,5 +137,5 @@ jobs:
state: "closed", state: "closed",
}) })
core.info(`Closed PR #${issue_number} from ${pr.user.login}`) core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
} }

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -30,7 +30,8 @@
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -30,7 +30,8 @@
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

135
README.tr.md Normal file
View file

@ -0,0 +1,135 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Kurulum
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Paket yöneticileri
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
```
> [!TIP]
> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
### Masaüstü Uygulaması (BETA)
OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
| Platform | İndirme |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` veya AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Kurulum Dizini (Installation Directory)
Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
4. `$HOME/.opencode/bin` - Varsayılan yedek konum
```bash
# Örnekler
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
```
### Ajanlar
OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
- **plan** - Analiz ve kod keşfi için salt okunur ajan
- Varsayılan olarak dosya düzenlemelerini reddeder
- Bash komutlarını çalıştırmadan önce izin ister
- Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
### Dokümantasyon
OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
### Katkıda Bulunma
OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
### OpenCode Üzerine Geliştirme
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
### SSS
#### Bu Claude Code'dan nasıl farklı?
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
- %100 açık kaynak
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
- Kurulum gerektirmeyen hazır LSP desteği
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
---
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View file

@ -29,7 +29,9 @@
<a href="README.ru.md">Русский</a> | <a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> | <a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
- 100% 開源。 - 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。 - 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。 - 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。 - 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。 - 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
--- ---

View file

@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
rustc rustc
jq jq
makeWrapper makeWrapper
] ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [ buildInputs = lib.optionals stdenv.isLinux [
dbus dbus
@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
gst_all_1.gstreamer gst_all_1.gstreamer
gst_all_1.gst-plugins-base gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
]; ];
strictDeps = true; strictDeps = true;
@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
mainProgram = "opencode-desktop"; mainProgram = "opencode-desktop";
inherit (opencode.meta) platforms; inherit (opencode.meta) platforms;
}; };
}) })

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-LGI4XJj9WhBwnnqCXVNOTygrB0rBFIIcMjMm1ZuqIQI=", "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=",
"aarch64-linux": "sha256-0L89lS1RcFmiz9qBRHftdtAZVOtoTG6X0RgEpaLI1sQ=", "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=",
"aarch64-darwin": "sha256-QdwEcYDtgo/5HIK5WPpV8cf/aZrH9ref/Fh2vS3m/CU=", "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=",
"x86_64-darwin": "sha256-YLMPQzo0hnSo722WbC+Cp88Db6oyQ+o9NQM8z/7t4uw=" "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI="
} }
} }

View file

@ -46,15 +46,16 @@ stdenvNoCC.mkDerivation {
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d) export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \ bun install \
--cpu="${bunCpu}" \ --cpu="${bunCpu}" \
--os="${bunOs}" \ --os="${bunOs}" \
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress \ --no-progress
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts} bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts} bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild runHook postBuild

View file

@ -8,11 +8,15 @@ import {
sessionItemSelector, sessionItemSelector,
dropdownMenuTriggerSelector, dropdownMenuTriggerSelector,
dropdownMenuContentSelector, dropdownMenuContentSelector,
projectMenuTriggerSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector, titlebarRightSelector,
popoverBodySelector, popoverBodySelector,
listItemSelector, listItemSelector,
listItemKeySelector, listItemKeySelector,
listItemKeyStartsWithSelector, listItemKeyStartsWithSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors" } from "./selectors"
import type { createSdk } from "./utils" import type { createSdk } from "./utils"
@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) {
return { rightSection, popoverBody } return { rightSection, popoverBody }
} }
export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
await openProjectMenu(page, projectSlug)
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await toggle.click({ force: true })
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
return menu
}

View file

@ -0,0 +1,391 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import path from "node:path"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
test.describe.configure({ mode: "serial" })
import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
createTestProject,
openSidebar,
openWorkspaceMenu,
seedProjects,
setWorkspacesEnabled,
} from "../actions"
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
import { dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
const project = await createTestProject()
const rootSlug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
return { project, rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject()
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
} finally {
await cleanupTestProject(project)
}
})
test("can create a workspace", async ({ page, directory, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject()
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir)
} finally {
await cleanupTestProject(project)
}
})
test("can rename a workspace", async ({ page, directory, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
try {
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
} finally {
await cleanupTestProject(project)
}
})
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
try {
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
} finally {
await cleanupTestProject(project)
}
})
test("can delete a workspace", async ({ page, directory, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
try {
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
} finally {
await cleanupTestProject(project)
}
})
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject()
const rootSlug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
await cleanupTestProject(project)
}
})

View file

@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
export const titlebarRightSelector = "#opencode-titlebar-right" export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]' export const popoverBodySelector = '[data-slot="popover-body"]'
@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]' export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

View file

@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const win = process.platform === "win32"
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@ -14,7 +15,8 @@ export default defineConfig({
expect: { expect: {
timeout: 10_000, timeout: 10_000,
}, },
fullyParallel: true, fullyParallel: !win,
workers: win ? 1 : undefined,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],

View file

@ -90,9 +90,10 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: { export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string provider?: string
children?: JSX.Element children?: JSX.Element | ((open: boolean) => JSX.Element)
triggerAs?: T triggerAs?: T
triggerProps?: ComponentProps<T> triggerProps?: ComponentProps<T>
gutter?: number
}) { }) {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
@ -175,14 +176,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
}} }}
modal={false} modal={false}
placement="top-start" placement="top-start"
gutter={8} gutter={props.gutter ?? 8}
> >
<Kobalte.Trigger <Kobalte.Trigger
ref={(el) => setStore("trigger", el)} ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"} as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)} {...(props.triggerProps as any)}
> >
{props.children} {typeof props.children === "function" ? props.children(store.open) : props.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content

View file

@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider" import type { IconName } from "@opencode-ai/ui/icons/provider"
@ -42,6 +44,7 @@ import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
@ -112,6 +115,7 @@ interface SlashCommand {
description?: string description?: string
keybind?: string keybind?: string
type: "builtin" | "custom" type: "builtin" | "custom"
source?: "command" | "mcp" | "skill"
} }
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
@ -517,6 +521,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name, title: cmd.name,
description: cmd.description, description: cmd.description,
type: "custom" as const, type: "custom" as const,
source: cmd.source,
})) }))
return [...custom, ...builtin] return [...custom, ...builtin]
@ -1252,7 +1257,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput() clearInput()
client.session client.session
.shell({ .shell({
sessionID: session.id, sessionID: session?.id || "",
agent, agent,
model, model,
command: text, command: text,
@ -1275,7 +1280,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput() clearInput()
client.session client.session
.command({ .command({
sessionID: session.id, sessionID: session?.id || "",
command: commandName, command: commandName,
arguments: args.join(" "), arguments: args.join(" "),
agent, agent,
@ -1431,13 +1436,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({ const optimisticParts = requestParts.map((part) => ({
...part, ...part,
sessionID: session.id, sessionID: session?.id || "",
messageID, messageID,
})) as unknown as Part[] })) as unknown as Part[]
const optimisticMessage: Message = { const optimisticMessage: Message = {
id: messageID, id: messageID,
sessionID: session.id, sessionID: session?.id || "",
role: "user", role: "user",
time: { created: Date.now() }, time: { created: Date.now() },
agent, agent,
@ -1448,9 +1453,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set( sync.set(
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (!messages) { if (!messages) {
draft.message[session.id] = [optimisticMessage] draft.message[session?.id || ""] = [optimisticMessage]
} else { } else {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage) messages.splice(result.index, 0, optimisticMessage)
@ -1466,9 +1471,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1]( globalSync.child(sessionDirectory)[1](
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (!messages) { if (!messages) {
draft.message[session.id] = [optimisticMessage] draft.message[session?.id || ""] = [optimisticMessage]
} else { } else {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage) messages.splice(result.index, 0, optimisticMessage)
@ -1485,7 +1490,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
sync.set( sync.set(
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (messages) { if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
@ -1498,7 +1503,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1]( globalSync.child(sessionDirectory)[1](
produce((draft) => { produce((draft) => {
const messages = draft.message[session.id] const messages = draft.message[session?.id || ""]
if (messages) { if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id) const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1) if (result.found) messages.splice(result.index, 1)
@ -1519,15 +1524,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory) const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "busy" }) sync.set("session_status", session?.id, { type: "busy" })
} }
const controller = new AbortController() const controller = new AbortController()
const cleanup = () => { const cleanup = () => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session?.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
for (const item of commentItems) { for (const item of commentItems) {
@ -1544,7 +1549,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreInput() restoreInput()
} }
pending.set(session.id, { abort: controller, cleanup }) pending.set(session?.id || "", { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
@ -1572,7 +1577,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return if (timer.id === undefined) return
clearTimeout(timer.id) clearTimeout(timer.id)
}) })
pending.delete(session.id) pending.delete(session?.id || "")
if (controller.signal.aborted) return false if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message) if (result.status === "failed") throw new Error(result.message)
return true return true
@ -1582,7 +1587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree() const ok = await waitForWorktree()
if (!ok) return if (!ok) return
await client.session.prompt({ await client.session.prompt({
sessionID: session.id, sessionID: session?.id || "",
agent, agent,
model, model,
messageID, messageID,
@ -1592,9 +1597,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
void send().catch((err) => { void send().catch((err) => {
pending.delete(session.id) pending.delete(session?.id || "")
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session.id, { type: "idle" }) sync.set("session_status", session?.id, { type: "idle" })
} }
showToast({ showToast({
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
@ -1616,6 +1621,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
const currrentModelVariant = createMemo(() => {
const modelVariant = local.model.variant.current() ?? ""
return modelVariant === "xhigh"
? "xHigh"
: modelVariant.length > 0
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
: "Default"
})
const reasoningPercentage = createMemo(() => {
const variants = local.model.variant.list()
const current = local.model.variant.current()
const totalEntries = variants.length + 1
if (totalEntries <= 2 || current === "Default") {
return 0
}
const currentIndex = current ? variants.indexOf(current) + 1 : 0
return ((currentIndex + 1) / totalEntries) * 100
}, [local.model.variant])
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3"> <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}> <Show when={store.popover}>
@ -1668,7 +1695,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</> </>
} }
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap"> <span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name} @{(item as { type: "agent"; name: string }).name}
</span> </span>
@ -1701,9 +1728,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom"}> <Show when={cmd.type === "custom" && cmd.source !== "command"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{language.t("prompt.slash.badge.custom")} {cmd.source === "skill"
? language.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? language.t("prompt.slash.badge.mcp")
: language.t("prompt.slash.badge.custom")}
</span> </span>
</Show> </Show>
<Show when={command.keybind(cmd.id)}> <Show when={command.keybind(cmd.id)}>
@ -1729,9 +1760,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
> >
<Show when={store.dragging}> <Show when={store.dragging}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak"> <div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name="photo" class="size-8" /> <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span> <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
</div> </div>
</div> </div>
@ -1770,7 +1801,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
> >
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
<div class="flex items-center text-11-regular min-w-0 font-medium"> <div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span> <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}> <Show when={item.selection}>
@ -1787,7 +1818,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
icon="close-small" icon="close-small"
variant="ghost" variant="ghost"
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID) if (item.commentID) comments.remove(item.path, item.commentID)
@ -1817,7 +1848,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
when={attachment.mime.startsWith("image/")} when={attachment.mime.startsWith("image/")}
fallback={ fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" /> <Icon name="folder" size="normal" class="size-6 text-text-base" />
</div> </div>
} }
> >
@ -1891,7 +1922,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<div class="relative p-3 flex items-center justify-between"> <div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0.5"> <div class="flex items-center justify-start gap-2">
<Switch> <Switch>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6"> <div class="flex items-center gap-2 px-2 h-6">
@ -1912,6 +1943,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize" class="capitalize"
variant="ghost" variant="ghost"
gutter={12}
/> />
</TooltipKeybind> </TooltipKeybind>
<Show <Show
@ -1922,12 +1954,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}> <Button
as="div"
variant="ghost"
class="px-2"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show> </Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
<Icon name="chevron-down" size="small" /> <MorphChevron
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
/>
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
} }
@ -1937,12 +1976,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}> <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
<Show when={local.model.current()?.provider?.id}> {(open) => (
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <>
</Show> <Show when={local.model.current()?.provider?.id}>
{local.model.current()?.name ?? language.t("dialog.model.select.title")} <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
<Icon name="chevron-down" size="small" /> </Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={open} class="text-text-weak" />
</>
)}
</ModelSelectorPopover> </ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
@ -1955,10 +1998,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button <Button
data-action="model-variant-cycle" data-action="model-variant-cycle"
variant="ghost" variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular" class="text-text-strong text-12-regular"
onClick={() => local.model.variant.cycle()} onClick={() => local.model.variant.cycle()}
> >
{local.model.variant.current() ?? language.t("common.default")} <Show when={local.model.variant.list().length > 1}>
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
</Show>
<CycleLabel value={currrentModelVariant()} />
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
@ -1972,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost" variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{ classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "_hidden group-hover/prompt-input:flex items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}} }}
@ -1994,7 +2040,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match> </Match>
</Switch> </Switch>
</div> </div>
<div class="flex items-center gap-3 absolute right-3 bottom-3"> <div class="flex items-center gap-1 absolute right-3 bottom-3">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -2006,18 +2052,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5 mr-1.5">
<SessionContextUsage /> <SessionContextUsage />
<Show when={store.mode === "normal"}> <Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}> <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
class="size-6" size="small"
class="px-1"
onClick={() => fileInputRef.click()} onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")} aria-label={language.t("prompt.action.attachFile")}
> >
<Icon name="photo" class="size-4.5" /> <Icon name="photo" class="size-6 text-icon-base" />
</Button> </Button>
</Tooltip> </Tooltip>
</Show> </Show>
@ -2036,7 +2083,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}> <Match when={true}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span> <span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" /> <Icon name="enter" size="normal" class="text-icon-base" />
</div> </div>
</Match> </Match>
</Switch> </Switch>
@ -2047,7 +2094,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && !working()} disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-6 w-5.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/> />
</Tooltip> </Tooltip>

View file

@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings" import { useSettings, monoFontFamily } from "@/context/settings"
@ -130,7 +131,12 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS] const soundOptions = [...SOUND_OPTIONS]
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8"> <div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@ -226,7 +232,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary" variant="secondary"
size="small" size="small"
triggerVariant="settings" triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
> >
{(option) => ( {(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}> <span style={{ "font-family": monoFontFamily(option?.value) }}>
@ -411,7 +417,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</div> </div>
</div> </div>
</div> </ScrollFade>
) )
} }

View file

@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => {
}) })
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
</div> </div>
</Show> </Show>
</div> </div>
</div> </ScrollFade>
) )
} }

View file

@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@ -39,7 +40,12 @@ export const SettingsModels: Component = () => {
}) })
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@ -125,6 +131,6 @@ export const SettingsModels: Component = () => {
</Show> </Show>
</Show> </Show>
</div> </div>
</div> </ScrollFade>
) )
} }

View file

@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource } type ProviderMeta = { source?: ProviderSource }
@ -115,7 +116,12 @@ export const SettingsProviders: Component = () => {
} }
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]"> <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@ -261,6 +267,6 @@ export const SettingsProviders: Component = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div> </ScrollFade>
) )
} }

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.slash.badge.custom": "مخصص", "prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "نشط", "prompt.context.active": "نشط",
"prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.includeActiveFile": "تضمين الملف النشط",
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
@ -432,6 +434,7 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات", "session.review.noChanges": "لا توجد تغييرات",
"session.files.selectToOpen": "اختر ملفًا لفتحه", "session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات", "session.files.all": "كل الملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة", "session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...", "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة", "session.messages.loadEarlier": "تحميل الرسائل السابقة",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.dropzone.label": "Solte imagens ou PDFs aqui",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "ativo", "prompt.context.active": "ativo",
"prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo",
"prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
@ -433,6 +435,7 @@ export const dict = {
"session.review.noChanges": "Sem alterações", "session.review.noChanges": "Sem alterações",
"session.files.selectToOpen": "Selecione um arquivo para abrir", "session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos", "session.files.all": "Todos os arquivos",
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.messages.renderEarlier": "Renderizar mensagens anteriores", "session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...", "session.messages.loadingEarlier": "Carregando mensagens anteriores...",
"session.messages.loadEarlier": "Carregar mensagens anteriores", "session.messages.loadEarlier": "Carregar mensagens anteriores",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.dropzone.label": "Slip billeder eller PDF'er her",
"prompt.slash.badge.custom": "brugerdefineret", "prompt.slash.badge.custom": "brugerdefineret",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv", "prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.includeActiveFile": "Inkluder aktiv fil",
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@ -434,6 +436,7 @@ export const dict = {
"session.review.noChanges": "Ingen ændringer", "session.review.noChanges": "Ingen ændringer",
"session.files.selectToOpen": "Vælg en fil at åbne", "session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer", "session.files.all": "Alle filer",
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere beskeder", "session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...", "session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
"session.messages.loadEarlier": "Indlæs tidligere beskeder", "session.messages.loadEarlier": "Indlæs tidligere beskeder",

View file

@ -214,6 +214,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.popover.emptyCommands": "Keine passenden Befehle",
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
"prompt.slash.badge.custom": "benutzerdefiniert", "prompt.slash.badge.custom": "benutzerdefiniert",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv", "prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Aktive Datei einbeziehen", "prompt.context.includeActiveFile": "Aktive Datei einbeziehen",
"prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
@ -442,6 +444,7 @@ export const dict = {
"session.review.noChanges": "Keine Änderungen", "session.review.noChanges": "Keine Änderungen",
"session.files.selectToOpen": "Datei zum Öffnen auswählen", "session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien", "session.files.all": "Alle Dateien",
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
"session.messages.renderEarlier": "Frühere Nachrichten rendern", "session.messages.renderEarlier": "Frühere Nachrichten rendern",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...", "session.messages.loadingEarlier": "Lade frühere Nachrichten...",
"session.messages.loadEarlier": "Frühere Nachrichten laden", "session.messages.loadEarlier": "Frühere Nachrichten laden",

View file

@ -216,6 +216,8 @@ export const dict = {
"prompt.popover.emptyCommands": "No matching commands", "prompt.popover.emptyCommands": "No matching commands",
"prompt.dropzone.label": "Drop images or PDFs here", "prompt.dropzone.label": "Drop images or PDFs here",
"prompt.slash.badge.custom": "custom", "prompt.slash.badge.custom": "custom",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "active", "prompt.context.active": "active",
"prompt.context.includeActiveFile": "Include active file", "prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeActiveFile": "Remove active file from context",
@ -441,6 +443,7 @@ export const dict = {
"session.files.selectToOpen": "Select a file to open", "session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files", "session.files.all": "All files",
"session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages", "session.messages.renderEarlier": "Render earlier messages",
"session.messages.loadingEarlier": "Loading earlier messages...", "session.messages.loadingEarlier": "Loading earlier messages...",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.popover.emptyCommands": "Sin comandos coincidentes",
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
"prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "activo", "prompt.context.active": "activo",
"prompt.context.includeActiveFile": "Incluir archivo activo", "prompt.context.includeActiveFile": "Incluir archivo activo",
"prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
@ -436,6 +438,7 @@ export const dict = {
"session.review.noChanges": "Sin cambios", "session.review.noChanges": "Sin cambios",
"session.files.selectToOpen": "Selecciona un archivo para abrir", "session.files.selectToOpen": "Selecciona un archivo para abrir",
"session.files.all": "Todos los archivos", "session.files.all": "Todos los archivos",
"session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
"session.messages.renderEarlier": "Renderizar mensajes anteriores", "session.messages.renderEarlier": "Renderizar mensajes anteriores",
"session.messages.loadingEarlier": "Cargando mensajes anteriores...", "session.messages.loadingEarlier": "Cargando mensajes anteriores...",
"session.messages.loadEarlier": "Cargar mensajes anteriores", "session.messages.loadEarlier": "Cargar mensajes anteriores",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.popover.emptyCommands": "Aucune commande correspondante",
"prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.dropzone.label": "Déposez des images ou des PDF ici",
"prompt.slash.badge.custom": "personnalisé", "prompt.slash.badge.custom": "personnalisé",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "actif", "prompt.context.active": "actif",
"prompt.context.includeActiveFile": "Inclure le fichier actif", "prompt.context.includeActiveFile": "Inclure le fichier actif",
"prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
@ -441,6 +443,7 @@ export const dict = {
"session.review.noChanges": "Aucune modification", "session.review.noChanges": "Aucune modification",
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir", "session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
"session.files.all": "Tous les fichiers", "session.files.all": "Tous les fichiers",
"session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
"session.messages.renderEarlier": "Afficher les messages précédents", "session.messages.renderEarlier": "Afficher les messages précédents",
"session.messages.loadingEarlier": "Chargement des messages précédents...", "session.messages.loadingEarlier": "Chargement des messages précédents...",
"session.messages.loadEarlier": "Charger les messages précédents", "session.messages.loadEarlier": "Charger les messages précédents",

View file

@ -209,6 +209,8 @@ export const dict = {
"prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.popover.emptyCommands": "一致するコマンドがありません",
"prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.dropzone.label": "画像またはPDFをここにドロップ",
"prompt.slash.badge.custom": "カスタム", "prompt.slash.badge.custom": "カスタム",
"prompt.slash.badge.skill": "スキル",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "アクティブ", "prompt.context.active": "アクティブ",
"prompt.context.includeActiveFile": "アクティブなファイルを含める", "prompt.context.includeActiveFile": "アクティブなファイルを含める",
"prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除",
@ -433,6 +435,7 @@ export const dict = {
"session.review.noChanges": "変更なし", "session.review.noChanges": "変更なし",
"session.files.selectToOpen": "開くファイルを選択", "session.files.selectToOpen": "開くファイルを選択",
"session.files.all": "すべてのファイル", "session.files.all": "すべてのファイル",
"session.files.binaryContent": "バイナリファイル(内容を表示できません)",
"session.messages.renderEarlier": "以前のメッセージを表示", "session.messages.renderEarlier": "以前のメッセージを表示",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...", "session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
"session.messages.loadEarlier": "以前のメッセージを読み込む", "session.messages.loadEarlier": "以前のメッセージを読み込む",

View file

@ -213,6 +213,8 @@ export const dict = {
"prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.popover.emptyCommands": "일치하는 명령어 없음",
"prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
"prompt.slash.badge.custom": "사용자 지정", "prompt.slash.badge.custom": "사용자 지정",
"prompt.slash.badge.skill": "스킬",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "활성", "prompt.context.active": "활성",
"prompt.context.includeActiveFile": "활성 파일 포함", "prompt.context.includeActiveFile": "활성 파일 포함",
"prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거",
@ -435,6 +437,7 @@ export const dict = {
"session.review.noChanges": "변경 없음", "session.review.noChanges": "변경 없음",
"session.files.selectToOpen": "열 파일을 선택하세요", "session.files.selectToOpen": "열 파일을 선택하세요",
"session.files.all": "모든 파일", "session.files.all": "모든 파일",
"session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
"session.messages.renderEarlier": "이전 메시지 렌더링", "session.messages.renderEarlier": "이전 메시지 렌더링",
"session.messages.loadingEarlier": "이전 메시지 로드 중...", "session.messages.loadingEarlier": "이전 메시지 로드 중...",
"session.messages.loadEarlier": "이전 메시지 로드", "session.messages.loadEarlier": "이전 메시지 로드",

View file

@ -213,6 +213,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.dropzone.label": "Slipp bilder eller PDF-er her",
"prompt.slash.badge.custom": "egendefinert", "prompt.slash.badge.custom": "egendefinert",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktiv", "prompt.context.active": "aktiv",
"prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.includeActiveFile": "Inkluder aktiv fil",
"prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@ -436,6 +438,7 @@ export const dict = {
"session.review.noChanges": "Ingen endringer", "session.review.noChanges": "Ingen endringer",
"session.files.selectToOpen": "Velg en fil å åpne", "session.files.selectToOpen": "Velg en fil å åpne",
"session.files.all": "Alle filer", "session.files.all": "Alle filer",
"session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere meldinger", "session.messages.renderEarlier": "Vis tidligere meldinger",
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...", "session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
"session.messages.loadEarlier": "Last inn tidligere meldinger", "session.messages.loadEarlier": "Last inn tidligere meldinger",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.popover.emptyCommands": "Brak pasujących poleceń",
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
"prompt.slash.badge.custom": "własne", "prompt.slash.badge.custom": "własne",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "aktywny", "prompt.context.active": "aktywny",
"prompt.context.includeActiveFile": "Dołącz aktywny plik", "prompt.context.includeActiveFile": "Dołącz aktywny plik",
"prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu",
@ -435,6 +437,7 @@ export const dict = {
"session.review.noChanges": "Brak zmian", "session.review.noChanges": "Brak zmian",
"session.files.selectToOpen": "Wybierz plik do otwarcia", "session.files.selectToOpen": "Wybierz plik do otwarcia",
"session.files.all": "Wszystkie pliki", "session.files.all": "Wszystkie pliki",
"session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości", "session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...", "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
"session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości", "session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",

View file

@ -210,6 +210,8 @@ export const dict = {
"prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.popover.emptyCommands": "Нет совпадающих команд",
"prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.dropzone.label": "Перетащите изображения или PDF сюда",
"prompt.slash.badge.custom": "своё", "prompt.slash.badge.custom": "своё",
"prompt.slash.badge.skill": "навык",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "активно", "prompt.context.active": "активно",
"prompt.context.includeActiveFile": "Включить активный файл", "prompt.context.includeActiveFile": "Включить активный файл",
"prompt.context.removeActiveFile": "Удалить активный файл из контекста", "prompt.context.removeActiveFile": "Удалить активный файл из контекста",
@ -437,6 +439,7 @@ export const dict = {
"session.review.noChanges": "Нет изменений", "session.review.noChanges": "Нет изменений",
"session.files.selectToOpen": "Выберите файл, чтобы открыть", "session.files.selectToOpen": "Выберите файл, чтобы открыть",
"session.files.all": "Все файлы", "session.files.all": "Все файлы",
"session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
"session.messages.renderEarlier": "Показать предыдущие сообщения", "session.messages.renderEarlier": "Показать предыдущие сообщения",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...", "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
"session.messages.loadEarlier": "Загрузить предыдущие сообщения", "session.messages.loadEarlier": "Загрузить предыдущие сообщения",

View file

@ -215,6 +215,8 @@ export const dict = {
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
"prompt.slash.badge.custom": "กำหนดเอง", "prompt.slash.badge.custom": "กำหนดเอง",
"prompt.slash.badge.skill": "skill",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "ใช้งานอยู่", "prompt.context.active": "ใช้งานอยู่",
"prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่", "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่",
"prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
@ -322,20 +324,20 @@ export const dict = {
"context.usage.clickToView": "คลิกเพื่อดูบริบท", "context.usage.clickToView": "คลิกเพื่อดูบริบท",
"context.usage.view": "ดูการใช้บริบท", "context.usage.view": "ดูการใช้บริบท",
"language.en": "อังกฤษ", "language.en": "English",
"language.zh": "จีนตัวย่อ", "language.zh": "简体中文",
"language.zht": "จีนตัวเต็ม", "language.zht": "繁體中文",
"language.ko": "เกาหลี", "language.ko": "한국어",
"language.de": "เยอรมัน", "language.de": "Deutsch",
"language.es": "สเปน", "language.es": "Español",
"language.fr": "ฝรั่งเศส", "language.fr": "Français",
"language.da": "เดนมาร์ก", "language.da": "Dansk",
"language.ja": "ญี่ปุ่น", "language.ja": "日本語",
"language.pl": "โปแลนด์", "language.pl": "Polski",
"language.ru": "รัสเซีย", "language.ru": "Русский",
"language.ar": "อาหรับ", "language.ar": "العربية",
"language.no": "นอร์เวย์", "language.no": "Norsk",
"language.br": "โปรตุเกส (บราซิล)", "language.br": "Português (Brasil)",
"language.th": "ไทย", "language.th": "ไทย",
"toast.language.title": "ภาษา", "toast.language.title": "ภาษา",
@ -438,6 +440,7 @@ export const dict = {
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด", "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
"session.files.all": "ไฟล์ทั้งหมด", "session.files.all": "ไฟล์ทั้งหมด",
"session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
"session.messages.renderEarlier": "แสดงข้อความก่อนหน้า", "session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
"session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...", "session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",

View file

@ -214,6 +214,8 @@ export const dict = {
"prompt.popover.emptyCommands": "没有匹配的命令", "prompt.popover.emptyCommands": "没有匹配的命令",
"prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.dropzone.label": "将图片或 PDF 拖到这里",
"prompt.slash.badge.custom": "自定义", "prompt.slash.badge.custom": "自定义",
"prompt.slash.badge.skill": "技能",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "当前", "prompt.context.active": "当前",
"prompt.context.includeActiveFile": "包含当前文件", "prompt.context.includeActiveFile": "包含当前文件",
"prompt.context.removeActiveFile": "从上下文移除活动文件", "prompt.context.removeActiveFile": "从上下文移除活动文件",
@ -434,6 +436,7 @@ export const dict = {
"session.review.noChanges": "无更改", "session.review.noChanges": "无更改",
"session.files.selectToOpen": "选择要打开的文件", "session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件", "session.files.all": "所有文件",
"session.files.binaryContent": "二进制文件(无法显示内容)",
"session.messages.renderEarlier": "显示更早的消息", "session.messages.renderEarlier": "显示更早的消息",
"session.messages.loadingEarlier": "正在加载更早的消息...", "session.messages.loadingEarlier": "正在加载更早的消息...",
"session.messages.loadEarlier": "加载更早的消息", "session.messages.loadEarlier": "加载更早的消息",

View file

@ -211,6 +211,8 @@ export const dict = {
"prompt.popover.emptyCommands": "沒有符合的命令", "prompt.popover.emptyCommands": "沒有符合的命令",
"prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
"prompt.slash.badge.custom": "自訂", "prompt.slash.badge.custom": "自訂",
"prompt.slash.badge.skill": "技能",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "作用中", "prompt.context.active": "作用中",
"prompt.context.includeActiveFile": "包含作用中檔案", "prompt.context.includeActiveFile": "包含作用中檔案",
"prompt.context.removeActiveFile": "從上下文移除目前檔案", "prompt.context.removeActiveFile": "從上下文移除目前檔案",
@ -431,6 +433,7 @@ export const dict = {
"session.review.noChanges": "沒有變更", "session.review.noChanges": "沒有變更",
"session.files.selectToOpen": "選取要開啟的檔案", "session.files.selectToOpen": "選取要開啟的檔案",
"session.files.all": "所有檔案", "session.files.all": "所有檔案",
"session.files.binaryContent": "二進位檔案(無法顯示內容)",
"session.messages.renderEarlier": "顯示更早的訊息", "session.messages.renderEarlier": "顯示更早的訊息",
"session.messages.loadingEarlier": "正在載入更早的訊息...", "session.messages.loadingEarlier": "正在載入更早的訊息...",
"session.messages.loadEarlier": "載入更早的訊息", "session.messages.loadEarlier": "載入更早的訊息",

View file

@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) {
> >
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}> <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1"> <div class="px-2 py-1">
<div class="group/workspace relative"> <div
class="group/workspace relative"
data-component="workspace-item"
data-workspace={base64Encode(props.directory)}
>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Show <Show
when={workspaceEditActive()} when={workspaceEditActive()}
fallback={ fallback={
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"> <Collapsible.Trigger
class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
data-action="workspace-toggle"
data-workspace={base64Encode(props.directory)}
>
{header()} {header()}
</Collapsible.Trigger> </Collapsible.Trigger>
} }
@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) {
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
class="size-6 rounded-md" class="size-6 rounded-md"
data-action="workspace-menu"
data-workspace={base64Encode(props.directory)}
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
</Tooltip> </Tooltip>
@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()} disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => { onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)() const enabled = layout.sidebar.workspaces(p.worktree)()

View file

@ -2342,6 +2342,7 @@ export default function Page() {
const c = state()?.content const c = state()?.content
return c?.mimeType === "image/svg+xml" return c?.mimeType === "image/svg+xml"
}) })
const isBinary = createMemo(() => state()?.content?.type === "binary")
const svgContent = createMemo(() => { const svgContent = createMemo(() => {
if (!isSvg()) return if (!isSvg()) return
const c = state()?.content const c = state()?.content
@ -2794,6 +2795,19 @@ export default function Page() {
</Show> </Show>
</div> </div>
</Match> </Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">
{path()?.split("/").pop()}
</div>
<div class="text-14-regular text-text-weak">
{language.t("session.files.binaryContent")}
</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>

View file

@ -18,7 +18,7 @@ import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer" import { Footer } from "~/component/footer"
import { Header } from "~/component/header" import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common" import { getLastSeenWorkspaceID } from "../workspace/common"
import { IconGemini, IconZai } from "~/component/icon" import { IconGemini, IconMiniMax, IconZai } from "~/component/icon"
const checkLoggedIn = query(async () => { const checkLoggedIn = query(async () => {
"use server" "use server"
@ -98,14 +98,7 @@ export default function Home() {
</svg> </svg>
</div> </div>
<div> <div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <IconMiniMax width="24" height="24" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.6043 1.34016C12.9973 2.03016 13.3883 2.72215 13.7783 3.41514C13.7941 3.44286 13.8169 3.46589 13.8445 3.48187C13.8721 3.49786 13.9034 3.50624 13.9353 3.50614H19.4873C19.6612 3.50614 19.8092 3.61614 19.9332 3.83314L21.3872 6.40311C21.5772 6.74011 21.6272 6.88111 21.4112 7.24011C21.1512 7.6701 20.8982 8.1041 20.6512 8.54009L20.2842 9.19809C20.1782 9.39409 20.0612 9.47809 20.2442 9.71008L22.8962 14.347C23.0682 14.648 23.0072 14.841 22.8532 15.117C22.4162 15.902 21.9712 16.681 21.5182 17.457C21.3592 17.729 21.1662 17.832 20.8382 17.827C20.0612 17.811 19.2863 17.817 18.5113 17.843C18.4946 17.8439 18.4785 17.8489 18.4644 17.8576C18.4502 17.8664 18.4385 17.8785 18.4303 17.893C17.5361 19.4773 16.6344 21.0573 15.7253 22.633C15.5563 22.926 15.3453 22.996 15.0003 22.997C14.0033 23 12.9983 23.001 11.9833 22.999C11.8889 22.9987 11.7961 22.9735 11.7145 22.9259C11.6328 22.8783 11.5652 22.8101 11.5184 22.728L10.1834 20.405C10.1756 20.3898 10.1637 20.3771 10.149 20.3684C10.1343 20.3598 10.1174 20.3554 10.1004 20.356H4.98244C4.69744 20.386 4.42944 20.355 4.17745 20.264L2.57447 17.494C2.52706 17.412 2.50193 17.319 2.50158 17.2243C2.50123 17.1296 2.52567 17.0364 2.57247 16.954L3.77945 14.834C3.79665 14.8041 3.80569 14.7701 3.80569 14.7355C3.80569 14.701 3.79665 14.667 3.77945 14.637C3.15073 13.5485 2.52573 12.4579 1.90448 11.3651L1.11449 9.97008C0.954488 9.66008 0.941489 9.47409 1.20949 9.00509C1.67448 8.1921 2.13647 7.38011 2.59647 6.56911C2.72847 6.33512 2.90046 6.23512 3.18046 6.23412C4.04344 6.23048 4.90644 6.23015 5.76943 6.23312C5.79123 6.23295 5.81259 6.22704 5.83138 6.21597C5.85016 6.20491 5.8657 6.1891 5.87643 6.17012L8.68239 1.27516C8.72491 1.2007 8.78631 1.13875 8.86039 1.09556C8.93448 1.05238 9.01863 1.02948 9.10439 1.02917C9.62838 1.02817 10.1574 1.02917 10.6874 1.02317L11.7044 1.00017C12.0453 0.997165 12.4283 1.03217 12.6043 1.34016ZM9.17238 1.74316C9.16185 1.74315 9.15149 1.74592 9.14236 1.75119C9.13323 1.75645 9.12565 1.76403 9.12038 1.77316L6.25442 6.78811C6.24066 6.81174 6.22097 6.83137 6.19729 6.84505C6.17361 6.85873 6.14677 6.86599 6.11942 6.86611H3.25346C3.19746 6.86611 3.18346 6.89111 3.21246 6.94011L9.02239 17.096C9.04739 17.138 9.03539 17.158 8.98839 17.159L6.19342 17.174C6.15256 17.1727 6.11214 17.1828 6.07678 17.2033C6.04141 17.2238 6.01253 17.2539 5.99342 17.29L4.67344 19.6C4.62944 19.678 4.65244 19.718 4.74144 19.718L10.4574 19.726C10.5034 19.726 10.5374 19.746 10.5614 19.787L11.9643 22.241C12.0103 22.322 12.0563 22.323 12.1033 22.241L17.1093 13.481L17.8923 12.0991C17.897 12.0905 17.904 12.0834 17.9125 12.0785C17.9209 12.0735 17.9305 12.0709 17.9403 12.0709C17.9501 12.0709 17.9597 12.0735 17.9681 12.0785C17.9765 12.0834 17.9835 12.0905 17.9883 12.0991L19.4123 14.629C19.4229 14.648 19.4385 14.6637 19.4573 14.6746C19.4761 14.6855 19.4975 14.6912 19.5193 14.691L22.2822 14.671C22.2893 14.6711 22.2963 14.6693 22.3024 14.6658C22.3086 14.6623 22.3137 14.6572 22.3172 14.651C22.3206 14.6449 22.3224 14.638 22.3224 14.631C22.3224 14.624 22.3206 14.6172 22.3172 14.611L19.4173 9.52508C19.4068 9.50809 19.4013 9.48853 19.4013 9.46859C19.4013 9.44864 19.4068 9.42908 19.4173 9.41209L19.7102 8.90509L20.8302 6.92811C20.8542 6.88711 20.8422 6.86611 20.7952 6.86611H9.20038C9.14138 6.86611 9.12738 6.84011 9.15738 6.78911L10.5914 4.28413C10.6021 4.26706 10.6078 4.24731 10.6078 4.22714C10.6078 4.20697 10.6021 4.18721 10.5914 4.17014L9.22538 1.77416C9.22016 1.7647 9.21248 1.75682 9.20315 1.75137C9.19382 1.74591 9.18319 1.74307 9.17238 1.74316ZM15.4623 9.76308C15.5083 9.76308 15.5203 9.78308 15.4963 9.82308L14.6643 11.2881L12.0513 15.873C12.0464 15.8819 12.0392 15.8894 12.0304 15.8945C12.0216 15.8996 12.0115 15.9022 12.0013 15.902C11.9912 15.902 11.9813 15.8993 11.9725 15.8942C11.9637 15.8891 11.9564 15.8818 11.9513 15.873L8.49839 9.84108C8.47839 9.80708 8.48839 9.78908 8.52639 9.78708L8.74239 9.77508L15.4643 9.76308H15.4623Z"
fill="currentColor"
/>
</svg>
</div> </div>
<div> <div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -118,6 +111,16 @@ export default function Home() {
<div> <div>
<IconZai width="24" height="24" /> <IconZai width="24" height="24" />
</div> </div>
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.6043 1.34016C12.9973 2.03016 13.3883 2.72215 13.7783 3.41514C13.7941 3.44286 13.8169 3.46589 13.8445 3.48187C13.8721 3.49786 13.9034 3.50624 13.9353 3.50614H19.4873C19.6612 3.50614 19.8092 3.61614 19.9332 3.83314L21.3872 6.40311C21.5772 6.74011 21.6272 6.88111 21.4112 7.24011C21.1512 7.6701 20.8982 8.1041 20.6512 8.54009L20.2842 9.19809C20.1782 9.39409 20.0612 9.47809 20.2442 9.71008L22.8962 14.347C23.0682 14.648 23.0072 14.841 22.8532 15.117C22.4162 15.902 21.9712 16.681 21.5182 17.457C21.3592 17.729 21.1662 17.832 20.8382 17.827C20.0612 17.811 19.2863 17.817 18.5113 17.843C18.4946 17.8439 18.4785 17.8489 18.4644 17.8576C18.4502 17.8664 18.4385 17.8785 18.4303 17.893C17.5361 19.4773 16.6344 21.0573 15.7253 22.633C15.5563 22.926 15.3453 22.996 15.0003 22.997C14.0033 23 12.9983 23.001 11.9833 22.999C11.8889 22.9987 11.7961 22.9735 11.7145 22.9259C11.6328 22.8783 11.5652 22.8101 11.5184 22.728L10.1834 20.405C10.1756 20.3898 10.1637 20.3771 10.149 20.3684C10.1343 20.3598 10.1174 20.3554 10.1004 20.356H4.98244C4.69744 20.386 4.42944 20.355 4.17745 20.264L2.57447 17.494C2.52706 17.412 2.50193 17.319 2.50158 17.2243C2.50123 17.1296 2.52567 17.0364 2.57247 16.954L3.77945 14.834C3.79665 14.8041 3.80569 14.7701 3.80569 14.7355C3.80569 14.701 3.79665 14.667 3.77945 14.637C3.15073 13.5485 2.52573 12.4579 1.90448 11.3651L1.11449 9.97008C0.954488 9.66008 0.941489 9.47409 1.20949 9.00509C1.67448 8.1921 2.13647 7.38011 2.59647 6.56911C2.72847 6.33512 2.90046 6.23512 3.18046 6.23412C4.04344 6.23048 4.90644 6.23015 5.76943 6.23312C5.79123 6.23295 5.81259 6.22704 5.83138 6.21597C5.85016 6.20491 5.8657 6.1891 5.87643 6.17012L8.68239 1.27516C8.72491 1.2007 8.78631 1.13875 8.86039 1.09556C8.93448 1.05238 9.01863 1.02948 9.10439 1.02917C9.62838 1.02817 10.1574 1.02917 10.6874 1.02317L11.7044 1.00017C12.0453 0.997165 12.4283 1.03217 12.6043 1.34016ZM9.17238 1.74316C9.16185 1.74315 9.15149 1.74592 9.14236 1.75119C9.13323 1.75645 9.12565 1.76403 9.12038 1.77316L6.25442 6.78811C6.24066 6.81174 6.22097 6.83137 6.19729 6.84505C6.17361 6.85873 6.14677 6.86599 6.11942 6.86611H3.25346C3.19746 6.86611 3.18346 6.89111 3.21246 6.94011L9.02239 17.096C9.04739 17.138 9.03539 17.158 8.98839 17.159L6.19342 17.174C6.15256 17.1727 6.11214 17.1828 6.07678 17.2033C6.04141 17.2238 6.01253 17.2539 5.99342 17.29L4.67344 19.6C4.62944 19.678 4.65244 19.718 4.74144 19.718L10.4574 19.726C10.5034 19.726 10.5374 19.746 10.5614 19.787L11.9643 22.241C12.0103 22.322 12.0563 22.323 12.1033 22.241L17.1093 13.481L17.8923 12.0991C17.897 12.0905 17.904 12.0834 17.9125 12.0785C17.9209 12.0735 17.9305 12.0709 17.9403 12.0709C17.9501 12.0709 17.9597 12.0735 17.9681 12.0785C17.9765 12.0834 17.9835 12.0905 17.9883 12.0991L19.4123 14.629C19.4229 14.648 19.4385 14.6637 19.4573 14.6746C19.4761 14.6855 19.4975 14.6912 19.5193 14.691L22.2822 14.671C22.2893 14.6711 22.2963 14.6693 22.3024 14.6658C22.3086 14.6623 22.3137 14.6572 22.3172 14.651C22.3206 14.6449 22.3224 14.638 22.3224 14.631C22.3224 14.624 22.3206 14.6172 22.3172 14.611L19.4173 9.52508C19.4068 9.50809 19.4013 9.48853 19.4013 9.46859C19.4013 9.44864 19.4068 9.42908 19.4173 9.41209L19.7102 8.90509L20.8302 6.92811C20.8542 6.88711 20.8422 6.86611 20.7952 6.86611H9.20038C9.14138 6.86611 9.12738 6.84011 9.15738 6.78911L10.5914 4.28413C10.6021 4.26706 10.6078 4.24731 10.6078 4.22714C10.6078 4.20697 10.6021 4.18721 10.5914 4.17014L9.22538 1.77416C9.22016 1.7647 9.21248 1.75682 9.20315 1.75137C9.19382 1.74591 9.18319 1.74307 9.17238 1.74316ZM15.4623 9.76308C15.5083 9.76308 15.5203 9.78308 15.4963 9.82308L14.6643 11.2881L12.0513 15.873C12.0464 15.8819 12.0392 15.8894 12.0304 15.8945C12.0216 15.8996 12.0115 15.9022 12.0013 15.902C11.9912 15.902 11.9813 15.8993 11.9725 15.8942C11.9637 15.8891 11.9564 15.8818 11.9513 15.873L8.49839 9.84108C8.47839 9.80708 8.48839 9.78908 8.52639 9.78708L8.74239 9.77508L15.4643 9.76308H15.4623Z"
fill="currentColor"
/>
</svg>
</div>
</div> </div>
<a href="/auth"> <a href="/auth">
<span>Get started with Zen </span> <span>Get started with Zen </span>

View file

@ -37,6 +37,7 @@ export namespace Agent {
providerID: z.string(), providerID: z.string(),
}) })
.optional(), .optional(),
variant: z.string().optional(),
prompt: z.string().optional(), prompt: z.string().optional(),
options: z.record(z.string(), z.any()), options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(), steps: z.number().int().positive().optional(),
@ -214,6 +215,7 @@ export namespace Agent {
native: false, native: false,
} }
if (value.model) item.model = Provider.parseModel(value.model) if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature item.temperature = value.temperature ?? item.temperature

View file

@ -307,7 +307,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError() if (prompts.isCancel(provider)) throw new UI.CancelledError()
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) { if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider) const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return if (handled) return
@ -323,7 +323,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError() if (prompts.isCancel(provider)) throw new UI.CancelledError()
// Check if a plugin provides auth for this custom provider // Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) { if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return if (handled) return

View file

@ -0,0 +1,34 @@
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { createResource, createMemo } from "solid-js"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
export type DialogSkillProps = {
onSelect: (skill: string) => void
}
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const sdk = useSDK()
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const list = skills() ?? []
return list.map((skill) => ({
title: skill.name,
description: skill.description,
value: skill.name,
category: "Skills",
onSelect: () => {
props.onSelect(skill.name)
dialog.clear()
},
}))
})
return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
}

View file

@ -345,7 +345,8 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...command.slashes()] const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) { for (const serverCommand of sync.data.command) {
const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({ results.push({
display: "/" + serverCommand.name + label, display: "/" + serverCommand.name + label,
description: serverCommand.description, description: serverCommand.description,

View file

@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content) input.cursorOffset = Bun.stringWidth(content)
}, },
}, },
{
title: "Skills",
value: "prompt.skills",
category: "Prompt",
slash: {
name: "skills",
},
onSelect: () => {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
input.setText(`/${skill} `)
setStore("prompt", {
input: `/${skill} `,
parts: [],
})
input.gotoBufferEnd()
}}
/>
))
},
},
] ]
}) })

View file

@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch" import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task" import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question" import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "question"}> <Match when={props.part.tool === "question"}>
<Question {...toolprops} /> <Question {...toolprops} />
</Match> </Match>
<Match when={props.part.tool === "skill"}>
<Skill {...toolprops} />
</Match>
<Match when={true}> <Match when={true}>
<GenericTool {...toolprops} /> <GenericTool {...toolprops} />
</Match> </Match>
@ -1636,7 +1640,9 @@ function Bash(props: ToolProps<typeof BashTool>) {
> >
<box gap={1}> <box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text> <text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>{limited()}</text> <Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}> <Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text> <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show> </Show>
@ -1701,7 +1707,9 @@ function Glob(props: ToolProps<typeof GlobTool>) {
return ( return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}> <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}>({props.metadata.count} matches)</Show> <Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
</InlineTool> </InlineTool>
) )
} }
@ -1737,7 +1745,9 @@ function Grep(props: ToolProps<typeof GrepTool>) {
return ( return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}> <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show> Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show> <Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
</InlineTool> </InlineTool>
) )
} }
@ -1795,7 +1805,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
return ( return (
<Switch> <Switch>
<Match when={props.metadata.summary?.length}> <Match when={props.input.description || props.input.subagent_type}>
<BlockTool <BlockTool
title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"} title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
onClick={ onClick={
@ -1807,7 +1817,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
> >
<box> <box>
<text style={{ fg: theme.textMuted }}> <text style={{ fg: theme.textMuted }}>
{props.input.description} ({props.metadata.summary?.length} toolcalls) {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
</text> </text>
<Show when={current()}> <Show when={current()}>
<text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}> <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
@ -1816,22 +1826,17 @@ function Task(props: ToolProps<typeof TaskTool>) {
</text> </text>
</Show> </Show>
</box> </box>
<text fg={theme.text}> <Show when={props.metadata.sessionId}>
{keybind.print("session_child_cycle")} <text fg={theme.text}>
<span style={{ fg: theme.textMuted }}> view subagents</span> {keybind.print("session_child_cycle")}
</text> <span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</Show>
</BlockTool> </BlockTool>
</Match> </Match>
<Match when={true}> <Match when={true}>
<InlineTool <InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
icon="◉" {props.input.subagent_type} Task {props.input.description}
iconColor={color()}
pending="Delegating..."
complete={props.input.subagent_type ?? props.input.description}
part={props.part}
>
<span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
{props.input.description}"
</InlineTool> </InlineTool>
</Match> </Match>
</Switch> </Switch>
@ -2036,6 +2041,14 @@ function Question(props: ToolProps<typeof QuestionTool>) {
) )
} }
function Skill(props: ToolProps<typeof SkillTool>) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
Skill "{props.input.name}"
</InlineTool>
)
}
function normalizePath(input?: string) { function normalizePath(input?: string) {
if (!input) return "" if (!input) return ""
if (path.isAbsolute(input)) { if (path.isAbsolute(input)) {

View file

@ -228,7 +228,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</text> </text>
<text fg={theme.textMuted}>esc</text> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box paddingTop={1} paddingBottom={1}> <box paddingTop={1}>
<input <input
onInput={(e) => { onInput={(e) => {
batch(() => { batch(() => {

View file

@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string {
} }
if (part.type === "tool") { if (part.type === "tool") {
let result = `\`\`\`\nTool: ${part.tool}\n` let result = `**Tool: ${part.tool}**\n`
if (options.toolDetails && part.state.input) { if (options.toolDetails && part.state.input) {
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
} }
if (options.toolDetails && part.state.status === "completed" && part.state.output) { if (options.toolDetails && part.state.status === "completed" && part.state.output) {
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
} }
if (options.toolDetails && part.state.status === "error" && part.state.error) { if (options.toolDetails && part.state.status === "error" && part.state.error) {
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
} }
result += `\n\`\`\`\n\n` result += `\n`
return result return result
} }

View file

@ -63,6 +63,7 @@ export namespace Command {
[Default.INIT]: { [Default.INIT]: {
name: Default.INIT, name: Default.INIT,
description: "create/update AGENTS.md", description: "create/update AGENTS.md",
source: "command",
get template() { get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
}, },
@ -71,6 +72,7 @@ export namespace Command {
[Default.REVIEW]: { [Default.REVIEW]: {
name: Default.REVIEW, name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted", description: "review changes [commit|branch|pr], defaults to uncommitted",
source: "command",
get template() { get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree) return PROMPT_REVIEW.replace("${path}", Instance.worktree)
}, },
@ -85,6 +87,7 @@ export namespace Command {
agent: command.agent, agent: command.agent,
model: command.model, model: command.model,
description: command.description, description: command.description,
source: "command",
get template() { get template() {
return command.template return command.template
}, },

View file

@ -593,6 +593,10 @@ export namespace Config {
export const Agent = z export const Agent = z
.object({ .object({
model: z.string().optional(), model: z.string().optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(), temperature: z.number().optional(),
top_p: z.number().optional(), top_p: z.number().optional(),
prompt: z.string().optional(), prompt: z.string().optional(),
@ -624,6 +628,7 @@ export namespace Config {
const knownKeys = new Set([ const knownKeys = new Set([
"name", "name",
"model", "model",
"variant",
"prompt", "prompt",
"description", "description",
"temperature", "temperature",

View file

@ -44,7 +44,7 @@ export namespace File {
export const Content = z export const Content = z
.object({ .object({
type: z.literal("text"), type: z.enum(["text", "binary"]),
content: z.string(), content: z.string(),
diff: z.string().optional(), diff: z.string().optional(),
patch: z patch: z
@ -73,6 +73,174 @@ export namespace File {
}) })
export type Content = z.infer<typeof Content> export type Content = z.infer<typeof Content>
const binaryExtensions = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"bat",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const imageExtensions = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
async function shouldEncode(file: BunFile): Promise<boolean> { async function shouldEncode(file: BunFile): Promise<boolean> {
const type = file.type?.toLowerCase() const type = file.type?.toLowerCase()
log.info("shouldEncode", { type }) log.info("shouldEncode", { type })
@ -83,30 +251,10 @@ export namespace File {
const parts = type.split("/", 2) const parts = type.split("/", 2)
const top = parts[0] const top = parts[0]
const rest = parts[1] ?? ""
const sub = rest.split(";", 1)[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"] const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true if (tops.includes(top)) return true
const bins = [
"zip",
"gzip",
"bzip",
"compressed",
"binary",
"pdf",
"msword",
"powerpoint",
"excel",
"ogg",
"exe",
"dmg",
"iso",
"rar",
]
if (bins.some((mark) => sub.includes(mark))) return true
return false return false
} }
@ -287,6 +435,22 @@ export namespace File {
throw new Error(`Access denied: path escapes project directory`) throw new Error(`Access denied: path escapes project directory`)
} }
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
const bunFile = Bun.file(full)
if (await bunFile.exists()) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
return { type: "text", content: "" }
}
if (isBinaryByExtension(file)) {
return { type: "binary", content: "" }
}
const bunFile = Bun.file(full) const bunFile = Bun.file(full)
if (!(await bunFile.exists())) { if (!(await bunFile.exists())) {
@ -294,11 +458,15 @@ export namespace File {
} }
const encode = await shouldEncode(bunFile) const encode = await shouldEncode(bunFile)
const mimeType = bunFile.type || "application/octet-stream"
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) { if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64") const content = Buffer.from(buffer).toString("base64")
const mimeType = bunFile.type || "application/octet-stream"
return { type: "text", content, mimeType, encoding: "base64" } return { type: "text", content, mimeType, encoding: "base64" }
} }

View file

@ -215,7 +215,7 @@ export namespace Ripgrep {
const args = [await filepath(), "--files", "--glob=!.git/*"] const args = [await filepath(), "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow") if (input.follow) args.push("--follow")
if (input.hidden) args.push("--hidden") if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) { if (input.glob) {
for (const g of input.glob) { for (const g of input.glob) {

View file

@ -100,7 +100,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
break break
} }
case "reasoning": { case "reasoning": {
reasoningText = part.text if (part.text) reasoningText = part.text
break break
} }
case "tool-call": { case "tool-call": {
@ -122,7 +122,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
role: "assistant", role: "assistant",
content: text || null, content: text || null,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined, tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
reasoning_text: reasoningText, reasoning_text: reasoningOpaque ? reasoningText : undefined,
reasoning_opaque: reasoningOpaque, reasoning_opaque: reasoningOpaque,
...metadata, ...metadata,
}) })

View file

@ -219,7 +219,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
// text content: // text content:
const text = choice.message.content const text = choice.message.content
if (text != null && text.length > 0) { if (text != null && text.length > 0) {
content.push({ type: "text", text }) content.push({
type: "text",
text,
providerMetadata: choice.message.reasoning_opaque
? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
: undefined,
})
} }
// reasoning content (Copilot uses reasoning_text): // reasoning content (Copilot uses reasoning_text):
@ -243,6 +249,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(), toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name, toolName: toolCall.function.name,
input: toolCall.function.arguments!, input: toolCall.function.arguments!,
providerMetadata: choice.message.reasoning_opaque
? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
: undefined,
}) })
} }
} }
@ -478,7 +487,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
} }
if (!isActiveText) { if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "txt-0" }) controller.enqueue({
type: "text-start",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
isActiveText = true isActiveText = true
} }
@ -559,6 +572,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(), toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name, toolName: toolCall.function.name,
input: toolCall.function.arguments, input: toolCall.function.arguments,
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
}) })
toolCall.hasFinished = true toolCall.hasFinished = true
} }
@ -601,6 +615,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(), toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name, toolName: toolCall.function.name,
input: toolCall.function.arguments, input: toolCall.function.arguments,
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
}) })
toolCall.hasFinished = true toolCall.hasFinished = true
} }

View file

@ -179,7 +179,7 @@ export namespace ProviderTransform {
cacheControl: { type: "ephemeral" }, cacheControl: { type: "ephemeral" },
}, },
bedrock: { bedrock: {
cachePoint: { type: "ephemeral" }, cachePoint: { type: "default" },
}, },
openaiCompatible: { openaiCompatible: {
cache_control: { type: "ephemeral" }, cache_control: { type: "ephemeral" },
@ -190,7 +190,8 @@ export namespace ProviderTransform {
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) { if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1] const lastContent = msg.content[msg.content.length - 1]
@ -394,31 +395,6 @@ export namespace ProviderTransform {
case "@ai-sdk/deepinfra": case "@ai-sdk/deepinfra":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
case "@ai-sdk/openai-compatible": case "@ai-sdk/openai-compatible":
// When using openai-compatible SDK with Claude/Anthropic models,
// we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
// and the OpenAI-compatible API spec uses snake_case
if (
model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude")
) {
return {
high: {
thinking: {
type: "enabled",
budget_tokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budget_tokens: 31999,
},
},
}
}
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure": case "@ai-sdk/azure":
@ -718,21 +694,9 @@ export namespace ProviderTransform {
const modelCap = modelLimit || globalLimit const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit) const standardLimit = Math.min(modelCap, globalLimit)
// Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens) if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
// and @ai-sdk/openai-compatible with Claude (budget_tokens)
if (
npm === "@ai-sdk/anthropic" ||
npm === "@ai-sdk/google-vertex/anthropic" ||
npm === "@ai-sdk/openai-compatible"
) {
const thinking = options?.["thinking"] const thinking = options?.["thinking"]
// Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible) const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
const budgetTokens =
typeof thinking?.["budgetTokens"] === "number"
? thinking["budgetTokens"]
: typeof thinking?.["budget_tokens"] === "number"
? thinking["budget_tokens"]
: 0
const enabled = thinking?.["type"] === "enabled" const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) { if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.

View file

@ -185,12 +185,15 @@ export namespace Server {
}, },
) )
.use(async (c, next) => { .use(async (c, next) => {
let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() if (c.req.path === "/log") return next()
try { const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
directory = decodeURIComponent(directory) const directory = (() => {
} catch { try {
// fallback to original value return decodeURIComponent(raw)
} } catch {
return raw
}
})()
return Instance.provide({ return Instance.provide({
directory, directory,
init: InstanceBootstrap, init: InstanceBootstrap,

View file

@ -505,17 +505,23 @@ export namespace Session {
export function* list() { export function* list() {
const project = Instance.project const project = Instance.project
const rows = Database.use((db) => for (const item of await Storage.list(["session", project.id])) {
db.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all(), const session = await Storage.read<Info>(item).catch(() => undefined)
) if (!session) continue
for (const row of rows) { yield session
yield fromRow(row)
} }
} }
export const children = fn(Identifier.schema("session"), async (parentID) => { export const children = fn(Identifier.schema("session"), async (parentID) => {
const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all()) const project = Instance.project
return rows.map((row) => fromRow(row)) const result = [] as Session.Info[]
for (const item of await Storage.list(["session", project.id])) {
const session = await Storage.read<Info>(item).catch(() => undefined)
if (!session) continue
if (session.parentID !== parentID) continue
result.push(session)
}
return result
}) })
export const remove = fn(Identifier.schema("session"), async (sessionID) => { export const remove = fn(Identifier.schema("session"), async (sessionID) => {

View file

@ -75,7 +75,9 @@ export namespace InstructionPrompt {
for (const file of FILES) { for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) { if (matches.length > 0) {
matches.forEach((p) => paths.add(path.resolve(p))) matches.forEach((p) => {
paths.add(path.resolve(p))
})
break break
} }
} }
@ -103,7 +105,9 @@ export namespace InstructionPrompt {
}), }),
).catch(() => []) ).catch(() => [])
: await resolveRelative(instruction) : await resolveRelative(instruction)
matches.forEach((p) => paths.add(path.resolve(p))) matches.forEach((p) => {
paths.add(path.resolve(p))
})
} }
} }
@ -168,12 +172,14 @@ export namespace InstructionPrompt {
const already = loaded(messages) const already = loaded(messages)
const results: { filepath: string; content: string }[] = [] const results: { filepath: string; content: string }[] = []
let current = path.dirname(path.resolve(filepath)) const target = path.resolve(filepath)
let current = path.dirname(target)
const root = path.resolve(Instance.directory) const root = path.resolve(Instance.directory)
while (current.startsWith(root)) { while (current.startsWith(root) && current !== root) {
const found = await find(current) const found = await find(current)
if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found) claim(messageID, found)
const content = await Bun.file(found) const content = await Bun.file(found)
.text() .text()
@ -182,7 +188,6 @@ export namespace InstructionPrompt {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
} }
} }
if (current === root) break
current = path.dirname(current) current = path.dirname(current)
} }

View file

@ -233,19 +233,12 @@ export namespace LLM {
}, },
maxRetries: input.retries ?? 0, maxRetries: input.retries ?? 0,
messages: [ messages: [
...(isCodex ...system.map(
? [ (x): ModelMessage => ({
{ role: "system",
role: "user", content: x,
content: system.join("\n\n"), }),
} as ModelMessage, ),
]
: system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
)),
...input.messages, ...input.messages,
], ],
model: wrapLanguageModel({ model: wrapLanguageModel({

View file

@ -180,6 +180,14 @@ export namespace SessionProcessor {
case "tool-result": { case "tool-result": {
const match = toolcalls[value.toolCallId] const match = toolcalls[value.toolCallId]
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
const attachments = value.output.attachments?.map(
(attachment: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: match.messageID,
sessionID: match.sessionID,
}),
)
await Session.updatePart({ await Session.updatePart({
...match, ...match,
state: { state: {
@ -192,7 +200,7 @@ export namespace SessionProcessor {
start: match.state.time.start, start: match.state.time.start,
end: Date.now(), end: Date.now(),
}, },
attachments: value.output.attachments, attachments,
}, },
}) })

View file

@ -185,13 +185,17 @@ export namespace SessionPrompt {
text: template, text: template,
}, },
] ]
const files = ConfigMarkdown.files(template) const matches = ConfigMarkdown.files(template)
const seen = new Set<string>() const seen = new Set<string>()
await Promise.all( const names = matches
files.map(async (match) => { .map((match) => match[1])
const name = match[1] .filter((name) => {
if (seen.has(name)) return if (seen.has(name)) return false
seen.add(name) seen.add(name)
return true
})
const resolved = await Promise.all(
names.map(async (name) => {
const filepath = name.startsWith("~/") const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2)) ? path.join(os.homedir(), name.slice(2))
: path.resolve(Instance.worktree, name) : path.resolve(Instance.worktree, name)
@ -199,33 +203,34 @@ export namespace SessionPrompt {
const stats = await fs.stat(filepath).catch(() => undefined) const stats = await fs.stat(filepath).catch(() => undefined)
if (!stats) { if (!stats) {
const agent = await Agent.get(name) const agent = await Agent.get(name)
if (agent) { if (!agent) return undefined
parts.push({ return {
type: "agent", type: "agent",
name: agent.name, name: agent.name,
}) } satisfies PromptInput["parts"][number]
}
return
} }
if (stats.isDirectory()) { if (stats.isDirectory()) {
parts.push({ return {
type: "file", type: "file",
url: `file://${filepath}`, url: `file://${filepath}`,
filename: name, filename: name,
mime: "application/x-directory", mime: "application/x-directory",
}) } satisfies PromptInput["parts"][number]
return
} }
parts.push({ return {
type: "file", type: "file",
url: `file://${filepath}`, url: `file://${filepath}`,
filename: name, filename: name,
mime: "text/plain", mime: "text/plain",
}) } satisfies PromptInput["parts"][number]
}), }),
) )
for (const item of resolved) {
if (!item) continue
parts.push(item)
}
return parts return parts
} }
@ -422,6 +427,12 @@ export namespace SessionPrompt {
assistantMessage.time.completed = Date.now() assistantMessage.time.completed = Date.now()
await Session.updateMessage(assistantMessage) await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") { if (result && part.state.status === "running") {
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: assistantMessage.id,
sessionID: assistantMessage.sessionID,
}))
await Session.updatePart({ await Session.updatePart({
...part, ...part,
state: { state: {
@ -430,7 +441,7 @@ export namespace SessionPrompt {
title: result.title, title: result.title,
metadata: result.metadata, metadata: result.metadata,
output: result.output, output: result.output,
attachments: result.attachments, attachments,
time: { time: {
...part.state.time, ...part.state.time,
end: Date.now(), end: Date.now(),
@ -769,16 +780,13 @@ export namespace SessionPrompt {
) )
const textParts: string[] = [] const textParts: string[] = []
const attachments: MessageV2.FilePart[] = [] const attachments: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">[] = []
for (const contentItem of result.content) { for (const contentItem of result.content) {
if (contentItem.type === "text") { if (contentItem.type === "text") {
textParts.push(contentItem.text) textParts.push(contentItem.text)
} else if (contentItem.type === "image") { } else if (contentItem.type === "image") {
attachments.push({ attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file", type: "file",
mime: contentItem.mimeType, mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`, url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@ -790,9 +798,6 @@ export namespace SessionPrompt {
} }
if (resource.blob) { if (resource.blob) {
attachments.push({ attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file", type: "file",
mime: resource.mimeType ?? "application/octet-stream", mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
@ -825,6 +830,17 @@ export namespace SessionPrompt {
async function createUserMessage(input: PromptInput) { async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const variant =
input.variant ??
(agent.variant &&
agent.model &&
model.providerID === agent.model.providerID &&
model.modelID === agent.model.modelID
? agent.variant
: undefined)
const info: MessageV2.Info = { const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"), id: input.messageID ?? Identifier.ascending("message"),
role: "user", role: "user",
@ -834,9 +850,9 @@ export namespace SessionPrompt {
}, },
tools: input.tools, tools: input.tools,
agent: agent.name, agent: agent.name,
model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), model,
system: input.system, system: input.system,
variant: input.variant, variant,
} }
using _ = defer(() => InstructionPrompt.clear(info.id)) using _ = defer(() => InstructionPrompt.clear(info.id))
@ -1030,6 +1046,7 @@ export namespace SessionPrompt {
pieces.push( pieces.push(
...result.attachments.map((attachment) => ({ ...result.attachments.map((attachment) => ({
...attachment, ...attachment,
id: Identifier.ascending("part"),
synthetic: true, synthetic: true,
filename: attachment.filename ?? part.filename, filename: attachment.filename ?? part.filename,
messageID: info.id, messageID: info.id,
@ -1167,7 +1184,18 @@ export namespace SessionPrompt {
}, },
] ]
}), }),
).then((x) => x.flat()) )
.then((x) => x.flat())
.then((drafts) =>
drafts.map(
(part): MessageV2.Part => ({
...part,
id: Identifier.ascending("part"),
messageID: info.id,
sessionID: input.sessionID,
}),
),
)
await Plugin.trigger( await Plugin.trigger(
"chat.message", "chat.message",

View file

@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
}) })
const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
messageID: ctx.messageID,
sessionID: ctx.sessionID,
}))
await Session.updatePart({ await Session.updatePart({
id: partID, id: partID,
@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
output: result.output, output: result.output,
title: result.title, title: result.title,
metadata: result.metadata, metadata: result.metadata,
attachments: result.attachments, attachments,
time: { time: {
start: callStartTime, start: callStartTime,
end: Date.now(), end: Date.now(),

View file

@ -6,7 +6,6 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction" import { InstructionPrompt } from "../session/instruction"
@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", {
}, },
attachments: [ attachments: [
{ {
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
type: "file", type: "file",
mime, mime,
url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,

View file

@ -36,7 +36,7 @@ export namespace Tool {
title: string title: string
metadata: M metadata: M
output: string output: string
attachments?: MessageV2.FilePart[] attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
}> }>
formatValidationError?(error: z.ZodError): string formatValidationError?(error: z.ZodError): string
}> }>

View file

@ -220,6 +220,13 @@ export namespace Worktree {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n") return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
} }
async function canonical(input: string) {
const abs = path.resolve(input)
const real = await fs.realpath(abs).catch(() => abs)
const normalized = path.normalize(real)
return process.platform === "win32" ? normalized.toLowerCase() : normalized
}
async function candidate(root: string, base?: string) { async function candidate(root: string, base?: string) {
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
@ -376,7 +383,7 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" }) throw new NotGitError({ message: "Worktrees are only supported for git projects" })
} }
const directory = path.resolve(input.directory) const directory = await canonical(input.directory)
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) { if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@ -399,7 +406,13 @@ export namespace Worktree {
return acc return acc
}, []) }, [])
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) const entry = await (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
if (!entry?.path) { if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" }) throw new RemoveFailedError({ message: "Worktree not found" })
} }
@ -425,8 +438,9 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" }) throw new NotGitError({ message: "Worktrees are only supported for git projects" })
} }
const directory = path.resolve(input.directory) const directory = await canonical(input.directory)
if (directory === path.resolve(Instance.worktree)) { const primary = await canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
} }
@ -452,7 +466,13 @@ export namespace Worktree {
return acc return acc
}, []) }, [])
const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) const entry = await (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
if (!entry?.path) { if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" }) throw new ResetFailedError({ message: "Worktree not found" })
} }

View file

@ -119,13 +119,38 @@ describe("transcript", () => {
}, },
} }
const result = formatPart(part, options) const result = formatPart(part, options)
expect(result).toContain("Tool: bash") expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Input:**") expect(result).toContain("**Input:**")
expect(result).toContain('"command": "ls"') expect(result).toContain('"command": "ls"')
expect(result).toContain("**Output:**") expect(result).toContain("**Output:**")
expect(result).toContain("file1.txt") expect(result).toContain("file1.txt")
}) })
test("formats tool output containing triple backticks without breaking markdown", () => {
const part: Part = {
id: "part_1",
sessionID: "ses_123",
messageID: "msg_123",
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: { command: "echo '```hello```'" },
output: "```hello```",
title: "Echo backticks",
metadata: {},
time: { start: 1000, end: 1100 },
},
}
const result = formatPart(part, options)
// The tool header should not be inside a code block
expect(result).toStartWith("**Tool: bash**\n")
// Input and output should each be in their own code blocks
expect(result).toContain("**Input:**\n```json")
expect(result).toContain("**Output:**\n```\n```hello```\n```")
})
test("formats tool part without details when disabled", () => { test("formats tool part without details when disabled", () => {
const part: Part = { const part: Part = {
id: "part_1", id: "part_1",
@ -144,7 +169,7 @@ describe("transcript", () => {
}, },
} }
const result = formatPart(part, { ...options, toolDetails: false }) const result = formatPart(part, { ...options, toolDetails: false })
expect(result).toContain("Tool: bash") expect(result).toContain("**Tool: bash**")
expect(result).not.toContain("**Input:**") expect(result).not.toContain("**Input:**")
expect(result).not.toContain("**Output:**") expect(result).not.toContain("**Output:**")
}) })

View file

@ -255,6 +255,37 @@ test("handles agent configuration", async () => {
}) })
}) })
test("treats agent variant as model-scoped setting (not provider option)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "openai/gpt-5.2",
variant: "xhigh",
max_tokens: 123,
},
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
expect(agent?.options).toMatchObject({
max_tokens: 123,
})
expect(agent?.options).not.toHaveProperty("variant")
},
})
})
test("handles command configuration", async () => { test("handles command configuration", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep"
describe("file.ripgrep", () => {
test("defaults to include hidden", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "visible.txt"), "hello")
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
const hasVisible = files.includes("visible.txt")
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(true)
})
test("hidden false excludes hidden", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "visible.txt"), "hello")
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
},
})
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
const hasVisible = files.includes("visible.txt")
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false)
})
})

View file

@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
describe("plugin.auth-override", () => {
test("user plugin overrides built-in github-copilot auth", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
"export default async () => ({",
" auth: {",
' provider: "github-copilot",',
" methods: [",
' { type: "api", label: "Test Override Auth" },',
" ],",
" loader: async () => ({ access: 'test-token' }),",
" },",
"})",
"",
].join("\n"),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const methods = await ProviderAuth.methods()
const copilot = methods["github-copilot"]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
},
})
}, 30000) // Increased timeout for plugin installation
})

View file

@ -354,7 +354,7 @@ describe("tool calls", () => {
}) })
describe("reasoning (copilot-specific)", () => { describe("reasoning (copilot-specific)", () => {
test("should include reasoning_text from reasoning part", () => { test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([ const result = convertToCopilotMessages([
{ {
role: "assistant", role: "assistant",
@ -370,7 +370,7 @@ describe("reasoning (copilot-specific)", () => {
role: "assistant", role: "assistant",
content: "The answer is 42.", content: "The answer is 42.",
tool_calls: undefined, tool_calls: undefined,
reasoning_text: "Let me think about this...", reasoning_text: undefined,
reasoning_opaque: undefined, reasoning_opaque: undefined,
}, },
]) ])
@ -404,6 +404,33 @@ describe("reasoning (copilot-specific)", () => {
]) ])
}) })
test("should include reasoning_opaque from text part providerOptions", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
content: [
{
type: "text",
text: "Done!",
providerOptions: {
copilot: { reasoningOpaque: "opaque-text-456" },
},
},
],
},
])
expect(result).toEqual([
{
role: "assistant",
content: "Done!",
tool_calls: undefined,
reasoning_text: undefined,
reasoning_opaque: "opaque-text-456",
},
])
})
test("should handle reasoning-only assistant message", () => { test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([ const result = convertToCopilotMessages([
{ {

View file

@ -65,6 +65,12 @@ const FIXTURES = {
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`, `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`, `data: [DONE]`,
], ],
reasoningOpaqueWithToolCallsNoReasoningText: [
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
`data: [DONE]`,
],
} }
function createMockFetch(chunks: string[]) { function createMockFetch(chunks: string[]) {
@ -447,6 +453,35 @@ describe("doStream", () => {
}) })
}) })
test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
const model = createModel(mockFetch)
const { stream } = await model.doStream({
prompt: TEST_PROMPT,
includeRawChunks: false,
})
const parts = await convertReadableStreamToArray(stream)
const reasoningParts = parts.filter(
(p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
)
expect(reasoningParts).toHaveLength(0)
const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
expect(toolCall).toMatchObject({
type: "tool-call",
toolCallId: "call_reasoning_only",
toolName: "read_file",
providerMetadata: {
copilot: {
reasoningOpaque: "opaque-xyz",
},
},
})
})
test("should include response metadata from first chunk", async () => { test("should include response metadata from first chunk", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText) const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch) const model = createModel(mockFetch)

View file

@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => {
expect(result).toBe(OUTPUT_TOKEN_MAX) expect(result).toBe(OUTPUT_TOKEN_MAX)
}) })
}) })
describe("openai-compatible with thinking options (snake_case)", () => {
test("returns 32k when budget_tokens + 32k <= modelLimit", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "enabled",
budget_tokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens(
"@ai-sdk/openai-compatible",
options,
modelLimit,
OUTPUT_TOKEN_MAX,
)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => {
const modelLimit = 50000
const options = {
thinking: {
type: "enabled",
budget_tokens: 30000,
},
}
const result = ProviderTransform.maxOutputTokens(
"@ai-sdk/openai-compatible",
options,
modelLimit,
OUTPUT_TOKEN_MAX,
)
expect(result).toBe(20000)
})
test("returns 32k when thinking type is not enabled", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "disabled",
budget_tokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens(
"@ai-sdk/openai-compatible",
options,
modelLimit,
OUTPUT_TOKEN_MAX,
)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns 32k when budget_tokens is 0", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "enabled",
budget_tokens: 0,
},
}
const result = ProviderTransform.maxOutputTokens(
"@ai-sdk/openai-compatible",
options,
modelLimit,
OUTPUT_TOKEN_MAX,
)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
})
}) })
describe("ProviderTransform.schema - gemini array items", () => { describe("ProviderTransform.schema - gemini array items", () => {
@ -1166,7 +1096,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
expect(result[0].providerOptions?.bedrock).toEqual( expect(result[0].providerOptions?.bedrock).toEqual(
expect.objectContaining({ expect.objectContaining({
cachePoint: { cachePoint: {
type: "ephemeral", type: "default",
}, },
}), }),
) )
@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => {
expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" }) expect(result.high).toEqual({ reasoningEffort: "high" })
}) })
test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4-5",
providerID: "anthropic",
api: {
id: "claude-sonnet-4-5-20250929",
url: "http://localhost:4000",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinking: {
type: "enabled",
budget_tokens: 16000,
},
})
expect(result.max).toEqual({
thinking: {
type: "enabled",
budget_tokens: 31999,
},
})
})
test("Claude model (by model.id) via openai-compatible uses snake_case", () => {
const model = createMockModel({
id: "litellm/claude-3-opus",
providerID: "litellm",
api: {
id: "claude-3-opus-20240229",
url: "http://localhost:4000",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinking: {
type: "enabled",
budget_tokens: 16000,
},
})
})
test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => {
const model = createMockModel({
id: "custom/my-model",
providerID: "custom",
api: {
id: "anthropic.claude-sonnet",
url: "http://localhost:4000",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high.thinking.budget_tokens).toBe(16000)
})
}) })
describe("@ai-sdk/azure", () => { describe("@ai-sdk/azure", () => {

View file

@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => {
}, },
}) })
}) })
test("doesn't reload AGENTS.md when reading it directly", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
const system = await InstructionPrompt.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
expect(results).toEqual([])
},
})
})
}) })

View file

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

View file

@ -0,0 +1,62 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("SessionPrompt ordering", () => {
test("keeps @file order with read output parts", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "28\n")
await Bun.write(path.join(dir, "b.txt"), "42\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "What numbers are written in files @a.txt and @b.txt ?"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"])
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const items = stored.parts
const aPath = path.join(tmp.path, "a.txt")
const bPath = path.join(tmp.path, "b.txt")
const sequence = items.flatMap((part) => {
if (part.type === "text") {
if (part.text.includes(aPath)) return ["input:a"]
if (part.text.includes(bPath)) return ["input:b"]
if (part.text.includes("00001| 28")) return ["output:a"]
if (part.text.includes("00001| 42")) return ["output:b"]
return []
}
if (part.type === "file") {
if (part.filename === "a.txt") return ["file:a"]
if (part.filename === "b.txt") return ["file:b"]
}
return []
})
expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"])
await Session.remove(session.id)
},
})
})
})

View file

@ -9,8 +9,14 @@
"build": "tsc" "build": "tsc"
}, },
"exports": { "exports": {
".": "./src/index.ts", ".": {
"./tool": "./src/tool.ts" "types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./tool": {
"types": "./dist/tool.d.ts",
"import": "./dist/tool.js"
}
}, },
"files": [ "files": [
"dist" "dist"

View file

@ -46,6 +46,20 @@ const VERSION = await (async () => {
return `${major}.${minor}.${patch + 1}` return `${major}.${minor}.${patch + 1}`
})() })()
const team = [
"actions-user",
"opencode",
"rekram1-node",
"thdxr",
"kommander",
"jayair",
"fwang",
"adamdotdevin",
"iamdavidhill",
"opencode-agent[bot]",
"R44VC0RP",
]
export const Script = { export const Script = {
get channel() { get channel() {
return CHANNEL return CHANNEL
@ -59,5 +73,8 @@ export const Script = {
get release() { get release() {
return env.OPENCODE_RELEASE return env.OPENCODE_RELEASE
}, },
get team() {
return team
},
} }
console.log(`opencode script`, JSON.stringify(Script, null, 2)) console.log(`opencode script`, JSON.stringify(Script, null, 2))

View file

@ -1554,7 +1554,7 @@ export type FileNode = {
} }
export type FileContent = { export type FileContent = {
type: "text" type: "text" | "binary"
content: string content: string
diff?: string diff?: string
patch?: { patch?: {

View file

@ -1378,6 +1378,10 @@ export type PermissionConfig =
export type AgentConfig = { export type AgentConfig = {
model?: string model?: string
/**
* Default model variant for this agent (applies only when using the agent's configured model).
*/
variant?: string
temperature?: number temperature?: number
top_p?: number top_p?: number
prompt?: string prompt?: string
@ -2049,7 +2053,7 @@ export type FileNode = {
} }
export type FileContent = { export type FileContent = {
type: "text" type: "text" | "binary"
content: string content: string
diff?: string diff?: string
patch?: { patch?: {
@ -2143,6 +2147,7 @@ export type Agent = {
modelID: string modelID: string
providerID: string providerID: string
} }
variant?: string
prompt?: string prompt?: string
options: { options: {
[key: string]: unknown [key: string]: unknown

View file

@ -9044,6 +9044,10 @@
"model": { "model": {
"type": "string" "type": "string"
}, },
"variant": {
"description": "Default model variant for this agent (applies only when using the agent's configured model).",
"type": "string"
},
"temperature": { "temperature": {
"type": "number" "type": "number"
}, },
@ -10591,7 +10595,7 @@
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
"const": "text" "enum": ["text", "binary"]
}, },
"content": { "content": {
"type": "string" "type": "string"
@ -10869,6 +10873,9 @@
}, },
"required": ["modelID", "providerID"] "required": ["modelID", "providerID"]
}, },
"variant": {
"type": "string"
},
"prompt": { "prompt": {
"type": "string" "type": "string"
}, },

View file

@ -9,7 +9,13 @@
user-select: none; user-select: none;
cursor: default; cursor: default;
outline: none; outline: none;
padding: 4px 8px;
white-space: nowrap; white-space: nowrap;
transition-property: background-color, border-color, color, box-shadow, opacity;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
outline: none;
line-height: 20px;
&[data-variant="primary"] { &[data-variant="primary"] {
background-color: var(--button-primary-base); background-color: var(--button-primary-base);
@ -94,7 +100,6 @@
&:active:not(:disabled) { &:active:not(:disabled) {
background-color: var(--button-secondary-base); background-color: var(--button-secondary-base);
scale: 0.99; scale: 0.99;
transition: all 150ms ease-out;
} }
&:disabled { &:disabled {
border-color: var(--border-disabled); border-color: var(--border-disabled);
@ -109,34 +114,31 @@
} }
&[data-size="small"] { &[data-size="small"] {
height: 22px; padding: 4px 8px;
padding: 0 8px;
&[data-icon] { &[data-icon] {
padding: 0 12px 0 4px; padding: 4px 12px 4px 4px;
} }
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 4px; gap: 4px;
/* text-12-medium */ /* text-12-medium */
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-small); font-size: var(--font-size-base);
font-style: normal; font-style: normal;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
} }
&[data-size="normal"] { &[data-size="normal"] {
height: 24px; padding: 4px 6px;
line-height: 24px;
padding: 0 6px;
&[data-icon] { &[data-icon] {
padding: 0 12px 0 4px; padding: 4px 12px 4px 4px;
}
&[aria-haspopup] {
padding: 4px 6px 4px 8px;
} }
font-size: var(--font-size-small);
gap: 6px; gap: 6px;
/* text-12-medium */ /* text-12-medium */
@ -148,7 +150,6 @@
} }
&[data-size="large"] { &[data-size="large"] {
height: 32px;
padding: 6px 12px; padding: 6px 12px;
&[data-icon] { &[data-icon] {

View file

@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
export interface ButtonProps export interface ButtonProps
extends ComponentProps<typeof Kobalte>, extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> { Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
size?: "small" | "normal" | "large" size?: "small" | "normal" | "large"
variant?: "primary" | "secondary" | "ghost" variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"] icon?: IconProps["name"]

View file

@ -0,0 +1,49 @@
.cycle-label {
--c-duration: 200ms;
--c-stagger: 30ms;
--c-opacity-start: 0;
--c-opacity-end: 1;
--c-blur-start: 0px;
--c-blur-end: 0px;
--c-skew: 10deg;
display: inline-flex;
position: relative;
transform-style: preserve-3d;
perspective: 500px;
transition: width var(--transition-duration) var(--transition-easing);
will-change: width;
overflow: hidden;
.cycle-char {
display: inline-block;
transform-style: preserve-3d;
min-width: 0.25em;
backface-visibility: hidden;
transition-property: transform, opacity, filter;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
transition-delay: calc(var(--i, 0) * var(--c-stagger));
&.enter {
opacity: var(--c-opacity-end);
filter: blur(var(--c-blur-end));
transform: translateY(0) rotateX(0) skewX(0);
}
&.exit {
opacity: var(--c-opacity-start);
filter: blur(var(--c-blur-start));
transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
}
&.pre {
opacity: var(--c-opacity-start);
filter: blur(var(--c-blur-start));
transition: none;
transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
}
}
}

View file

@ -0,0 +1,135 @@
import "./cycle-label.css"
import { createEffect, createSignal, JSX, on } from "solid-js"
export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
value: string
onValueChange?: (value: string) => void
duration?: number | ((value: string) => number)
stagger?: number
opacity?: [number, number]
blur?: [number, number]
skewX?: number
onAnimationStart?: () => void
onAnimationEnd?: () => void
}
const segmenter =
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
const getChars = (text: string): string[] =>
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export function CycleLabel(props: CycleLabelProps) {
const getDuration = (text: string) => {
const d =
props.duration ??
Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
200
return typeof d === "function" ? d(text) : d
}
const stagger = () => props?.stagger ?? 30
const opacity = () => props?.opacity ?? [0, 1]
const blur = () => props?.blur ?? [0, 0]
const skewX = () => props?.skewX ?? 10
let containerRef: HTMLSpanElement | undefined
let isAnimating = false
const [currentText, setCurrentText] = createSignal(props.value)
const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
el.innerHTML = ""
const chars = getChars(text)
chars.forEach((char, i) => {
const span = document.createElement("span")
span.textContent = char === " " ? "\u00A0" : char
span.className = `cycle-char ${state}`
span.style.setProperty("--i", String(i))
el.appendChild(span)
})
}
const animateToText = async (newText: string) => {
if (!containerRef || isAnimating) return
if (newText === currentText()) return
isAnimating = true
props.onAnimationStart?.()
const dur = getDuration(newText)
const stag = stagger()
containerRef.style.width = containerRef.offsetWidth + "px"
const oldChars = containerRef.querySelectorAll(".cycle-char")
oldChars.forEach((c) => c.classList.replace("enter", "exit"))
const clone = containerRef.cloneNode(false) as HTMLElement
Object.assign(clone.style, {
position: "absolute",
visibility: "hidden",
width: "auto",
transition: "none",
})
setChars(clone, newText)
document.body.appendChild(clone)
const nextWidth = clone.offsetWidth
clone.remove()
const exitTime = oldChars.length * stag + dur
await wait(exitTime * 0.3)
containerRef.style.width = nextWidth + "px"
const widthDur = 200
await wait(widthDur * 0.3)
setChars(containerRef, newText, "pre")
containerRef.offsetWidth
Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
setCurrentText(newText)
props.onValueChange?.(newText)
const enterTime = getChars(newText).length * stag + dur
await wait(enterTime)
containerRef.style.width = ""
isAnimating = false
props.onAnimationEnd?.()
}
createEffect(
on(
() => props.value,
(newValue) => {
if (newValue !== currentText()) {
animateToText(newValue)
}
},
),
)
const initRef = (el: HTMLSpanElement) => {
containerRef = el
setChars(el, props.value)
}
return (
<span
ref={initRef}
class={`cycle-label ${props.class ?? ""}`}
style={{
"--c-duration": `${getDuration(currentText())}ms`,
"--c-stagger": `${stagger()}ms`,
"--c-opacity-start": opacity()[0],
"--c-opacity-end": opacity()[1],
"--c-blur-start": `${blur()[0]}px`,
"--c-blur-end": `${blur()[1]}px`,
"--c-skew": `${skewX()}deg`,
...(typeof props.style === "object" ? props.style : {}),
}}
/>
)
}

View file

@ -2,26 +2,29 @@
[data-component="dropdown-menu-sub-content"] { [data-component="dropdown-menu-sub-content"] {
min-width: 8rem; min-width: 8rem;
overflow: hidden; overflow: hidden;
border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); box-shadow: var(--shadow-xs-border);
background-clip: padding-box; background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha); background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px; padding: 4px;
box-shadow: var(--shadow-md); z-index: 100;
z-index: 50;
transform-origin: var(--kb-menu-content-transform-origin); transform-origin: var(--kb-menu-content-transform-origin);
&:focus, &:focus-within,
&:focus-visible { &:focus {
outline: none; outline: none;
} }
&[data-closed] { animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
animation: dropdown-menu-close 0.15s ease-out;
@starting-style {
animation: none;
} }
&[data-expanded] { &[data-expanded] {
animation: dropdown-menu-open 0.15s ease-out; pointer-events: auto;
animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
} }
} }
@ -38,18 +41,22 @@
padding: 4px 8px; padding: 4px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: default; cursor: default;
user-select: none;
outline: none; outline: none;
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-small); font-size: var(--font-size-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--text-strong);
&[data-highlighted] { transition-property: background-color, color;
background: var(--surface-raised-base-hover); transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
user-select: none;
&:hover {
background-color: var(--surface-raised-base-hover);
} }
&[data-disabled] { &[data-disabled] {
@ -61,6 +68,8 @@
[data-slot="dropdown-menu-sub-trigger"] { [data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] { &[data-expanded] {
background: var(--surface-raised-base-hover); background: var(--surface-raised-base-hover);
outline: none;
border: none;
} }
} }
@ -102,24 +111,24 @@
} }
} }
@keyframes dropdown-menu-open { @keyframes dropdownMenuContentShow {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.96); transform: scaleY(0.95);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scaleY(1);
} }
} }
@keyframes dropdown-menu-close { @keyframes dropdownMenuContentHide {
from { from {
opacity: 1; opacity: 1;
transform: scale(1); transform: scaleY(1);
} }
to { to {
opacity: 0; opacity: 0;
transform: scale(0.96); transform: scaleY(0.95);
} }
} }

View file

@ -80,13 +80,16 @@ const icons = {
export interface IconProps extends ComponentProps<"svg"> { export interface IconProps extends ComponentProps<"svg"> {
name: keyof typeof icons name: keyof typeof icons
size?: "small" | "normal" | "medium" | "large" size?: "small" | "normal" | "medium" | "large" | number
} }
export function Icon(props: IconProps) { export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
return ( return (
<div data-component="icon" data-size={local.size || "normal"}> <div
data-component="icon"
data-size={typeof local.size !== "number" ? local.size || "normal" : `size-[${local.size}px]`}
>
<svg <svg
data-slot="icon-svg" data-slot="icon-svg"
classList={{ classList={{

View file

@ -1,25 +1,7 @@
@property --bottom-fade {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes scroll {
0% {
--bottom-fade: 20px;
}
90% {
--bottom-fade: 20px;
}
100% {
--bottom-fade: 0;
}
}
[data-component="list"] { [data-component="list"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
overflow: hidden; overflow: hidden;
padding: 0 12px; padding: 0 12px;
@ -37,7 +19,9 @@
flex-shrink: 0; flex-shrink: 0;
background-color: transparent; background-color: transparent;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s ease; transition-property: opacity;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
&:hover:not(:disabled), &:hover:not(:disabled),
&:focus-visible:not(:disabled), &:focus-visible:not(:disabled),
@ -88,7 +72,9 @@
height: 20px; height: 20px;
background-color: transparent; background-color: transparent;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s ease; transition-property: opacity;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
&:hover:not(:disabled), &:hover:not(:disabled),
&:focus-visible:not(:disabled), &:focus-visible:not(:disabled),
@ -131,15 +117,6 @@
gap: 12px; gap: 12px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
animation: scroll;
animation-timeline: --scroll;
scroll-timeline: --scroll y;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="list-empty-state"] { [data-slot="list-empty-state"] {
display: flex; display: flex;
@ -215,7 +192,9 @@
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease; transition-property: opacity;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
} }
&[data-stuck="true"]::after { &[data-stuck="true"]::after {
@ -251,17 +230,22 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
aspect-ratio: 1/1; aspect-ratio: 1 / 1;
[data-component="icon"] { [data-component="icon"] {
color: var(--icon-strong-base); color: var(--icon-strong-base);
} }
} }
[name="check"] {
color: var(--icon-strong-base);
}
[data-slot="list-item-active-icon"] { [data-slot="list-item-active-icon"] {
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
aspect-ratio: 1/1; aspect-ratio: 1 / 1;
[data-component="icon"] { [data-component="icon"] {
color: var(--icon-strong-base); color: var(--icon-strong-base);
} }

View file

@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n"
import { Icon, type IconProps } from "./icon" import { Icon, type IconProps } from "./icon"
import { IconButton } from "./icon-button" import { IconButton } from "./icon-button"
import { TextField } from "./text-field" import { TextField } from "./text-field"
import { ScrollFade } from "./scroll-fade"
function findByKey(container: HTMLElement, key: string) { function findByKey(container: HTMLElement, key: string) {
const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]') const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
@ -267,7 +268,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
{searchAction()} {searchAction()}
</div> </div>
</Show> </Show>
<div ref={setScrollRef} data-slot="list-scroll"> <ScrollFade ref={setScrollRef} direction="vertical" fadeStartSize={0} fadeEndSize={20} data-slot="list-scroll">
<Show <Show
when={flat().length > 0 || showAdd()} when={flat().length > 0 || showAdd()}
fallback={ fallback={
@ -339,7 +340,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
</div> </div>
</Show> </Show>
</Show> </Show>
</div> </ScrollFade>
</div> </div>
) )
} }

View file

@ -42,13 +42,13 @@ import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview" import { ImagePreview } from "./image-preview"
import { findLast } from "@opencode-ai/util/array"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip" import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button" import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks" import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { MorphChevron } from "./morph-chevron"
interface Diagnostic { interface Diagnostic {
range: { range: {
@ -415,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
toggleExpanded() toggleExpanded()
}} }}
> >
<Icon name="chevron-down" size="small" /> <MorphChevron expanded={expanded()} />
</button> </button>
<div data-slot="user-message-copy-wrapper"> <div data-slot="user-message-copy-wrapper">
<Tooltip <Tooltip
@ -898,7 +898,7 @@ ToolRegistry.register({
if (!sessionId) return undefined if (!sessionId) return undefined
// Find the tool part that matches the permission's callID // Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? [] const messages = data.store.message[sessionId] ?? []
const message = findLast(messages, (m) => m.id === perm.tool!.messageID) const message = messages.findLast((m) => m.id === perm.tool!.messageID)
if (!message) return undefined if (!message) return undefined
const parts = data.store.part[message.id] ?? [] const parts = data.store.part[message.id] ?? []
for (const part of parts) { for (const part of parts) {

View file

@ -0,0 +1,10 @@
[data-slot="morph-chevron-svg"] {
width: 16px;
height: 16px;
display: block;
fill: none;
stroke-width: 1.5;
stroke: currentcolor;
stroke-linecap: round;
stroke-linejoin: round;
}

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